Files
pulse/tests/utils.test.js
T
jared 6e5f18ea58
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
Add jest test suite, extract pure utils module, fix cron-parser v5 API
- 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>
2026-04-14 12:24:30 -04:00

164 lines
6.4 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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');
});
});