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>
This commit is contained in:
@@ -0,0 +1,20 @@
|
|||||||
|
name: Test
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ["**"]
|
||||||
|
pull_request:
|
||||||
|
branches: ["**"]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
jest:
|
||||||
|
name: JS Tests (jest)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm install
|
||||||
|
|
||||||
|
- name: Run jest
|
||||||
|
run: npm test
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a webhook URL.
|
||||||
|
* Returns { ok: true, url } or { ok: false, reason }.
|
||||||
|
*/
|
||||||
|
function validateWebhookUrl(raw) {
|
||||||
|
if (!raw) return { ok: true, url: null };
|
||||||
|
let url;
|
||||||
|
try { url = new URL(raw); } catch { return { ok: false, reason: 'Invalid URL format' }; }
|
||||||
|
if (!['http:', 'https:'].includes(url.protocol)) {
|
||||||
|
return { ok: false, reason: 'Webhook URL must use http or https' };
|
||||||
|
}
|
||||||
|
const host = url.hostname.toLowerCase();
|
||||||
|
if (
|
||||||
|
host === 'localhost' ||
|
||||||
|
host === '::1' ||
|
||||||
|
/^127\./.test(host) ||
|
||||||
|
/^10\./.test(host) ||
|
||||||
|
/^192\.168\./.test(host) ||
|
||||||
|
/^172\.(1[6-9]|2\d|3[01])\./.test(host) ||
|
||||||
|
/^169\.254\./.test(host) ||
|
||||||
|
/^fe80:/i.test(host)
|
||||||
|
) {
|
||||||
|
return { ok: false, reason: 'Webhook URL must not point to a private/internal address' };
|
||||||
|
}
|
||||||
|
return { ok: true, url };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace {{param}} placeholders in a command string.
|
||||||
|
* Throws if a substituted value contains unsafe characters.
|
||||||
|
*/
|
||||||
|
function applyParams(command, params) {
|
||||||
|
return command.replace(/\{\{(\w+)\}\}/g, (match, key) => {
|
||||||
|
if (!(key in params)) return match;
|
||||||
|
const val = String(params[key]).trim();
|
||||||
|
if (!/^[a-zA-Z0-9._:@\-\/]+$/.test(val)) {
|
||||||
|
throw new Error(`Unsafe value for workflow parameter "${key}"`);
|
||||||
|
}
|
||||||
|
return val;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluate a condition expression safely using vm.runInNewContext.
|
||||||
|
* Returns boolean (false on error).
|
||||||
|
*/
|
||||||
|
function evalCondition(condition, state, params) {
|
||||||
|
const vm = require('vm');
|
||||||
|
try {
|
||||||
|
const context = vm.createContext({ state, params, promptResponse: state.promptResponse });
|
||||||
|
return !!vm.runInNewContext(condition, context, { timeout: 100 });
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`[Workflow] evalCondition error (treated as false): ${e.message} — condition: ${condition}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate the next run date for a scheduled command.
|
||||||
|
*/
|
||||||
|
function calculateNextRun(scheduleType, scheduleValue) {
|
||||||
|
const cronParser = require('cron-parser');
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
if (scheduleType === 'interval') {
|
||||||
|
const minutes = parseInt(scheduleValue);
|
||||||
|
if (isNaN(minutes) || minutes <= 0) throw new Error(`Invalid interval value: ${scheduleValue}`);
|
||||||
|
return new Date(now.getTime() + minutes * 60000);
|
||||||
|
} else if (scheduleType === 'daily') {
|
||||||
|
const [hours, minutes] = scheduleValue.split(':').map(Number);
|
||||||
|
if (isNaN(hours) || isNaN(minutes)) throw new Error(`Invalid daily time format: ${scheduleValue}`);
|
||||||
|
const next = new Date(now);
|
||||||
|
next.setHours(hours, minutes, 0, 0);
|
||||||
|
if (next <= now) next.setDate(next.getDate() + 1);
|
||||||
|
return next;
|
||||||
|
} else if (scheduleType === 'hourly') {
|
||||||
|
const hours = parseInt(scheduleValue);
|
||||||
|
if (isNaN(hours) || hours <= 0) throw new Error(`Invalid hourly value: ${scheduleValue}`);
|
||||||
|
return new Date(now.getTime() + hours * 3600000);
|
||||||
|
} else if (scheduleType === 'cron') {
|
||||||
|
const interval = cronParser.CronExpressionParser.parse(scheduleValue, { currentDate: now });
|
||||||
|
return interval.next().toDate();
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Unknown schedule type: ${scheduleType}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { validateWebhookUrl, applyParams, evalCondition, calculateNextRun };
|
||||||
Generated
+3054
-1
File diff suppressed because it is too large
Load Diff
+3
-2
@@ -3,7 +3,7 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "echo \"Error: no test specified\" && exit 1"
|
"test": "jest"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
"ws": "^8.18.3"
|
"ws": "^8.18.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"eslint": "^8.57.1"
|
"eslint": "^8.57.1",
|
||||||
|
"jest": "^29.7.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const vm = require('vm');
|
|||||||
const rateLimit = require('express-rate-limit');
|
const rateLimit = require('express-rate-limit');
|
||||||
const cronParser = require('cron-parser');
|
const cronParser = require('cron-parser');
|
||||||
require('dotenv').config();
|
require('dotenv').config();
|
||||||
|
const { validateWebhookUrl, applyParams, evalCondition, calculateNextRun } = require('./lib/utils');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const server = http.createServer(app);
|
const server = http.createServer(app);
|
||||||
@@ -62,28 +63,7 @@ function requireJSON(req, res, next) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Validate and parse a webhook URL; returns { ok, url, reason }
|
// Validate and parse a webhook URL; returns { ok, url, reason }
|
||||||
function validateWebhookUrl(raw) {
|
// validateWebhookUrl, applyParams, evalCondition, calculateNextRun imported from ./lib/utils
|
||||||
if (!raw) return { ok: true, url: null };
|
|
||||||
let url;
|
|
||||||
try { url = new URL(raw); } catch { return { ok: false, reason: 'Invalid URL format' }; }
|
|
||||||
if (!['http:', 'https:'].includes(url.protocol)) {
|
|
||||||
return { ok: false, reason: 'Webhook URL must use http or https' };
|
|
||||||
}
|
|
||||||
const host = url.hostname.toLowerCase();
|
|
||||||
if (
|
|
||||||
host === 'localhost' ||
|
|
||||||
host === '::1' ||
|
|
||||||
/^127\./.test(host) ||
|
|
||||||
/^10\./.test(host) ||
|
|
||||||
/^192\.168\./.test(host) ||
|
|
||||||
/^172\.(1[6-9]|2\d|3[01])\./.test(host) ||
|
|
||||||
/^169\.254\./.test(host) ||
|
|
||||||
/^fe80:/i.test(host)
|
|
||||||
) {
|
|
||||||
return { ok: false, reason: 'Webhook URL must not point to a private/internal address' };
|
|
||||||
}
|
|
||||||
return { ok: true, url };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Database pool
|
// Database pool
|
||||||
const pool = mysql.createPool({
|
const pool = mysql.createPool({
|
||||||
@@ -311,35 +291,6 @@ async function processScheduledCommands() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function calculateNextRun(scheduleType, scheduleValue) {
|
|
||||||
const now = new Date();
|
|
||||||
|
|
||||||
if (scheduleType === 'interval') {
|
|
||||||
// Interval in minutes
|
|
||||||
const minutes = parseInt(scheduleValue);
|
|
||||||
if (isNaN(minutes) || minutes <= 0) throw new Error(`Invalid interval value: ${scheduleValue}`);
|
|
||||||
return new Date(now.getTime() + minutes * 60000);
|
|
||||||
} else if (scheduleType === 'daily') {
|
|
||||||
// Daily at HH:MM
|
|
||||||
const [hours, minutes] = scheduleValue.split(':').map(Number);
|
|
||||||
if (isNaN(hours) || isNaN(minutes)) throw new Error(`Invalid daily time format: ${scheduleValue}`);
|
|
||||||
const next = new Date(now);
|
|
||||||
next.setHours(hours, minutes, 0, 0);
|
|
||||||
if (next <= now) next.setDate(next.getDate() + 1);
|
|
||||||
return next;
|
|
||||||
} else if (scheduleType === 'hourly') {
|
|
||||||
// Every N hours
|
|
||||||
const hours = parseInt(scheduleValue);
|
|
||||||
if (isNaN(hours) || hours <= 0) throw new Error(`Invalid hourly value: ${scheduleValue}`);
|
|
||||||
return new Date(now.getTime() + hours * 3600000);
|
|
||||||
} else if (scheduleType === 'cron') {
|
|
||||||
// Full cron expression e.g. "0 2 * * 0" (Sundays at 2am)
|
|
||||||
const interval = cronParser.parseExpression(scheduleValue, { currentDate: now });
|
|
||||||
return interval.next().toDate();
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(`Unknown schedule type: ${scheduleType}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run scheduler every minute
|
// Run scheduler every minute
|
||||||
setInterval(processScheduledCommands, 60 * 1000);
|
setInterval(processScheduledCommands, 60 * 1000);
|
||||||
@@ -714,28 +665,6 @@ function authenticateGandalf(req, res, next) {
|
|||||||
|
|
||||||
// Substitute {{param_name}} placeholders in a command string.
|
// Substitute {{param_name}} placeholders in a command string.
|
||||||
// Only alphanumeric + safe punctuation allowed in substituted values.
|
// Only alphanumeric + safe punctuation allowed in substituted values.
|
||||||
function applyParams(command, params) {
|
|
||||||
return command.replace(/\{\{(\w+)\}\}/g, (match, key) => {
|
|
||||||
if (!(key in params)) return match;
|
|
||||||
const val = String(params[key]).trim();
|
|
||||||
if (!/^[a-zA-Z0-9._:@\-\/]+$/.test(val)) {
|
|
||||||
throw new Error(`Unsafe value for workflow parameter "${key}"`);
|
|
||||||
}
|
|
||||||
return val;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Evaluate a condition string against execution state and params.
|
|
||||||
// Uses vm.runInNewContext with a timeout to avoid arbitrary code execution risk.
|
|
||||||
function evalCondition(condition, state, params) {
|
|
||||||
try {
|
|
||||||
const context = vm.createContext({ state, params, promptResponse: state.promptResponse });
|
|
||||||
return !!vm.runInNewContext(condition, context, { timeout: 100 });
|
|
||||||
} catch (e) {
|
|
||||||
console.warn(`[Workflow] evalCondition error (treated as false): ${e.message} — condition: ${condition}`);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Per-execution mutable state (params + user-keyed state dict).
|
// Per-execution mutable state (params + user-keyed state dict).
|
||||||
// Survives across step boundaries; cleaned up when execution ends.
|
// Survives across step boundaries; cleaned up when execution ends.
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user