feat(notifications): customizable per-channel JSON payload templates (#1805)

Add an optional Edit Payload editor to every notification channel
(Discord, Slack, Webhook, Apprise, ntfy). A saved template replaces the
built-in payload for that channel, with {{level}}, {{message}},
{{category}}, {{timestamp}}, {{stack_name}}, and {{actor}} substituted as
JSON-escaped values (variables may stand alone or be mixed into strings).
Templates are validated on save: known variables only, no unterminated
tokens, valid JSON after substitution, 8000 characters max. Apprise keeps
urls/tag managed by the channel fields and merges them server-side at
dispatch. ntfy publishes JSON instead of plain text when templated. Test
dispatch uses the editor template.
This commit is contained in:
Anso
2026-08-11 10:25:29 -04:00
committed by GitHub
parent 578ce7684d
commit 866d784316
16 changed files with 1508 additions and 33 deletions
+148
View File
@@ -6,6 +6,7 @@ import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vites
import request from 'supertest';
import bcrypt from 'bcrypt';
import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb';
import { PAYLOAD_TEMPLATE_MAX_LENGTH } from '../helpers/notificationPayloadTemplate';
let tmpDir: string;
let app: import('express').Express;
@@ -243,3 +244,150 @@ describe('Apprise agents - redaction and preserve-on-write', () => {
expect(JSON.stringify(res.body)).not.toContain('key-secret-value');
});
});
describe('payload templates', () => {
it('stores and returns a valid payload template', async () => {
const template = '{"title": "{{level}}", "body": "{{message}}"}';
const res = await request(app).post('/api/agents').set('Cookie', adminCookie).send({
type: 'discord',
url: 'https://discord.com/api/webhooks/1/token',
enabled: true,
payload_template: template,
});
expect(res.status).toBe(200);
const get = await request(app).get('/api/agents').set('Cookie', adminCookie);
expect(get.status).toBe(200);
expect(get.body[0].payload_template).toBe(template);
});
it('rejects unknown template variables with a named error', async () => {
const res = await request(app).post('/api/agents').set('Cookie', adminCookie).send({
type: 'discord',
url: 'https://discord.com/api/webhooks/1/token',
enabled: true,
payload_template: '{"a": "{{foo}}"}',
});
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/Unknown template variable: \{\{foo\}\}/);
});
it('rejects invalid JSON, non-strings, and over-length templates', async () => {
const base = {
type: 'discord',
url: 'https://discord.com/api/webhooks/1/token',
enabled: true,
};
const malformed = await request(app).post('/api/agents').set('Cookie', adminCookie).send({
...base, payload_template: '{',
});
expect(malformed.status).toBe(400);
expect(malformed.body.error).toContain('valid JSON');
const nonString = await request(app).post('/api/agents').set('Cookie', adminCookie).send({
...base, payload_template: 42,
});
expect(nonString.status).toBe(400);
expect(nonString.body.error).toContain('must be a string');
const over = await request(app).post('/api/agents').set('Cookie', adminCookie).send({
...base, payload_template: `{"msg":"${'x'.repeat(PAYLOAD_TEMPLATE_MAX_LENGTH - 9)}"}`,
});
expect(over.status).toBe(400);
expect(over.body.error).toContain('8000 characters or fewer');
const atLimit = await request(app).post('/api/agents').set('Cookie', adminCookie).send({
...base, payload_template: `{"msg":"${'x'.repeat(PAYLOAD_TEMPLATE_MAX_LENGTH - 10)}"}`,
});
expect(atLimit.status).toBe(200);
});
it('preserves the stored template when payload_template is omitted', async () => {
const db = DatabaseService.getInstance();
db.upsertAgent(1, {
type: 'slack',
url: 'https://hooks.slack.com/services/T/B/X',
enabled: true,
payload_template: '{"text": "{{message}}"}',
});
const res = await request(app).post('/api/agents').set('Cookie', adminCookie).send({
type: 'slack',
url: 'https://hooks.slack.com/services/T/B/X',
enabled: true,
});
expect(res.status).toBe(200);
expect(db.getAgents(1).find(a => a.type === 'slack')?.payload_template).toBe('{"text": "{{message}}"}');
});
it('clears the stored template with an empty string or null', async () => {
const db = DatabaseService.getInstance();
db.upsertAgent(1, {
type: 'slack',
url: 'https://hooks.slack.com/services/T/B/X',
enabled: true,
payload_template: '{"text": "{{message}}"}',
});
const cleared = await request(app).post('/api/agents').set('Cookie', adminCookie).send({
type: 'slack',
url: 'https://hooks.slack.com/services/T/B/X',
enabled: true,
payload_template: '',
});
expect(cleared.status).toBe(200);
expect(db.getAgents(1).find(a => a.type === 'slack')?.payload_template).toBeNull();
});
it('rejects a template write from a user without node:manage', async () => {
const res = await request(app).post('/api/agents').set('Cookie', viewerCookie).send({
type: 'discord',
url: 'https://discord.com/api/webhooks/1/token',
enabled: true,
payload_template: '{"a": "{{level}}"}',
});
expect(res.status).toBe(403);
});
it('rejects Apprise templates that carry urls, tag, or a non-object body', async () => {
const base = {
type: 'apprise',
url: 'http://apprise.local/notify',
enabled: true,
};
const withUrls = await request(app).post('/api/agents').set('Cookie', adminCookie).send({
...base, config: { urls: 'discord://token@id' }, payload_template: '{"urls": "discord://token@id"}',
});
expect(withUrls.status).toBe(400);
expect(withUrls.body.error).toContain('urls');
const withTag = await request(app).post('/api/agents').set('Cookie', adminCookie).send({
...base, config: { tags: 'ops' }, payload_template: '{"tag": "ops"}',
});
expect(withTag.status).toBe(400);
const scalar = await request(app).post('/api/agents').set('Cookie', adminCookie).send({
...base, config: { urls: 'discord://token@id' }, payload_template: '"{{message}}"',
});
expect(scalar.status).toBe(400);
expect(scalar.body.error).toContain('render a JSON object');
});
it('never exposes Apprise destination credentials through a templated agent', async () => {
const db = DatabaseService.getInstance();
const serviceUrl = 'discord://webhook-id/webhook-token?token=query-secret';
db.upsertAgent(1, {
type: 'apprise',
url: 'http://apprise.local/notify',
enabled: true,
config: JSON.stringify({ urls: serviceUrl }),
payload_template: '{"title": "{{level}}"}',
});
const res = await request(app).get('/api/agents').set('Cookie', adminCookie);
expect(res.status).toBe(200);
expect(res.body[0].payload_template).toBe('{"title": "{{level}}"}');
expect(JSON.stringify(res.body)).not.toContain('webhook-token');
expect(JSON.stringify(res.body)).not.toContain('query-secret');
});
});
+110
View File
@@ -7,6 +7,7 @@ import request from 'supertest';
import jwt from 'jsonwebtoken';
import bcrypt from 'bcrypt';
import { setupTestDb, cleanupTestDb, loginAsTestAdmin, TEST_JWT_SECRET } from './helpers/setupTestDb';
import { PAYLOAD_TEMPLATE_MAX_LENGTH } from '../helpers/notificationPayloadTemplate';
let tmpDir: string;
let app: import('express').Express;
@@ -641,4 +642,113 @@ describe('POST /api/notifications/test', () => {
vi.unstubAllGlobals();
}
});
it('dispatches a templated test with substituted variables', async () => {
const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 });
vi.stubGlobal('fetch', fetchMock);
try {
const res = await request(app)
.post('/api/notifications/test')
.set('Cookie', authCookie)
.send({
type: 'webhook',
url: 'https://example.com/hooks/sencho',
payload_template: '{"message": "{{message}}", "level": "{{level}}", "category": "{{category}}"}',
});
expect(res.status).toBe(200);
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(JSON.parse(String(init.body))).toEqual({
message: '🔌 Test Notification from Sencho!',
level: 'info',
category: 'system',
});
} finally {
vi.unstubAllGlobals();
}
});
it('rejects an unknown variable on a templated test without dispatching', async () => {
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
try {
const res = await request(app)
.post('/api/notifications/test')
.set('Cookie', authCookie)
.send({
type: 'webhook',
url: 'https://example.com/hooks/sencho',
payload_template: '{"a": "{{nope}}"}',
});
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/Unknown template variable/);
expect(fetchMock).not.toHaveBeenCalled();
} finally {
vi.unstubAllGlobals();
}
});
it('rejects an over-length template on a templated test', async () => {
const res = await request(app)
.post('/api/notifications/test')
.set('Cookie', authCookie)
.send({
type: 'webhook',
url: 'https://example.com/hooks/sencho',
payload_template: `{"msg":"${'x'.repeat(PAYLOAD_TEMPLATE_MAX_LENGTH - 9)}"}`,
});
expect(res.status).toBe(400);
expect(res.body.error).toContain('8000 characters or fewer');
});
it('keeps the built-in payload when the test template is blank', async () => {
const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 });
vi.stubGlobal('fetch', fetchMock);
try {
const res = await request(app)
.post('/api/notifications/test')
.set('Cookie', authCookie)
.send({ type: 'webhook', url: 'https://example.com/hooks/sencho', payload_template: ' ' });
expect(res.status).toBe(200);
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
const body = JSON.parse(String(init.body)) as { source?: string };
expect(body.source).toBe('sencho');
} finally {
vi.unstubAllGlobals();
}
});
it('merges Apprise destinations into a templated test dispatch', async () => {
const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 });
vi.stubGlobal('fetch', fetchMock);
try {
const res = await request(app)
.post('/api/notifications/test')
.set('Cookie', authCookie)
.send({
type: 'apprise',
url: 'http://apprise.local/notify/test-key',
config: { tags: 'ops' },
payload_template: '{"title": "{{level}}"}',
});
expect(res.status).toBe(200);
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(JSON.parse(String(init.body))).toEqual({ title: 'info', tag: 'ops' });
} finally {
vi.unstubAllGlobals();
}
});
it('rejects an Apprise templated test that carries urls', async () => {
const res = await request(app)
.post('/api/notifications/test')
.set('Cookie', authCookie)
.send({
type: 'apprise',
url: 'http://apprise.local/notify',
config: { urls: 'discord://token@id' },
payload_template: '{"urls": "discord://token@id"}',
});
expect(res.status).toBe(400);
expect(res.body.error).toContain('urls');
});
});
@@ -207,4 +207,34 @@ describe('Apprise secrets at rest (downgrade-safe)', () => {
expect(routes.find(r => r.name === 'discord-ok')!.channel_url).toContain('discord.com');
expect(routes.find(r => r.name === 'apprise-broken')!.channel_url).toBe('');
});
it('stores payload_template in plaintext while Apprise credentials stay encrypted', () => {
const db = DatabaseService.getInstance();
const keySecret = 'SuperSecretKey99';
const template = '{"title": "{{level}}", "body": "{{message}}"}';
db.upsertAgent(1, {
type: 'apprise',
url: `http://apprise.local/notify/${keySecret}`,
enabled: true,
config: '{}',
payload_template: template,
});
const raw = db.getDb().prepare('SELECT * FROM agents WHERE type = ?').get('apprise') as {
url: string;
config: string | null;
payload_template: string | null;
};
expect(raw.url).toMatch(/^enc:/);
expect(raw.url).not.toContain(keySecret);
expect(raw.config).toMatch(/^enc:/);
expect(raw.payload_template).toBe(template);
const apprise = db.getAgents(1).find(a => a.type === 'apprise')!;
expect(apprise.payload_template).toBe(template);
const pub = serializePublicAgent(apprise);
expect(pub.payload_template).toBe(template);
expect(JSON.stringify(pub)).not.toContain(keySecret);
});
});
@@ -105,4 +105,45 @@ describe('notification channel config column migration', () => {
expect(agent2.url).toBe('https://discord.example/webhook/legacy');
expect(agent2.config).toBeNull();
});
it('adds the payload_template column to an existing config-era agents schema', () => {
scratchDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sencho-tpl-mig-'));
const dbPath = path.join(scratchDir, 'sencho.db');
const seed = new Database(dbPath);
try {
seed.exec(`
CREATE TABLE agents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
node_id INTEGER NOT NULL DEFAULT 0,
type TEXT NOT NULL,
url TEXT NOT NULL,
enabled INTEGER DEFAULT 0,
config TEXT NULL
);
INSERT INTO agents (node_id, type, url, enabled, config)
VALUES (1, 'discord', 'https://discord.example/webhook/legacy', 1, NULL);
`);
} finally {
seed.close();
}
process.env.DATA_DIR = scratchDir;
resetDatabaseSingleton();
const db = DatabaseService.getInstance();
const agentCols = db.getDb().prepare('PRAGMA table_info(agents)').all() as Array<{ name: string }>;
expect(agentCols.filter(c => c.name === 'payload_template')).toHaveLength(1);
const legacy = db.getAgents(1).find(a => a.type === 'discord')!;
expect(legacy.url).toBe('https://discord.example/webhook/legacy');
expect(legacy.payload_template).toBeNull();
db.upsertAgent(1, {
type: 'discord',
url: 'https://discord.example/webhook/legacy',
enabled: true,
payload_template: '{"title": "{{level}}"}',
});
expect(db.getAgents(1).find(a => a.type === 'discord')!.payload_template).toBe('{"title": "{{level}}"}');
});
});
@@ -0,0 +1,164 @@
/**
* Per-agent notification payload template validation and rendering.
*/
import { describe, it, expect } from 'vitest';
import {
PAYLOAD_TEMPLATE_MAX_LENGTH,
assertPayloadTemplateAllowedForChannel,
renderPayloadTemplate,
templateTopLevelKeys,
validatePayloadTemplate,
} from '../helpers/notificationPayloadTemplate';
describe('validatePayloadTemplate', () => {
it('accepts undefined, null, and blank as null (built-in payload)', () => {
expect(validatePayloadTemplate(undefined)).toEqual({ ok: true, value: null });
expect(validatePayloadTemplate(null)).toEqual({ ok: true, value: null });
expect(validatePayloadTemplate('')).toEqual({ ok: true, value: null });
expect(validatePayloadTemplate(' \n ')).toEqual({ ok: true, value: null });
});
it('rejects non-string values', () => {
expect(validatePayloadTemplate(42)).toEqual({ ok: false, error: 'must be a string' });
expect(validatePayloadTemplate({ a: 1 })).toEqual({ ok: false, error: 'must be a string' });
});
it('accepts valid templates and returns the trimmed value', () => {
expect(validatePayloadTemplate('{"level": "{{level}}"}')).toEqual({
ok: true,
value: '{"level": "{{level}}"}',
});
expect(validatePayloadTemplate('"{{message}}"')).toEqual({ ok: true, value: '"{{message}}"' });
});
it('accepts nested JSON, quoted keys, arrays, and variables mixed into strings', () => {
expect(validatePayloadTemplate('{"a": {"b": "{{level}}"}}').ok).toBe(true);
expect(validatePayloadTemplate('{"{{level}}": 1}').ok).toBe(true);
expect(validatePayloadTemplate('["{{message}}"]').ok).toBe(true);
expect(validatePayloadTemplate('{"a": "pre {{level}} post"}').ok).toBe(true);
expect(validatePayloadTemplate('{"a": "{{level}} and {{message}}"}').ok).toBe(true);
expect(validatePayloadTemplate('{"a": "{{level}}!"}').ok).toBe(true);
expect(validatePayloadTemplate('{"empty": {}}').ok).toBe(true);
});
it('rejects unterminated or stray open variable tokens', () => {
const unclosed = validatePayloadTemplate('{"a": "{{message}"}');
expect(unclosed.ok).toBe(false);
if (!unclosed.ok) expect(unclosed.error).toContain('complete {{name}} token');
const strayOpen = validatePayloadTemplate('{"a": "{{"}');
expect(strayOpen.ok).toBe(false);
});
it('rejects a bare variable in a non-string value position', () => {
const result = validatePayloadTemplate('{"a": {{level}}}');
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toContain('valid JSON');
});
it('rejects unknown variables and names all of them', () => {
const result = validatePayloadTemplate('{"a": "{{foo}}"}');
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toContain('{{foo}}');
expect(result.error).toContain('Allowed variables: level, message, category, timestamp, stack_name, actor');
}
const multi = validatePayloadTemplate('{"a": "{{foo}}", "b": "{{bar}}"}');
expect(multi.ok).toBe(false);
if (!multi.ok) {
expect(multi.error).toContain('{{foo}}');
expect(multi.error).toContain('{{bar}}');
}
});
it('rejects malformed JSON', () => {
const result = validatePayloadTemplate('{');
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toContain('valid JSON');
});
it('enforces the length cap after trimming, before substitution', () => {
// '{"msg":"' is 8 chars and '"}' is 2, so the payload is 10 + repeats.
const atLimit = `{"msg":"${'x'.repeat(PAYLOAD_TEMPLATE_MAX_LENGTH - 10)}"}`;
expect(atLimit.length).toBe(PAYLOAD_TEMPLATE_MAX_LENGTH);
expect(validatePayloadTemplate(atLimit).ok).toBe(true);
const over = `{"msg":"${'x'.repeat(PAYLOAD_TEMPLATE_MAX_LENGTH - 9)}"}`;
expect(over.length).toBe(PAYLOAD_TEMPLATE_MAX_LENGTH + 1);
const result = validatePayloadTemplate(over);
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toContain(`${PAYLOAD_TEMPLATE_MAX_LENGTH} characters or fewer`);
});
});
describe('templateTopLevelKeys', () => {
it('returns the top-level keys of the placeholder-substituted object', () => {
expect(templateTopLevelKeys('{"a": 1, "b": "{{level}}"}')).toEqual(['a', 'b']);
});
it('returns an empty array for non-object or unparseable documents', () => {
expect(templateTopLevelKeys('"{{message}}"')).toEqual([]);
expect(templateTopLevelKeys('[1, 2]')).toEqual([]);
expect(templateTopLevelKeys('{')).toEqual([]);
expect(templateTopLevelKeys('{}')).toEqual([]);
});
});
describe('assertPayloadTemplateAllowedForChannel', () => {
it('allows any template on non-Apprise channels', () => {
expect(assertPayloadTemplateAllowedForChannel('{{message}}', 'discord')).toBeNull();
expect(assertPayloadTemplateAllowedForChannel('{"urls": "x"}', 'webhook')).toBeNull();
});
it('rejects Apprise templates carrying urls or tag', () => {
const urls = assertPayloadTemplateAllowedForChannel('{"urls": "discord://x"}', 'apprise');
expect(urls).toContain('urls');
const tag = assertPayloadTemplateAllowedForChannel('{"tag": "ops"}', 'apprise');
expect(tag).toContain('tag');
});
it('requires Apprise templates to render a non-empty JSON object', () => {
expect(assertPayloadTemplateAllowedForChannel('{{message}}', 'apprise')).toContain('render a JSON object');
expect(assertPayloadTemplateAllowedForChannel('"{{message}}"', 'apprise')).toContain('render a JSON object');
expect(assertPayloadTemplateAllowedForChannel('{}', 'apprise')).toContain('render a JSON object');
expect(assertPayloadTemplateAllowedForChannel('{"title": "{{level}}"}', 'apprise')).toBeNull();
});
});
describe('renderPayloadTemplate', () => {
it('JSON-escapes values so quotes, newlines, and backslashes survive', () => {
const message = 'say "hi"\nline\\two';
const rendered = renderPayloadTemplate('{"message": "{{message}}"}', { message }) as {
message: string;
};
expect(rendered.message).toBe(message);
});
it('substitutes missing context with an empty string', () => {
expect(renderPayloadTemplate('{"message": "{{message}}"}', {})).toEqual({ message: '' });
});
it('supports quoted keys, arrays, and variables mixed into strings', () => {
expect(renderPayloadTemplate('{"{{level}}": 1}', { level: 'info' })).toEqual({ info: 1 });
expect(renderPayloadTemplate('["{{level}}"]', { level: 'info' })).toEqual(['info']);
expect(renderPayloadTemplate('{"a": "x {{level}} y"}', { level: 'info' })).toEqual({ a: 'x info y' });
});
it('JSON-escapes values substituted inside a string', () => {
const message = 'say "hi"\nline\\two';
const rendered = renderPayloadTemplate('{"a": "x {{message}} y"}', { message }) as { a: string };
expect(rendered.a).toBe(`x ${message} y`);
});
it('does not re-substitute braces inside substituted values', () => {
const rendered = renderPayloadTemplate('{"message": "{{message}}"}', {
message: 'contains {{level}} literal',
}) as { message: string };
expect(rendered.message).toBe('contains {{level}} literal');
});
it('throws on a template that fails to parse after substitution', () => {
expect(() => renderPayloadTemplate('{"a":', {})).toThrow('Templated payload rendered invalid JSON');
});
});
@@ -0,0 +1,406 @@
/**
* Templated payload dispatch through NotificationService: variable
* substitution, retry stability, ntfy JSON publish, Apprise destination
* merging, non-goal enforcement (routes never templated), and regression
* guards for untemplated agents.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
const {
mockGetEnabledNotificationRoutes,
mockGetEnabledNotificationSuppressionRules,
mockGetEnabledAgents,
mockGetStackLabelIds,
mockAddNotificationHistory,
mockUpdateNotificationDispatchError,
mockGetGlobalSettings,
} = vi.hoisted(() => ({
mockGetEnabledNotificationRoutes: vi.fn().mockReturnValue([]),
mockGetEnabledNotificationSuppressionRules: vi.fn().mockReturnValue([]),
mockGetEnabledAgents: vi.fn().mockReturnValue([]),
mockGetStackLabelIds: vi.fn().mockReturnValue([]),
mockAddNotificationHistory: vi.fn().mockReturnValue({
id: 42,
level: 'error',
message: 'down',
timestamp: 1700000000000,
is_read: 0,
}),
mockUpdateNotificationDispatchError: vi.fn(),
mockGetGlobalSettings: vi.fn().mockReturnValue({ notification_dispatch_retries: '0' }),
}));
vi.mock('../services/DatabaseService', () => ({
DatabaseService: {
getInstance: () => ({
getEnabledNotificationRoutes: mockGetEnabledNotificationRoutes,
getEnabledNotificationSuppressionRules: mockGetEnabledNotificationSuppressionRules,
getEnabledAgents: mockGetEnabledAgents,
getStackLabelIds: mockGetStackLabelIds,
addNotificationHistory: mockAddNotificationHistory,
updateNotificationDispatchError: mockUpdateNotificationDispatchError,
getGlobalSettings: mockGetGlobalSettings,
}),
},
}));
vi.mock('../services/NodeRegistry', () => ({
NodeRegistry: {
getInstance: () => ({
getDefaultNodeId: () => 1,
getComposeDir: () => '/app/compose',
}),
},
}));
vi.mock('../services/StackActivityMetricsService', () => ({
StackActivityMetricsService: {
getInstance: () => ({ record: vi.fn() }),
},
}));
import { NotificationService } from '../services/NotificationService';
const WEBHOOK_URL = 'https://example.com/hooks/sencho';
function makeRoute(overrides: Record<string, unknown> = {}) {
return {
id: 1,
name: 'Prod Discord',
node_id: null as number | null,
stack_patterns: [] as string[],
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/1/token',
priority: 0,
enabled: true,
created_at: Date.now(),
updated_at: Date.now(),
...overrides,
};
}
function makeAgent(overrides: Record<string, unknown> = {}) {
return {
id: 1,
node_id: 1,
type: 'webhook' as const,
url: WEBHOOK_URL,
enabled: true,
config: null as string | null,
payload_template: null as string | null,
...overrides,
};
}
describe('templated payload dispatch', () => {
let svc: NotificationService;
let mockFetch: ReturnType<typeof vi.fn>;
beforeEach(() => {
(NotificationService as unknown as { instance?: NotificationService }).instance = undefined;
NotificationService.setRetryDelayMsForTests(0);
svc = NotificationService.getInstance();
mockFetch = vi.fn().mockResolvedValue({ ok: true, status: 200 });
vi.stubGlobal('fetch', mockFetch);
mockGetEnabledNotificationRoutes.mockReturnValue([]);
mockGetEnabledNotificationSuppressionRules.mockReturnValue([]);
mockGetEnabledAgents.mockReturnValue([]);
mockGetStackLabelIds.mockReturnValue([]);
mockUpdateNotificationDispatchError.mockClear();
mockAddNotificationHistory.mockClear();
mockGetGlobalSettings.mockReset();
mockGetGlobalSettings.mockReturnValue({ notification_dispatch_retries: '0' });
});
afterEach(() => {
NotificationService.setRetryDelayMsForTests(1000);
vi.unstubAllGlobals();
});
it('substitutes all template variables from the dispatch context', async () => {
mockGetEnabledAgents.mockReturnValue([
makeAgent({
payload_template:
'{"level":"{{level}}","message":"{{message}}","category":"{{category}}",'
+ '"timestamp":"{{timestamp}}","stack_name":"{{stack_name}}","actor":"{{actor}}"}',
}),
]);
await svc.dispatchAlert('error', 'monitor_alert', 'down', { stackName: 'web', actor: 'boris' });
expect(mockFetch).toHaveBeenCalledTimes(1);
const [, init] = mockFetch.mock.calls[0] as [string, RequestInit];
expect(init.headers).toMatchObject({ 'Content-Type': 'application/json' });
expect(JSON.parse(String(init.body))).toEqual({
level: 'error',
message: 'down',
category: 'monitor_alert',
timestamp: new Date(1700000000000).toISOString(),
stack_name: 'web',
actor: 'boris',
});
});
it('renders the persisted history-row timestamp, stable across retries', async () => {
mockGetGlobalSettings.mockReturnValue({ notification_dispatch_retries: '1' });
mockGetEnabledAgents.mockReturnValue([
makeAgent({ payload_template: '{"timestamp": "{{timestamp}}", "message": "{{message}}"}' }),
]);
mockFetch
.mockResolvedValueOnce({ ok: false, status: 502 })
.mockResolvedValueOnce({ ok: true, status: 200 });
await svc.dispatchAlert('error', 'monitor_alert', 'down');
expect(mockFetch).toHaveBeenCalledTimes(2);
const first = mockFetch.mock.calls[0][1] as RequestInit;
const second = mockFetch.mock.calls[1][1] as RequestInit;
expect(String(first.body)).toBe(String(second.body));
expect(JSON.parse(String(first.body))).toEqual({
timestamp: new Date(1700000000000).toISOString(),
message: 'down',
});
});
it('treats a 4xx on a templated agent as non-retryable even with extras configured', async () => {
mockGetGlobalSettings.mockReturnValue({ notification_dispatch_retries: '3' });
mockGetEnabledAgents.mockReturnValue([makeAgent({ payload_template: '{"m":"{{message}}"}' })]);
mockFetch.mockResolvedValue({ ok: false, status: 400 });
await svc.dispatchAlert('error', 'monitor_alert', 'down');
expect(mockFetch).toHaveBeenCalledTimes(1);
expect(mockUpdateNotificationDispatchError).toHaveBeenCalledWith(
42,
expect.stringContaining('webhook rejected templated payload with HTTP 400'),
);
});
it('posts JSON to a normalized ntfy URL without plaintext headers', async () => {
mockGetEnabledAgents.mockReturnValue([
makeAgent({
type: 'ntfy',
url: 'https://ntfy.sh/mytopic/',
payload_template: '{"message": "{{message}}"}',
}),
]);
await svc.dispatchAlert('warning', 'stack_restarted', 'up again', { stackName: 'blog' });
expect(mockFetch).toHaveBeenCalledTimes(1);
const [target, init] = mockFetch.mock.calls[0] as [string, RequestInit];
expect(target).toBe('https://ntfy.sh/mytopic');
expect(init.headers).toMatchObject({ 'Content-Type': 'application/json' });
expect(init.headers).not.toHaveProperty('Title');
expect(init.headers).not.toHaveProperty('Priority');
expect(init.headers).not.toHaveProperty('Tags');
expect(JSON.parse(String(init.body))).toEqual({ message: 'up again' });
});
it('translates ntfy URL userinfo into Basic authorization for templated posts', async () => {
mockGetEnabledAgents.mockReturnValue([
makeAgent({
type: 'ntfy',
url: 'https://user:pass@ntfy.sh/mytopic',
payload_template: '{"message": "{{message}}"}',
}),
]);
await svc.dispatchAlert('info', 'system', 'hello');
const [target, init] = mockFetch.mock.calls[0] as [string, RequestInit];
expect(target).toBe('https://ntfy.sh/mytopic');
expect(init.headers).toMatchObject({ Authorization: 'Basic dXNlcjpwYXNz' });
expect(JSON.parse(String(init.body))).toEqual({ message: 'hello' });
});
it('merges stored Apprise destination URLs into the rendered body', async () => {
mockGetEnabledAgents.mockReturnValue([
makeAgent({
type: 'apprise',
url: 'http://apprise.local/notify',
config: JSON.stringify({ urls: 'discord://token@id' }),
payload_template: '{"title": "{{level}}", "body": "{{message}}"}',
}),
]);
await svc.dispatchAlert('error', 'monitor_alert', 'down');
const [, init] = mockFetch.mock.calls[0] as [string, RequestInit];
expect(JSON.parse(String(init.body))).toEqual({
title: 'error',
body: 'down',
urls: 'discord://token@id',
});
});
it('treats an Apprise 204 on the templated path as a non-retryable no-delivery', async () => {
mockGetGlobalSettings.mockReturnValue({ notification_dispatch_retries: '2' });
mockGetEnabledAgents.mockReturnValue([
makeAgent({
type: 'apprise',
url: 'http://apprise.local/notify',
config: JSON.stringify({ urls: 'discord://token@id' }),
payload_template: '{"title": "{{level}}"}',
}),
]);
mockFetch.mockResolvedValue({ ok: true, status: 204 });
await svc.dispatchAlert('error', 'monitor_alert', 'down');
expect(mockFetch).toHaveBeenCalledTimes(1);
expect(mockUpdateNotificationDispatchError).toHaveBeenCalledWith(
42,
expect.stringContaining('no delivery (HTTP 204)'),
);
});
it('does not fetch and records a non-retryable error for a corrupt stored template', async () => {
mockGetEnabledAgents.mockReturnValue([makeAgent({ payload_template: '{"a":' })]);
await svc.dispatchAlert('error', 'monitor_alert', 'down');
expect(mockFetch).not.toHaveBeenCalled();
expect(mockUpdateNotificationDispatchError).toHaveBeenCalledWith(
42,
expect.stringContaining('Templated payload could not be rendered'),
);
});
it('ignores a stored agent template when a notification route matches', async () => {
mockGetEnabledNotificationRoutes.mockReturnValue([makeRoute()]);
mockGetEnabledAgents.mockReturnValue([
makeAgent({ payload_template: '{"custom": "{{level}}"}' }),
]);
await svc.dispatchAlert('error', 'monitor_alert', 'down');
const [target, init] = mockFetch.mock.calls[0] as [string, RequestInit];
expect(target).toBe('https://discord.com/api/webhooks/1/token');
const body = JSON.parse(String(init.body)) as { embeds?: unknown[]; custom?: unknown };
expect(body.custom).toBeUndefined();
expect(Array.isArray(body.embeds)).toBe(true);
});
it('merges stored Apprise tags for keyed endpoints into the rendered body', async () => {
mockGetEnabledAgents.mockReturnValue([
makeAgent({
type: 'apprise',
url: 'http://apprise.local/notify/test-key',
config: JSON.stringify({ tags: 'ops' }),
payload_template: '{"title": "{{level}}"}',
}),
]);
await svc.dispatchAlert('error', 'monitor_alert', 'down');
const [, init] = mockFetch.mock.calls[0] as [string, RequestInit];
expect(JSON.parse(String(init.body))).toEqual({ title: 'error', tag: 'ops' });
});
it('does not merge a tag for keyed Apprise endpoints without tags', async () => {
mockGetEnabledAgents.mockReturnValue([
makeAgent({
type: 'apprise',
url: 'http://apprise.local/notify/test-key',
config: '{}',
payload_template: '{"title": "{{level}}"}',
}),
]);
await svc.dispatchAlert('error', 'monitor_alert', 'down');
const [, init] = mockFetch.mock.calls[0] as [string, RequestInit];
expect(JSON.parse(String(init.body))).toEqual({ title: 'error' });
});
it('treats a whitespace-only stored template as no template', async () => {
mockGetEnabledAgents.mockReturnValue([makeAgent({ type: 'webhook', payload_template: ' ' })]);
await svc.dispatchAlert('error', 'monitor_alert', 'down');
const [, init] = mockFetch.mock.calls[0] as [string, RequestInit];
const body = JSON.parse(String(init.body)) as { source?: string };
expect(body.source).toBe('sencho');
});
it('classifies a network failure on the templated path as retryable', async () => {
mockGetGlobalSettings.mockReturnValue({ notification_dispatch_retries: '1' });
mockGetEnabledAgents.mockReturnValue([makeAgent({ payload_template: '{"m": "{{message}}"}' })]);
mockFetch
.mockRejectedValueOnce(new Error('ECONNREFUSED'))
.mockResolvedValueOnce({ ok: true, status: 200 });
await svc.dispatchAlert('error', 'monitor_alert', 'down');
expect(mockFetch).toHaveBeenCalledTimes(2);
expect(mockUpdateNotificationDispatchError).not.toHaveBeenCalled();
});
it('rejects a non-object rendered template on the Apprise path without fetching', async () => {
mockGetEnabledAgents.mockReturnValue([
makeAgent({
type: 'apprise',
url: 'http://apprise.local/notify',
config: '{}',
payload_template: '"{{message}}"',
}),
]);
await svc.dispatchAlert('error', 'monitor_alert', 'down');
expect(mockFetch).not.toHaveBeenCalled();
expect(mockUpdateNotificationDispatchError).toHaveBeenCalledWith(
42,
expect.stringContaining('must render a JSON object'),
);
});
it('rejects an invalid stored Apprise config on the templated path without fetching', async () => {
mockGetEnabledAgents.mockReturnValue([
makeAgent({
type: 'apprise',
url: 'http://apprise.local/notify',
config: '{not-json',
payload_template: '{"title": "{{level}}"}',
}),
]);
await svc.dispatchAlert('error', 'monitor_alert', 'down');
expect(mockFetch).not.toHaveBeenCalled();
expect(mockUpdateNotificationDispatchError).toHaveBeenCalledWith(
42,
expect.stringContaining('Stored Apprise configuration is invalid'),
);
});
it('keeps the built-in payload for an untemplated agent', async () => {
mockGetEnabledAgents.mockReturnValue([makeAgent({ type: 'webhook' })]);
await svc.dispatchAlert('error', 'monitor_alert', 'down');
const [, init] = mockFetch.mock.calls[0] as [string, RequestInit];
const body = JSON.parse(String(init.body)) as { level: string; message: string; source: string };
expect(body).toMatchObject({ level: 'error', message: 'down', source: 'sencho' });
});
it('testDispatch renders the template with the test message and system category', async () => {
await svc.testDispatch(
'webhook',
WEBHOOK_URL,
undefined,
'{"message": "{{message}}", "level": "{{level}}", "category": "{{category}}"}',
);
const [, init] = mockFetch.mock.calls[0] as [string, RequestInit];
expect(JSON.parse(String(init.body))).toEqual({
message: '🔌 Test Notification from Sencho!',
level: 'info',
category: 'system',
});
});
});