mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-12 19:57:37 +00:00
866d784316
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.
165 lines
7.2 KiB
TypeScript
165 lines
7.2 KiB
TypeScript
/**
|
|
* 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');
|
|
});
|
|
});
|