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:
@@ -7,6 +7,7 @@ const vm = require('vm');
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const cronParser = require('cron-parser');
|
||||
require('dotenv').config();
|
||||
const { validateWebhookUrl, applyParams, evalCondition, calculateNextRun } = require('./lib/utils');
|
||||
|
||||
const app = express();
|
||||
const server = http.createServer(app);
|
||||
@@ -62,28 +63,7 @@ function requireJSON(req, res, next) {
|
||||
}
|
||||
|
||||
// Validate and parse a webhook URL; returns { ok, url, 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 };
|
||||
}
|
||||
// validateWebhookUrl, applyParams, evalCondition, calculateNextRun imported from ./lib/utils
|
||||
|
||||
// Database pool
|
||||
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
|
||||
setInterval(processScheduledCommands, 60 * 1000);
|
||||
@@ -714,28 +665,6 @@ function authenticateGandalf(req, res, next) {
|
||||
|
||||
// Substitute {{param_name}} placeholders in a command string.
|
||||
// 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).
|
||||
// Survives across step boundaries; cleaned up when execution ends.
|
||||
|
||||
Reference in New Issue
Block a user