diff --git a/apps/server/src/api-data/automation/__tests__/automation.controller.test.ts b/apps/server/src/api-data/automation/__tests__/automation.controller.test.ts new file mode 100644 index 000000000..5d9bf71f9 --- /dev/null +++ b/apps/server/src/api-data/automation/__tests__/automation.controller.test.ts @@ -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; +} + +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' })] }), + ); + }); + +}); diff --git a/apps/server/src/api-data/automation/__tests__/automation.validation.test.ts b/apps/server/src/api-data/automation/__tests__/automation.validation.test.ts index a5e02c9bd..0e425d85f 100644 --- a/apps/server/src/api-data/automation/__tests__/automation.validation.test.ts +++ b/apps/server/src/api-data/automation/__tests__/automation.validation.test.ts @@ -5,13 +5,13 @@ describe('parseOutput', () => { it('parses a valid payload', () => { const payload = { type: 'osc', - targetIP: 'localhost', + targetIP: ' qlab ', targetPort: 1234, address: '/test', args: 'test', }; const result = parseOutput(payload); - expect(result).toStrictEqual(payload); + expect(result).toStrictEqual({ ...payload, targetIP: 'qlab' }); }); it('throws on a invalid payload', () => { @@ -24,6 +24,33 @@ describe('parseOutput', () => { }; 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', () => { it('parses a valid payload', () => { @@ -41,6 +68,25 @@ describe('parseOutput', () => { }; 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', () => { it('parses a valid payload', () => { diff --git a/apps/server/src/api-data/automation/automation.controller.ts b/apps/server/src/api-data/automation/automation.controller.ts index 590232b1b..b9cfcf34e 100644 --- a/apps/server/src/api-data/automation/automation.controller.ts +++ b/apps/server/src/api-data/automation/automation.controller.ts @@ -6,7 +6,7 @@ import { oscServer } from '../../adapters/OscAdapter.js'; import { getDataProvider } from '../../classes/data-provider/DataProvider.js'; import * as automationDao from './automation.dao.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) { res.status(200).json(automationDao.getAutomationSettings()); @@ -75,12 +75,8 @@ export async function deleteTrigger(req: Request, res: Response) { try { - const newAutomation = await automationDao.addAutomation({ - title: req.body.title, - filterRule: req.body.filterRule, - filters: req.body.filters, - outputs: req.body.outputs, - }); + const automation = parseAutomation(req.body); + const newAutomation = await automationDao.addAutomation(automation); res.status(201).send(newAutomation); } catch (error) { const message = getErrorMessage(error); @@ -90,12 +86,8 @@ export async function postAutomation(req: Request, res: Response) { try { - const newAutomation = await automationDao.editAutomation(req.params.id, { - title: req.body.title, - filterRule: req.body.filterRule, - filters: req.body.filters, - outputs: req.body.outputs, - }); + const automation = parseAutomation(req.body); + const newAutomation = await automationDao.editAutomation(req.params.id, automation); res.status(200).send(newAutomation); } catch (error) { const message = getErrorMessage(error); diff --git a/apps/server/src/api-data/automation/automation.validation.ts b/apps/server/src/api-data/automation/automation.validation.ts index 4295d3aed..4471106d5 100644 --- a/apps/server/src/api-data/automation/automation.validation.ts +++ b/apps/server/src/api-data/automation/automation.validation.ts @@ -1,6 +1,8 @@ -import { body, oneOf, param } from 'express-validator'; +import { isIP } from 'node:net'; + +import { body, param } from 'express-validator'; import { - Automation, + AutomationDTO, AutomationFilter, AutomationOutput, HTTPOutput, @@ -56,7 +58,7 @@ export const validateAutomationPatch = [ /** * Parses and validates a use given automation */ -export function parseAutomation(maybeAutomation: unknown): Automation { +export function parseAutomation(maybeAutomation: unknown): AutomationDTO { assert.isObject(maybeAutomation); assert.hasKeys(maybeAutomation, ['title', 'filterRule', 'filters', 'outputs']); @@ -70,12 +72,12 @@ export function parseAutomation(maybeAutomation: unknown): Automation { validateFilters(filters); assert.isArray(outputs); - validateOutput(outputs); + const parsedOutputs = outputs.map(parseOutput); - return maybeAutomation as Automation; + return { title, filterRule, filters, outputs: parsedOutputs }; } -function validateFilters(filters: Array): filters is AutomationFilter[] { +function validateFilters(filters: Array): asserts filters is AutomationFilter[] { filters.forEach((condition) => { assert.isObject(condition); @@ -92,47 +94,9 @@ function validateFilters(filters: Array): filters is AutomationFilter[] throw new Error(`Invalid automation: unhandled filter type ${typeof value}`); } }); - return true; } -function validateOutput(output: Array): output is AutomationOutput[] { - 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, -]; +export const validateTestPayload = [body().custom(parseOutput), requestValidationFunction]; /** * Sanitises an output object @@ -163,9 +127,25 @@ function parseOSCOutput(maybeOSCOutput: object): OSCOutput { assert.isString(maybeOSCOutput.address); 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 { type: 'osc', - targetIP: maybeOSCOutput.targetIP, + targetIP, targetPort: maybeOSCOutput.targetPort, address: maybeOSCOutput.address, args: maybeOSCOutput.args, @@ -176,12 +156,32 @@ function parseHTTPOutput(maybeHTTPOutput: object): HTTPOutput { assert.hasKeys(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 { type: 'http', 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 { assert.hasKeys(maybeOntimeAction, ['action']); assert.isString(maybeOntimeAction.action);