diff --git a/core/api-server/lib/service/execution.js b/core/api-server/lib/service/execution.js index 1d565ed07..d058ea73d 100644 --- a/core/api-server/lib/service/execution.js +++ b/core/api-server/lib/service/execution.js @@ -52,20 +52,23 @@ class ExecutionService { } async _runStored(options) { - const { pipeline, jobId, types } = options; + const { pipeline, jobId, rootJobId, types } = options; const storedPipeline = await stateManager.pipelines.get({ name: pipeline.name }); if (!storedPipeline) { throw new ResourceNotFoundError('pipeline', pipeline.name); } const newPipeline = mergeWith(storedPipeline, pipeline, (obj, src, key) => (key === 'flowInput' ? src || obj : undefined)); - return this._run({ pipeline: newPipeline, jobId, options: { parentSpan: pipeline.spanId }, types }); + return this._run({ pipeline: newPipeline, jobId, rootJobId, options: { parentSpan: pipeline.spanId }, types }); } async _run(payload) { let { pipeline, jobId } = payload; - const { types } = payload; + const { types, rootJobId } = payload; const { alreadyExecuted, parentSpan } = payload.options || {}; + validator.addPipelineDefaults(pipeline); + validator.validatePipeline(pipeline); + if (!jobId) { jobId = this._createJobID({ name: pipeline.name, experimentName: pipeline.experimentName }); } @@ -85,7 +88,7 @@ class ExecutionService { }; } const lastRunResult = await this._getLastPipeline(jobId); - const pipelineObject = { ...pipeline, jobId, startTime: Date.now(), lastRunResult, types }; + const pipelineObject = { ...pipeline, jobId, rootJobId, startTime: Date.now(), lastRunResult, types }; await storageManager.hkubeIndex.put({ jobId }, tracer.startSpan.bind(tracer, { name: 'storage-put-index', parent: span.context() })); await storageManager.hkubeExecutions.put({ jobId, data: pipelineObject }, tracer.startSpan.bind(tracer, { name: 'storage-put-executions', parent: span.context() })); await stateManager.executions.stored.set(pipelineObject); diff --git a/core/api-server/lib/service/internal.js b/core/api-server/lib/service/internal.js index 0fc8f42e1..905663cd6 100644 --- a/core/api-server/lib/service/internal.js +++ b/core/api-server/lib/service/internal.js @@ -23,24 +23,23 @@ class InternalService { async runStoredSubPipeline(options) { validator.validateStoredSubPipeline(options); - const pipeline = await this._createPipeline(options); + const { pipeline, rootJobId } = await this._createPipeline(options); const parentSpan = options.spanId; - return execution._runStored({ pipeline, options: { parentSpan }, types: [pipelineTypes.INTERNAL, pipelineTypes.STORED, pipelineTypes.SUB_PIPELINE] }); + return execution._runStored({ pipeline, rootJobId, options: { parentSpan }, types: [pipelineTypes.INTERNAL, pipelineTypes.STORED, pipelineTypes.SUB_PIPELINE] }); } async runRawSubPipeline(options) { validator.validateRawSubPipeline(options); - const pipeline = await this._createPipeline(options); + const { pipeline, rootJobId } = await this._createPipeline(options); const parentSpan = options.spanId; - return execution._run({ pipeline, options: { parentSpan }, types: [pipelineTypes.INTERNAL, pipelineTypes.RAW, pipelineTypes.SUB_PIPELINE] }); + return execution._run({ pipeline, rootJobId, options: { parentSpan }, types: [pipelineTypes.INTERNAL, pipelineTypes.RAW, pipelineTypes.SUB_PIPELINE] }); } async _createPipeline(options) { const { jobId, taskId, rootJobId, ...pipeline } = options; const experimentName = await this._getExperimentName({ jobId }); - pipeline.rootJobId = rootJobId || jobId; pipeline.experimentName = experimentName; - return pipeline; + return { pipeline, rootJobId: rootJobId || jobId }; } async _getExperimentName(options) { diff --git a/core/api-server/lib/validation/api-validator.js b/core/api-server/lib/validation/api-validator.js index 62ea9415a..46f3b4ff6 100644 --- a/core/api-server/lib/validation/api-validator.js +++ b/core/api-server/lib/validation/api-validator.js @@ -74,12 +74,16 @@ class ApiValidator { this._validate(this._definitionsInternal.storedSubPipeline, pipeline, false); } + validatePipeline(pipeline) { + this._validate(this._definitions.pipeline, pipeline, false, { checkFlowInput: true }); + } + validateRunRawPipeline(pipeline) { this._validate(this._definitions.pipeline, pipeline, false, { checkFlowInput: true }); } validateRunStoredPipeline(pipeline) { - this._validate(this._definitions.storedPipelineRequest, pipeline, false, { checkFlowInput: true }); + this._validate(this._definitions.storedPipelineRequest, pipeline, false); } validateCaching(request) { @@ -205,10 +209,13 @@ class ApiValidator { const valid = validatorInstance.validate(schema, object); if (!valid) { const { errors } = validatorInstance; - let error = validatorInstance.errorsText(errors); + let error = validatorInstance.errorsText(errors, { extraInfo: true }); if (errors[0].params && errors[0].params.allowedValues) { error += ` (${errors[0].params.allowedValues.join(',')})`; } + else if (errors[0].params && errors[0].params.additionalProperty) { + error += ` (${errors[0].params.additionalProperty})`; + } throw new InvalidDataError(error); } if (object.nodes) { @@ -352,12 +359,12 @@ class ApiValidator { } wrapErrorMessageFn(wrappedFn) { - const errorsTextWapper = (errors) => { + const errorsTextWapper = (errors, options) => { let message; if (errors) { message = this.getCustomMessage(errors[0]); } - return message || wrappedFn(errors); + return message || wrappedFn(errors, options); }; return errorsTextWapper; } diff --git a/core/api-server/tests/common.js b/core/api-server/tests/common.js index bf97de772..b84ca69d9 100644 --- a/core/api-server/tests/common.js +++ b/core/api-server/tests/common.js @@ -4,27 +4,57 @@ const swagger = require('../api/rest-api/swagger.json') const { request } = require('./utils'); const httpMethods = ['GET', 'POST', 'PUT', 'DELETE']; -describe('Method Not Allowed', () => { +describe('Common', () => { before(() => { restUrl = global.testParams.restUrl; }); - Object.entries(swagger.paths).forEach(([k, v]) => { - it(`${k} - should throw Method Not Allowed`, async () => { - const keys = Object.keys(v).map(m => m.toUpperCase()); - const methods = httpMethods.filter(h => !keys.includes(h)) - const method = methods[0]; - const uri = `${restUrl}${k}`; - const options = { - method, - uri, - body: {} - }; - const response = await request(options); - if (response.body.error && response.body.error.code === HttpStatus.METHOD_NOT_ALLOWED) { - expect(response.body).to.have.property('error'); - expect(response.body.error.code).to.equal(HttpStatus.METHOD_NOT_ALLOWED); - expect(response.body.error.message).to.equal('Method Not Allowed'); - } + describe('Method Not Allowed', () => { + Object.entries(swagger.paths).forEach(([k, v]) => { + it(`${k} - should throw Method Not Allowed`, async () => { + const keys = Object.keys(v).map(m => m.toUpperCase()); + const methods = httpMethods.filter(h => !keys.includes(h)) + const method = methods[0]; + const uri = `${restUrl}${k}`; + const options = { + method, + uri, + body: {} + }; + const response = await request(options); + if (response.body.error && response.body.error.code === HttpStatus.METHOD_NOT_ALLOWED) { + expect(response.body).to.have.property('error'); + expect(response.body.error.code).to.equal(HttpStatus.METHOD_NOT_ALLOWED); + expect(response.body.error.message).to.equal('Method Not Allowed'); + } + }); + }); + }); + describe.skip('should NOT have additional properties', () => { + Object.entries(swagger.paths).forEach(([k, v]) => { + it(`${k} - should NOT have additional properties`, async () => { + const method = Object.keys(v).map(m => m.toUpperCase()).find(m => m === 'POST'); + if (!method) { + return; + } + const content = v.post.requestBody && v.post.requestBody.content['application/json']; + if (!content || content.additionalProperties || (content.schema && !content.schema.hasOwnProperty('additionalProperties'))) { + return; + } + const uri = `${restUrl}${k}`; + const options = { + method, + uri, + body: { + no_such_prop: 'bla' + } + }; + const response = await request(options); + if (response.body.error && response.body.error.code === HttpStatus.BAD_REQUEST) { + expect(response.body).to.have.property('error'); + expect(response.body.error.code).to.equal(HttpStatus.BAD_REQUEST); + expect(response.body.error.message).to.equal('Method Not Allowed'); + } + }); }); }) }); diff --git a/core/api-server/tests/exec-algorithm.js b/core/api-server/tests/exec-algorithm.js index 3aae282a8..805ff14fa 100644 --- a/core/api-server/tests/exec-algorithm.js +++ b/core/api-server/tests/exec-algorithm.js @@ -64,29 +64,6 @@ describe('Executions', () => { expect(response.body).to.have.property('error'); expect(response.body.error.code).to.equal(HttpStatus.BAD_REQUEST); }); - it('should throw validation error of data should NOT have additional properties', async () => { - const options = { - uri: restPath, - body: { - name: 'string', - nodes: [ - { - nodeName: 'string', - algorithmName: 'green-alg', - input: [] - } - ], - additionalProps: { - bla: 60, - blabla: 'info' - } - } - }; - const response = await request(options); - expect(response.body).to.have.property('error'); - expect(response.body.error.code).to.equal(HttpStatus.BAD_REQUEST); - expect(response.body.error.message).to.equal('data should NOT have additional properties'); - }); it('should throw validation error of algorithm not found', async () => { const options = { uri: restPath, diff --git a/core/api-server/tests/exec-raw.js b/core/api-server/tests/exec-raw.js index b67bba5d2..14a084dfd 100644 --- a/core/api-server/tests/exec-raw.js +++ b/core/api-server/tests/exec-raw.js @@ -69,7 +69,7 @@ describe('Executions', () => { const response = await request(options); expect(response.body).to.have.property('error'); expect(response.body.error.code).to.equal(HttpStatus.BAD_REQUEST); - expect(response.body.error.message).to.equal('data should NOT have additional properties'); + expect(response.body.error.message).to.equal('data should NOT have additional properties (stam)'); }); it('should fail on no such node or job', async () => { const options = { @@ -231,7 +231,7 @@ describe('Executions', () => { const response = await request(options); expect(response.body).to.have.property('error'); expect(response.body.error.code).to.equal(HttpStatus.BAD_REQUEST); - expect(response.body.error.message).to.equal('data should NOT have additional properties'); + expect(response.body.error.message).to.equal('data should NOT have additional properties (additionalProps)'); }); it('should throw validation error of duplicate node', async () => { const options = { diff --git a/core/api-server/tests/exec-stored.js b/core/api-server/tests/exec-stored.js index cbd5fd07d..5f42ba540 100644 --- a/core/api-server/tests/exec-stored.js +++ b/core/api-server/tests/exec-stored.js @@ -65,7 +65,7 @@ describe('Executions', () => { const response = await request(options); expect(response.body).to.have.property('error'); expect(response.body.error.code).to.equal(HttpStatus.BAD_REQUEST); - expect(response.body.error.message).to.equal('data should NOT have additional properties'); + expect(response.body.error.message).to.equal('data should NOT have additional properties (nodes)'); }); it('should throw pipeline not found', async () => { const options = { @@ -91,6 +91,18 @@ describe('Executions', () => { expect(response.body.error.code).to.equal(HttpStatus.BAD_REQUEST); expect(response.body.error.message).to.equal(validationMessages.PIPELINE_NAME_FORMAT); }); + it('should throw unable to find flowInput', async () => { + const options = { + uri: restPath, + body: { + name: 'flowInput' + } + }; + const response = await request(options); + expect(response.body).to.have.property('error'); + expect(response.body.error.code).to.equal(HttpStatus.BAD_REQUEST); + expect(response.body.error.message).to.equal('unable to find flowInput.files.links'); + }); it('should succeed and return job id', async () => { const options = { uri: restPath, @@ -120,7 +132,7 @@ describe('Executions', () => { const options = { uri: restPath, body: { - name: 'flow4', + name: 'override-flowInput', flowInput: { inp: [ [], diff --git a/core/api-server/tests/internal.js b/core/api-server/tests/internal.js index 450d2b039..120b9598d 100644 --- a/core/api-server/tests/internal.js +++ b/core/api-server/tests/internal.js @@ -216,10 +216,7 @@ describe('Internal', () => { body: { name: pipeline.name, jobId: response1.body.jobId, - taskId: `taskId:${uuid()} `, - flowInput: { - bla: 'bla' - } + taskId: `taskId:${uuid()} ` } }; const response2 = await request(options2); @@ -232,10 +229,7 @@ describe('Internal', () => { body: { name: pipeline.name, jobId: `jobId - ${uuid()} `, - taskId: `taskId - ${uuid()} `, - flowInput: { - bla: 'bla' - } + taskId: `taskId - ${uuid()} ` } }; const res1 = await request(options); @@ -254,18 +248,15 @@ describe('Internal', () => { name: pipeline.name, nodes: [ { - "nodeName": "green", - "algorithmName": "green-alg", - "input": [ - "@flowInput" + nodeName: "green", + algorithmName: "green-alg", + input: [ + "data" ] } ], - jobId: `jobId - ${uuid()} `, - taskId: `taskId - ${uuid()} `, - flowInput: { - bla: 'bla' - } + jobId: `jobId-${uuid()}`, + taskId: `taskId-${uuid()}` } }; const response = await request(options); @@ -279,18 +270,15 @@ describe('Internal', () => { name: pipeline.name, nodes: [ { - "nodeName": "green", - "algorithmName": "green-alg", - "input": [ - "@flowInput" + nodeName: "green", + algorithmName: "green-alg", + input: [ + "data" ] } ], - jobId: `jobId - ${uuid()} `, - taskId: `taskId - ${uuid()} `, - flowInput: { - bla: 'bla' - } + jobId: `jobId-${uuid()}`, + taskId: `taskId-${uuid()}` } }; const res1 = await request(options); @@ -321,8 +309,8 @@ describe('Internal', () => { } ], flowInput: { - "files": { - "link": "links-1" + files: { + link: "links-1" } }, triggers: { diff --git a/core/api-server/tests/mocks/pipelines.json b/core/api-server/tests/mocks/pipelines.json index dbb302c98..3d291e777 100644 --- a/core/api-server/tests/mocks/pipelines.json +++ b/core/api-server/tests/mocks/pipelines.json @@ -7,40 +7,28 @@ "algorithmName": "green-alg", "input": [ "@flowInput.files.link" - ], - "metrics": { - "tensorboard": true - } + ] }, { "nodeName": "yellow", "algorithmName": "yellow-alg", "input": [ "@green" - ], - "metrics": { - "tensorboard": true - } + ] }, { "nodeName": "black", "algorithmName": "black-alg", "input": [ "@yellow" - ], - "metrics": { - "tensorboard": true - } + ] }, { "nodeName": "white", "algorithmName": "black-alg", "input": [ "test" - ], - "metrics": { - "tensorboard": true - } + ] } ], "flowInput": { @@ -63,20 +51,14 @@ "algorithmName": "green-alg", "input": [ "#@flowInput.files.links" - ], - "metrics": { - "tensorboard": true - } + ] }, { "nodeName": "yellow", "algorithmName": "yellow-alg", "input": [ "@green" - ], - "metrics": { - "tensorboard": true - } + ] }, { "nodeName": "black", @@ -84,10 +66,7 @@ "input": [ "@green", "@yellow" - ], - "metrics": { - "tensorboard": true - } + ] } ], "flowInput": { @@ -115,30 +94,21 @@ "algorithmName": "green-alg", "input": [ "#@flowInput.files.links" - ], - "metrics": { - "tensorboard": true - } + ] }, { "nodeName": "yellow", "algorithmName": "yellow-alg", "input": [ "#@flowInput.files.links" - ], - "metrics": { - "tensorboard": true - } + ] }, { "nodeName": "black", "algorithmName": "black-alg", "input": [ "#@flowInput.files.links" - ], - "metrics": { - "tensorboard": true - } + ] } ], "flowInput": { @@ -166,37 +136,59 @@ } }, { - "name": "flow4", + "name": "flowInput", "nodes": [ { "nodeName": "green", "algorithmName": "green-alg", "input": [ "#@flowInput.files.links" - ], - "metrics": { - "tensorboard": true - } + ] }, { "nodeName": "yellow", "algorithmName": "yellow-alg", "input": [ "#@flowInput.files.links" - ], - "metrics": { - "tensorboard": true - } + ] }, { "nodeName": "black", "algorithmName": "black-alg", "input": [ "#@flowInput.files.links" - ], - "metrics": { - "tensorboard": true - } + ] + } + ], + "flowInput": { + "files": { + "link": "links-1" + } + } + }, + { + "name": "override-flowInput", + "nodes": [ + { + "nodeName": "green", + "algorithmName": "green-alg", + "input": [ + "@flowInput" + ] + }, + { + "nodeName": "yellow", + "algorithmName": "yellow-alg", + "input": [ + "@flowInput" + ] + }, + { + "nodeName": "black", + "algorithmName": "black-alg", + "input": [ + "@flowInput" + ] } ], "flowInput": { @@ -225,30 +217,21 @@ "algorithmName": "yellow-alg", "input": [ "@green" - ], - "metrics": { - "tensorboard": true - } + ] }, { "nodeName": "black", "algorithmName": "black-alg", "input": [ "@yellow" - ], - "metrics": { - "tensorboard": true - } + ] }, { "nodeName": "white", "algorithmName": "black-alg", "input": [ "test" - ], - "metrics": { - "tensorboard": true - } + ] } ], "flowInput": { @@ -309,20 +292,14 @@ { "nodeName": "A", "algorithmName": "green-alg", - "input": [], - "metrics": { - "tensorboard": true - } + "input": [] }, { "nodeName": "B", "algorithmName": "green-alg", "input": [ "@C" - ], - "metrics": { - "tensorboard": true - } + ] } ] }, @@ -334,20 +311,14 @@ "algorithmName": "green-alg", "input": [ "@B" - ], - "metrics": { - "tensorboard": true - } + ] }, { "nodeName": "B", "algorithmName": "green-alg", "input": [ "@A" - ], - "metrics": { - "tensorboard": true - } + ] } ] }, @@ -359,10 +330,7 @@ "algorithmName": "green-alg", "input": [ "flowInput" - ], - "metrics": { - "tensorboard": true - } + ] } ], "triggers": { @@ -380,10 +348,7 @@ "algorithmName": "green-alg", "input": [ "flowInput" - ], - "metrics": { - "tensorboard": true - } + ] } ], "triggers": { @@ -401,10 +366,7 @@ "algorithmName": "green-alg", "input": [ "flowInput" - ], - "metrics": { - "tensorboard": true - } + ] } ], "triggers": {} @@ -417,10 +379,7 @@ "algorithmName": "green-alg", "input": [ "flowInput" - ], - "metrics": { - "tensorboard": true - } + ] } ], "options": { diff --git a/core/api-server/tests/pipelines-store.js b/core/api-server/tests/pipelines-store.js index d7d510e7a..60c8b1d13 100644 --- a/core/api-server/tests/pipelines-store.js +++ b/core/api-server/tests/pipelines-store.js @@ -242,7 +242,7 @@ describe('Store/Pipelines', () => { const response = await request(options); expect(response.body).to.have.property('error'); expect(response.body.error.code).to.equal(HttpStatus.BAD_REQUEST); - expect(response.body.error.message).to.equal('data should NOT have additional properties'); + expect(response.body.error.message).to.equal('data should NOT have additional properties (additionalProps)'); }); it('should throw conflict error', async () => { const pipeline = clone(pipelines[0]); diff --git a/core/api-server/tests/webhooks.js b/core/api-server/tests/webhooks.js index bffb6d7fb..931d1cfbb 100644 --- a/core/api-server/tests/webhooks.js +++ b/core/api-server/tests/webhooks.js @@ -11,42 +11,8 @@ describe('Webhooks', () => { nock('http://my-webhook-server-2').persist().post('/webhook/result').reply(200); nock('http://my-webhook-server-2').persist().post('/webhook/progress').reply(200); }); - describe('Results', () => { - it('should succeed to send webhook result', async () => { - return new Promise(async (resolve) => { - nock('http://my-webhook-server-1') - .post('/webhook/result') - .reply(200, async (uri, requestBody) => { - expect(requestBody).to.have.property('data'); - expect(requestBody).to.have.property('jobId'); - expect(requestBody).to.have.property('status'); - expect(requestBody).to.have.property('timestamp'); - - const status = { - uri: `${restUrl}/exec/results/${requestBody.jobId}`, - method: 'GET' - }; - const response = await request(status); - expect(requestBody).to.deep.equal(response.body); - return resolve(); - - }) - const stored = { - uri: restUrl + '/exec/stored', - body: { name: 'webhookFlow1' } - }; - const response = await request(stored); - - const results = { - jobId: response.body.jobId, - status: 'completed', - data: [{ res1: 400 }, { res2: 500 }] - } - await stateManager.jobs.status.set(results); - await stateManager.jobs.results.set(results); - }); - }); - it('should throw webhooks validation error of should match format "url', async () => { + describe('Validation', () => { + it('should throw webhooks validation error of result should match format "url', async () => { const options = { uri: restUrl + '/exec/raw', body: { @@ -68,6 +34,28 @@ describe('Webhooks', () => { expect(response.body.error.code).to.equal(HttpStatus.BAD_REQUEST); expect(response.body.error.message).to.equal('data.webhooks.result should match format "url"'); }); + it('should throw webhooks validation error of progress should match format "url', async () => { + const options = { + uri: restUrl + '/exec/raw', + body: { + name: 'string', + nodes: [ + { + nodeName: 'string', + algorithmName: 'green-alg', + input: [] + } + ], + webhooks: { + progress: 'not_a_url' + } + } + }; + const response = await request(options); + expect(response.body).to.have.property('error'); + expect(response.body.error.code).to.equal(HttpStatus.BAD_REQUEST); + expect(response.body.error.message).to.equal('data.webhooks.progress should match format "url"'); + }); it('should throw webhooks validation error of NOT have additional properties', async () => { const options = { uri: restUrl + '/exec/raw', @@ -81,14 +69,50 @@ describe('Webhooks', () => { } ], webhooks: { - result2: 'http://localhost' + no_such_prop: 'http://localhost' } } }; const response = await request(options); expect(response.body).to.have.property('error'); expect(response.body.error.code).to.equal(HttpStatus.BAD_REQUEST); - expect(response.body.error.message).to.equal('data.webhooks should NOT have additional properties'); + expect(response.body.error.message).to.equal('data.webhooks should NOT have additional properties (no_such_prop)'); + }); + }); + describe('Results', () => { + it('should succeed to send webhook result', async () => { + return new Promise(async (resolve) => { + nock('http://my-webhook-server-1') + .post('/webhook/result') + .reply(200, async (uri, requestBody) => { + expect(requestBody).to.have.property('data'); + expect(requestBody).to.have.property('jobId'); + expect(requestBody).to.have.property('status'); + expect(requestBody).to.have.property('timestamp'); + + const status = { + uri: `${restUrl}/exec/results/${requestBody.jobId}`, + method: 'GET' + }; + const response = await request(status); + expect(requestBody).to.deep.equal(response.body); + return resolve(); + + }) + const stored = { + uri: restUrl + '/exec/stored', + body: { name: 'webhookFlow1' } + }; + const response = await request(stored); + + const results = { + jobId: response.body.jobId, + status: 'completed', + data: [{ res1: 400 }, { res2: 500 }] + } + await stateManager.jobs.status.set(results); + await stateManager.jobs.results.set(results); + }); }); it('should succeed to store pipeline with webhooks', async () => { const options = { @@ -180,50 +204,6 @@ describe('Webhooks', () => { await request(stored); }); }); - it('should throw webhooks validation error of should match format "url', async () => { - const options = { - uri: restUrl + '/exec/raw', - body: { - name: 'string', - nodes: [ - { - nodeName: 'string', - algorithmName: 'green-alg', - input: [] - } - ], - webhooks: { - progress: 'not_a_url' - } - } - }; - const response = await request(options); - expect(response.body).to.have.property('error'); - expect(response.body.error.code).to.equal(HttpStatus.BAD_REQUEST); - expect(response.body.error.message).to.equal('data.webhooks.progress should match format "url"'); - }); - it('should throw webhooks validation error of NOT have additional properties', async () => { - const options = { - uri: restUrl + '/exec/raw', - body: { - name: 'string', - nodes: [ - { - nodeName: 'string', - algorithmName: 'green-alg', - input: [] - } - ], - webhooks: { - progress2: 'http://localhost' - } - } - }; - const response = await request(options); - expect(response.body).to.have.property('error'); - expect(response.body.error.code).to.equal(HttpStatus.BAD_REQUEST); - expect(response.body.error.message).to.equal('data.webhooks should NOT have additional properties'); - }); it('should succeed to store pipeline with webhooks', async () => { const options = { uri: restUrl + '/exec/raw',