mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-20 15:22:59 +00:00
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:
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user