mirror of
https://github.com/cpvalente/ontime.git
synced 2026-09-14 10:39:37 +00:00
fix(automation): validate template-aware output targets
This commit is contained in:
committed by
Carlos Valente
parent
1175b3c641
commit
79c21daf73
@@ -0,0 +1,54 @@
|
|||||||
|
import type { Request, Response } from 'express';
|
||||||
|
import type { Automation, ErrorResponse } from 'ontime-types';
|
||||||
|
|
||||||
|
import { editAutomation, postAutomation } from '../automation.controller.js';
|
||||||
|
import * as automationDao from '../automation.dao.js';
|
||||||
|
|
||||||
|
vi.mock('../automation.dao.js', () => ({
|
||||||
|
addAutomation: vi.fn(),
|
||||||
|
editAutomation: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function makeResponse() {
|
||||||
|
return {
|
||||||
|
send: vi.fn(),
|
||||||
|
status: vi.fn().mockReturnThis(),
|
||||||
|
} as unknown as Response<Automation | ErrorResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestBody = {
|
||||||
|
title: 'OSC definition',
|
||||||
|
filterRule: 'all',
|
||||||
|
filters: [],
|
||||||
|
outputs: [{ type: 'osc', targetIP: ' 127.0.0.1 ', targetPort: 53000, address: '/test', args: '' }],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('automation controllers', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.mocked(automationDao.addAutomation).mockImplementation(async (automation) => ({ id: 'new-id', ...automation }));
|
||||||
|
vi.mocked(automationDao.editAutomation).mockImplementation(async (id, automation) => ({ id, ...automation }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('persists normalized outputs when creating an automation', async () => {
|
||||||
|
const request = { body: requestBody } as Request;
|
||||||
|
|
||||||
|
await postAutomation(request, makeResponse());
|
||||||
|
|
||||||
|
expect(automationDao.addAutomation).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ outputs: [expect.objectContaining({ targetIP: '127.0.0.1' })] }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('persists normalized outputs when editing an automation', async () => {
|
||||||
|
const request = { body: requestBody, params: { id: 'automation-id' } } as unknown as Request;
|
||||||
|
|
||||||
|
await editAutomation(request, makeResponse());
|
||||||
|
|
||||||
|
expect(automationDao.editAutomation).toHaveBeenCalledWith(
|
||||||
|
'automation-id',
|
||||||
|
expect.objectContaining({ outputs: [expect.objectContaining({ targetIP: '127.0.0.1' })] }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
@@ -5,13 +5,13 @@ describe('parseOutput', () => {
|
|||||||
it('parses a valid payload', () => {
|
it('parses a valid payload', () => {
|
||||||
const payload = {
|
const payload = {
|
||||||
type: 'osc',
|
type: 'osc',
|
||||||
targetIP: 'localhost',
|
targetIP: ' qlab ',
|
||||||
targetPort: 1234,
|
targetPort: 1234,
|
||||||
address: '/test',
|
address: '/test',
|
||||||
args: 'test',
|
args: 'test',
|
||||||
};
|
};
|
||||||
const result = parseOutput(payload);
|
const result = parseOutput(payload);
|
||||||
expect(result).toStrictEqual(payload);
|
expect(result).toStrictEqual({ ...payload, targetIP: 'qlab' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws on a invalid payload', () => {
|
it('throws on a invalid payload', () => {
|
||||||
@@ -24,6 +24,33 @@ describe('parseOutput', () => {
|
|||||||
};
|
};
|
||||||
expect(() => parseOutput(payload)).toThrow('Unexpected payload type:');
|
expect(() => parseOutput(payload)).toThrow('Unexpected payload type:');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rejects invalid targets and ports', () => {
|
||||||
|
expect(() =>
|
||||||
|
parseOutput({ type: 'osc', targetIP: 'not a host', targetPort: 53000, address: '/test', args: '' }),
|
||||||
|
).toThrow('Invalid OSC target');
|
||||||
|
expect(() =>
|
||||||
|
parseOutput({ type: 'osc', targetIP: '127.0.0.1', targetPort: 70000, address: '/test', args: '' }),
|
||||||
|
).toThrow('Invalid OSC port');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows runtime templates in a target hostname', () => {
|
||||||
|
expect(
|
||||||
|
parseOutput({
|
||||||
|
type: 'osc',
|
||||||
|
targetIP: '{{eventNow.custom.oscTarget}}',
|
||||||
|
targetPort: 53000,
|
||||||
|
address: '/test',
|
||||||
|
args: '',
|
||||||
|
}),
|
||||||
|
).toMatchObject({ targetIP: '{{eventNow.custom.oscTarget}}' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects IPv6 targets', () => {
|
||||||
|
expect(() =>
|
||||||
|
parseOutput({ type: 'osc', targetIP: '::1', targetPort: 53000, address: '/test', args: '' }),
|
||||||
|
).toThrow('Invalid OSC target');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
describe('handles HTTP outputs', () => {
|
describe('handles HTTP outputs', () => {
|
||||||
it('parses a valid payload', () => {
|
it('parses a valid payload', () => {
|
||||||
@@ -41,6 +68,25 @@ describe('parseOutput', () => {
|
|||||||
};
|
};
|
||||||
expect(() => parseOutput(payload)).toThrow('Unexpected payload type:');
|
expect(() => parseOutput(payload)).toThrow('Unexpected payload type:');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rejects malformed and unsupported URLs', () => {
|
||||||
|
expect(() => parseOutput({ type: 'http', url: 'localhost:3000/hook' })).toThrow('Invalid HTTP URL');
|
||||||
|
expect(() => parseOutput({ type: 'http', url: 'ftp://example.com/hook' })).toThrow('Invalid HTTP URL');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows runtime templates in HTTP URLs', () => {
|
||||||
|
expect(parseOutput({ type: 'http', url: 'http://{{eventNow.customFields.webhookHost}}/hook' })).toEqual({
|
||||||
|
type: 'http',
|
||||||
|
url: 'http://{{eventNow.customFields.webhookHost}}/hook',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows a runtime template for a complete HTTP URL', () => {
|
||||||
|
expect(parseOutput({ type: 'http', url: '{{eventNow.customFields.webhookUrl}}' })).toEqual({
|
||||||
|
type: 'http',
|
||||||
|
url: '{{eventNow.customFields.webhookUrl}}',
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
describe('handles Ontime outputs', () => {
|
describe('handles Ontime outputs', () => {
|
||||||
it('parses a valid payload', () => {
|
it('parses a valid payload', () => {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { oscServer } from '../../adapters/OscAdapter.js';
|
|||||||
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
|
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
|
||||||
import * as automationDao from './automation.dao.js';
|
import * as automationDao from './automation.dao.js';
|
||||||
import * as automationService from './automation.service.js';
|
import * as automationService from './automation.service.js';
|
||||||
import { parseOutput } from './automation.validation.js';
|
import { parseAutomation, parseOutput } from './automation.validation.js';
|
||||||
|
|
||||||
export function getAutomationSettings(_req: Request, res: Response<AutomationSettings>) {
|
export function getAutomationSettings(_req: Request, res: Response<AutomationSettings>) {
|
||||||
res.status(200).json(automationDao.getAutomationSettings());
|
res.status(200).json(automationDao.getAutomationSettings());
|
||||||
@@ -75,12 +75,8 @@ export async function deleteTrigger(req: Request, res: Response<void | ErrorResp
|
|||||||
|
|
||||||
export async function postAutomation(req: Request, res: Response<Automation | ErrorResponse>) {
|
export async function postAutomation(req: Request, res: Response<Automation | ErrorResponse>) {
|
||||||
try {
|
try {
|
||||||
const newAutomation = await automationDao.addAutomation({
|
const automation = parseAutomation(req.body);
|
||||||
title: req.body.title,
|
const newAutomation = await automationDao.addAutomation(automation);
|
||||||
filterRule: req.body.filterRule,
|
|
||||||
filters: req.body.filters,
|
|
||||||
outputs: req.body.outputs,
|
|
||||||
});
|
|
||||||
res.status(201).send(newAutomation);
|
res.status(201).send(newAutomation);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = getErrorMessage(error);
|
const message = getErrorMessage(error);
|
||||||
@@ -90,12 +86,8 @@ export async function postAutomation(req: Request, res: Response<Automation | Er
|
|||||||
|
|
||||||
export async function editAutomation(req: Request, res: Response<Automation | ErrorResponse>) {
|
export async function editAutomation(req: Request, res: Response<Automation | ErrorResponse>) {
|
||||||
try {
|
try {
|
||||||
const newAutomation = await automationDao.editAutomation(req.params.id, {
|
const automation = parseAutomation(req.body);
|
||||||
title: req.body.title,
|
const newAutomation = await automationDao.editAutomation(req.params.id, automation);
|
||||||
filterRule: req.body.filterRule,
|
|
||||||
filters: req.body.filters,
|
|
||||||
outputs: req.body.outputs,
|
|
||||||
});
|
|
||||||
res.status(200).send(newAutomation);
|
res.status(200).send(newAutomation);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = getErrorMessage(error);
|
const message = getErrorMessage(error);
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { body, oneOf, param } from 'express-validator';
|
import { isIP } from 'node:net';
|
||||||
|
|
||||||
|
import { body, param } from 'express-validator';
|
||||||
import {
|
import {
|
||||||
Automation,
|
AutomationDTO,
|
||||||
AutomationFilter,
|
AutomationFilter,
|
||||||
AutomationOutput,
|
AutomationOutput,
|
||||||
HTTPOutput,
|
HTTPOutput,
|
||||||
@@ -56,7 +58,7 @@ export const validateAutomationPatch = [
|
|||||||
/**
|
/**
|
||||||
* Parses and validates a use given automation
|
* Parses and validates a use given automation
|
||||||
*/
|
*/
|
||||||
export function parseAutomation(maybeAutomation: unknown): Automation {
|
export function parseAutomation(maybeAutomation: unknown): AutomationDTO {
|
||||||
assert.isObject(maybeAutomation);
|
assert.isObject(maybeAutomation);
|
||||||
assert.hasKeys(maybeAutomation, ['title', 'filterRule', 'filters', 'outputs']);
|
assert.hasKeys(maybeAutomation, ['title', 'filterRule', 'filters', 'outputs']);
|
||||||
|
|
||||||
@@ -70,12 +72,12 @@ export function parseAutomation(maybeAutomation: unknown): Automation {
|
|||||||
validateFilters(filters);
|
validateFilters(filters);
|
||||||
|
|
||||||
assert.isArray(outputs);
|
assert.isArray(outputs);
|
||||||
validateOutput(outputs);
|
const parsedOutputs = outputs.map(parseOutput);
|
||||||
|
|
||||||
return maybeAutomation as Automation;
|
return { title, filterRule, filters, outputs: parsedOutputs };
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateFilters(filters: Array<unknown>): filters is AutomationFilter[] {
|
function validateFilters(filters: Array<unknown>): asserts filters is AutomationFilter[] {
|
||||||
filters.forEach((condition) => {
|
filters.forEach((condition) => {
|
||||||
assert.isObject(condition);
|
assert.isObject(condition);
|
||||||
|
|
||||||
@@ -92,47 +94,9 @@ function validateFilters(filters: Array<unknown>): filters is AutomationFilter[]
|
|||||||
throw new Error(`Invalid automation: unhandled filter type ${typeof value}`);
|
throw new Error(`Invalid automation: unhandled filter type ${typeof value}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateOutput(output: Array<unknown>): output is AutomationOutput[] {
|
export const validateTestPayload = [body().custom(parseOutput), requestValidationFunction];
|
||||||
output.forEach((payload) => {
|
|
||||||
parseOutput(payload);
|
|
||||||
});
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const validateTestPayload = [
|
|
||||||
body('type').isIn(['osc', 'http', 'ontime']),
|
|
||||||
|
|
||||||
// validation for OSC message
|
|
||||||
oneOf([
|
|
||||||
body('targetIP').if(body('type').equals('osc')).isIP(),
|
|
||||||
body('targetIP').if(body('type').equals('osc')).isFQDN(),
|
|
||||||
body('targetIP').if(body('type').equals('osc')).equals('localhost'),
|
|
||||||
]),
|
|
||||||
body('targetPort').if(body('type').equals('osc')).isPort(),
|
|
||||||
body('address').if(body('type').equals('osc')).isString().trim(),
|
|
||||||
body('args').if(body('type').equals('osc')).isString().trim(),
|
|
||||||
|
|
||||||
// validation for HTTP message
|
|
||||||
body('url').if(body('type').equals('http')).isURL({ require_tld: false }).trim(),
|
|
||||||
|
|
||||||
// validation for Ontime actions
|
|
||||||
body('action').if(body('type').equals('ontime')).isString().trim(),
|
|
||||||
body('text').if(body('type').equals('ontime')).optional().isString().trim(),
|
|
||||||
body('time').if(body('type').equals('ontime')).optional().isString().trim(),
|
|
||||||
body('visible').if(body('type').equals('ontime')).optional().isBoolean(),
|
|
||||||
// secondary source can be a enum case or null to clear it
|
|
||||||
body('secondarySource')
|
|
||||||
.if(body('type').equals('ontime'))
|
|
||||||
.optional({ nullable: true })
|
|
||||||
.if((value) => value !== null)
|
|
||||||
.isString()
|
|
||||||
.trim(),
|
|
||||||
|
|
||||||
requestValidationFunction,
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sanitises an output object
|
* Sanitises an output object
|
||||||
@@ -163,9 +127,25 @@ function parseOSCOutput(maybeOSCOutput: object): OSCOutput {
|
|||||||
assert.isString(maybeOSCOutput.address);
|
assert.isString(maybeOSCOutput.address);
|
||||||
assert.isString(maybeOSCOutput.args);
|
assert.isString(maybeOSCOutput.args);
|
||||||
|
|
||||||
|
const targetIP = maybeOSCOutput.targetIP.trim();
|
||||||
|
const target = replaceAutomationTemplates(targetIP, 'template.local');
|
||||||
|
const isHostname = /^(?=.{1,253}$)[a-z\d](?:[a-z\d-]{0,61}[a-z\d])?(?:\.[a-z\d](?:[a-z\d-]{0,61}[a-z\d])?)*$/i.test(
|
||||||
|
target,
|
||||||
|
);
|
||||||
|
if (isIP(target) !== 4 && !isHostname) {
|
||||||
|
throw new Error('Invalid OSC target');
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!Number.isInteger(maybeOSCOutput.targetPort) ||
|
||||||
|
maybeOSCOutput.targetPort < 1 ||
|
||||||
|
maybeOSCOutput.targetPort > 65535
|
||||||
|
) {
|
||||||
|
throw new Error('Invalid OSC port');
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: 'osc',
|
type: 'osc',
|
||||||
targetIP: maybeOSCOutput.targetIP,
|
targetIP,
|
||||||
targetPort: maybeOSCOutput.targetPort,
|
targetPort: maybeOSCOutput.targetPort,
|
||||||
address: maybeOSCOutput.address,
|
address: maybeOSCOutput.address,
|
||||||
args: maybeOSCOutput.args,
|
args: maybeOSCOutput.args,
|
||||||
@@ -176,12 +156,32 @@ function parseHTTPOutput(maybeHTTPOutput: object): HTTPOutput {
|
|||||||
assert.hasKeys(maybeHTTPOutput, ['url']);
|
assert.hasKeys(maybeHTTPOutput, ['url']);
|
||||||
assert.isString(maybeHTTPOutput.url);
|
assert.isString(maybeHTTPOutput.url);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = new URL(replaceHTTPTemplatesForValidation(maybeHTTPOutput.url));
|
||||||
|
if ((url.protocol !== 'http:' && url.protocol !== 'https:') || !url.hostname) {
|
||||||
|
throw new Error('Invalid HTTP URL');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
throw new Error('Invalid HTTP URL');
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: 'http',
|
type: 'http',
|
||||||
url: maybeHTTPOutput.url,
|
url: maybeHTTPOutput.url,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function replaceAutomationTemplates(value: string, replacement: string): string {
|
||||||
|
return value.replace(/{{.*?}}/g, replacement);
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceHTTPTemplatesForValidation(value: string): string {
|
||||||
|
if (/^{{.*?}}$/.test(value)) {
|
||||||
|
return 'https://template.local';
|
||||||
|
}
|
||||||
|
return replaceAutomationTemplates(value, 'template');
|
||||||
|
}
|
||||||
|
|
||||||
function parseOntimeAction(maybeOntimeAction: object): OntimeAction {
|
function parseOntimeAction(maybeOntimeAction: object): OntimeAction {
|
||||||
assert.hasKeys(maybeOntimeAction, ['action']);
|
assert.hasKeys(maybeOntimeAction, ['action']);
|
||||||
assert.isString(maybeOntimeAction.action);
|
assert.isString(maybeOntimeAction.action);
|
||||||
|
|||||||
Reference in New Issue
Block a user