'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'); }); });