Add jest test suite, extract pure utils module, fix cron-parser v5 API
Lint / JS (eslint) (push) Failing after 12s
Security / JS Security (npm audit) (push) Failing after 13s
Test / JS Tests (jest) (push) Successful in 13s
Lint / Deploy (push) Has been skipped

- 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>
This commit is contained in:
2026-04-14 12:24:30 -04:00
parent 0a677d69a8
commit 6e5f18ea58
6 changed files with 3332 additions and 76 deletions
+163
View File
@@ -0,0 +1,163 @@
'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');
});
});