6e5f18ea58
- Extract validateWebhookUrl, applyParams, evalCondition, calculateNextRun to lib/utils.js so they can be tested without DB connection - Fix cron-parser v5 API: parseExpression → CronExpressionParser.parse - Add 31 jest tests covering all four utility functions - Add test.yml CI workflow running jest on every push/PR - Add jest devDependency Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
164 lines
6.4 KiB
JavaScript
164 lines
6.4 KiB
JavaScript
'use strict';
|
||
|
||
const { validateWebhookUrl, applyParams, evalCondition, calculateNextRun } = require('../lib/utils');
|
||
|
||
// ── validateWebhookUrl ──────────────────────────────────────────────────────
|
||
|
||
describe('validateWebhookUrl', () => {
|
||
test('null/empty returns ok with null url', () => {
|
||
expect(validateWebhookUrl(null)).toEqual({ ok: true, url: null });
|
||
expect(validateWebhookUrl('')).toEqual({ ok: true, url: null });
|
||
});
|
||
|
||
test('valid public https URL is accepted', () => {
|
||
const result = validateWebhookUrl('https://hooks.example.com/notify');
|
||
expect(result.ok).toBe(true);
|
||
expect(result.url).toBeInstanceOf(URL);
|
||
});
|
||
|
||
test('valid public http URL is accepted', () => {
|
||
const result = validateWebhookUrl('http://webhook.example.com/event');
|
||
expect(result.ok).toBe(true);
|
||
});
|
||
|
||
test('invalid URL format is rejected', () => {
|
||
expect(validateWebhookUrl('not a url')).toMatchObject({ ok: false, reason: expect.stringContaining('Invalid URL') });
|
||
});
|
||
|
||
test('non-http protocol is rejected', () => {
|
||
expect(validateWebhookUrl('ftp://example.com/hook')).toMatchObject({ ok: false, reason: expect.stringContaining('http') });
|
||
});
|
||
|
||
test('localhost is rejected', () => {
|
||
expect(validateWebhookUrl('http://localhost/hook')).toMatchObject({ ok: false });
|
||
});
|
||
|
||
test('10.x.x.x private range is rejected', () => {
|
||
expect(validateWebhookUrl('http://10.0.0.1/hook')).toMatchObject({ ok: false });
|
||
expect(validateWebhookUrl('http://10.10.10.45/hook')).toMatchObject({ ok: false });
|
||
});
|
||
|
||
test('192.168.x.x private range is rejected', () => {
|
||
expect(validateWebhookUrl('http://192.168.1.1/hook')).toMatchObject({ ok: false });
|
||
});
|
||
|
||
test('127.0.0.1 loopback is rejected', () => {
|
||
expect(validateWebhookUrl('http://127.0.0.1/hook')).toMatchObject({ ok: false });
|
||
});
|
||
|
||
test('172.16.x.x – 172.31.x.x private range is rejected', () => {
|
||
expect(validateWebhookUrl('http://172.16.0.1/hook')).toMatchObject({ ok: false });
|
||
expect(validateWebhookUrl('http://172.31.255.255/hook')).toMatchObject({ ok: false });
|
||
});
|
||
|
||
test('172.32.x.x (outside private range) is accepted', () => {
|
||
const result = validateWebhookUrl('http://172.32.0.1/hook');
|
||
expect(result.ok).toBe(true);
|
||
});
|
||
});
|
||
|
||
// ── applyParams ─────────────────────────────────────────────────────────────
|
||
|
||
describe('applyParams', () => {
|
||
test('replaces a single placeholder', () => {
|
||
expect(applyParams('echo {{msg}}', { msg: 'hello' })).toBe('echo hello');
|
||
});
|
||
|
||
test('replaces multiple placeholders', () => {
|
||
expect(applyParams('cp {{src}} {{dst}}', { src: 'a.txt', dst: 'b.txt' })).toBe('cp a.txt b.txt');
|
||
});
|
||
|
||
test('leaves unknown placeholders unchanged', () => {
|
||
expect(applyParams('echo {{unknown}}', {})).toBe('echo {{unknown}}');
|
||
});
|
||
|
||
test('throws on unsafe param value with spaces', () => {
|
||
expect(() => applyParams('echo {{x}}', { x: 'rm -rf /' })).toThrow('Unsafe value');
|
||
});
|
||
|
||
test('throws on unsafe param value with semicolons', () => {
|
||
expect(() => applyParams('echo {{x}}', { x: 'a;b' })).toThrow('Unsafe value');
|
||
});
|
||
|
||
test('allows safe characters: dots, dashes, slashes, colons, @', () => {
|
||
expect(applyParams('ssh {{host}}', { host: 'user@10.0.0.1:22' })).toBe('ssh user@10.0.0.1:22');
|
||
expect(applyParams('cat {{path}}', { path: '/etc/hosts' })).toBe('cat /etc/hosts');
|
||
});
|
||
|
||
test('trims whitespace from param values', () => {
|
||
expect(applyParams('echo {{x}}', { x: ' hello ' })).toBe('echo hello');
|
||
});
|
||
});
|
||
|
||
// ── evalCondition ───────────────────────────────────────────────────────────
|
||
|
||
describe('evalCondition', () => {
|
||
test('evaluates truthy expression', () => {
|
||
expect(evalCondition('1 === 1', {}, {})).toBe(true);
|
||
});
|
||
|
||
test('evaluates falsy expression', () => {
|
||
expect(evalCondition('1 === 2', {}, {})).toBe(false);
|
||
});
|
||
|
||
test('accesses state variables', () => {
|
||
expect(evalCondition('state.count > 5', { count: 10 }, {})).toBe(true);
|
||
expect(evalCondition('state.count > 5', { count: 3 }, {})).toBe(false);
|
||
});
|
||
|
||
test('accesses params', () => {
|
||
expect(evalCondition('params.env === "prod"', {}, { env: 'prod' })).toBe(true);
|
||
});
|
||
|
||
test('returns false on syntax error (does not throw)', () => {
|
||
expect(evalCondition('this is not valid js !!!', {}, {})).toBe(false);
|
||
});
|
||
|
||
test('returns false on timeout / infinite loop', () => {
|
||
expect(evalCondition('while(true){}', {}, {})).toBe(false);
|
||
});
|
||
});
|
||
|
||
// ── calculateNextRun ────────────────────────────────────────────────────────
|
||
|
||
describe('calculateNextRun', () => {
|
||
test('interval: returns a date ~N minutes in the future', () => {
|
||
const before = Date.now();
|
||
const result = calculateNextRun('interval', '30');
|
||
expect(result.getTime()).toBeGreaterThanOrEqual(before + 30 * 60000 - 100);
|
||
expect(result.getTime()).toBeLessThanOrEqual(before + 30 * 60000 + 1000);
|
||
});
|
||
|
||
test('interval: throws on non-positive value', () => {
|
||
expect(() => calculateNextRun('interval', '0')).toThrow();
|
||
expect(() => calculateNextRun('interval', '-5')).toThrow();
|
||
expect(() => calculateNextRun('interval', 'abc')).toThrow();
|
||
});
|
||
|
||
test('hourly: returns a date ~N hours in the future', () => {
|
||
const before = Date.now();
|
||
const result = calculateNextRun('hourly', '2');
|
||
expect(result.getTime()).toBeGreaterThanOrEqual(before + 2 * 3600000 - 100);
|
||
});
|
||
|
||
test('daily: returns a Date object', () => {
|
||
const result = calculateNextRun('daily', '06:00');
|
||
expect(result).toBeInstanceOf(Date);
|
||
expect(result.getTime()).toBeGreaterThan(Date.now());
|
||
});
|
||
|
||
test('daily: throws on invalid time format', () => {
|
||
expect(() => calculateNextRun('daily', 'noon')).toThrow();
|
||
});
|
||
|
||
test('cron: returns next occurrence after now', () => {
|
||
const result = calculateNextRun('cron', '0 6 * * 1');
|
||
expect(result).toBeInstanceOf(Date);
|
||
expect(result.getTime()).toBeGreaterThan(Date.now());
|
||
});
|
||
|
||
test('unknown type throws', () => {
|
||
expect(() => calculateNextRun('weekly', '1')).toThrow('Unknown schedule type');
|
||
});
|
||
});
|