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
+2 -73
View File
@@ -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.