diff --git a/lib/customValidators.js b/lib/customValidators.js index 925930428..47eb7ae60 100644 --- a/lib/customValidators.js +++ b/lib/customValidators.js @@ -133,8 +133,108 @@ function validateOperationId(parsedJSON, asyncapiYAMLorJSON, initialFormat, oper return true; } +/** + * Validates if server security is declared properly and the name has a corresponding security schema definition in components with the same name + * + * @param {Object} parsedJSON parsed AsyncAPI document + * @param {String} asyncapiYAMLorJSON AsyncAPI document in string + * @param {String} initialFormat information of the document was oryginally JSON or YAML + * @param {Array[String]} specialSecTypes list of security types that can have data in array + * @returns {Boolean} true in case the document is valid, otherwise throws ParserError + */ +function validateServerSecurity(parsedJSON, asyncapiYAMLorJSON, initialFormat, specialSecTypes) { + const srvs = parsedJSON.servers; + if (!srvs) return true; + + const root = 'servers'; + const srvsMap = new Map(Object.entries(srvs)); + + const missingSecSchema = new Map(); + const invalidSecurityValues = new Map(); + + srvsMap.forEach((server, serverName) => { + const serverSecInfo = server.security; + + if (!serverSecInfo) return true; + + serverSecInfo.forEach(secObj => { + Object.keys(secObj).forEach(secName => { + const schema = findSecuritySchema(secName, parsedJSON.components); + const srvrSecurityPath = `${serverName}/security/${secName}`; + + if (!schema.length) return missingSecSchema.set(srvrSecurityPath); + + const schemaType = schema[1]; + if (!isSrvrSecProperArray(schemaType, specialSecTypes, secObj, secName)) invalidSecurityValues.set(srvrSecurityPath, schemaType); + }); + }); + }); + + if (missingSecSchema.size) { + throw new ParserError({ + type: validationError, + title: 'Server security name must correspond to a security scheme which is declared in the security schemes under the components object.', + parsedJSON, + validationErrors: groupValidationErrors(root, 'doesn\'t have a corresponding security schema under the components object', missingSecSchema, asyncapiYAMLorJSON, initialFormat) + }); + } + + if (invalidSecurityValues.size) { + throw new ParserError({ + type: validationError, + title: 'Server security value must be an empty array if corresponding security schema type is not oauth2 or openIdConnect.', + parsedJSON, + validationErrors: groupValidationErrors(root, 'security info must have an empty array because its corresponding security schema type is', invalidSecurityValues, asyncapiYAMLorJSON, initialFormat) + }); + } + + return true; +} + +/** + * Searches for server security corresponding object in security schema object + * @private + * @param {String} securityName name of the server security element that you want to localize in the security schema object + * @param {Object} components components object from the AsyncAPI document + * @returns {Array[String]} there are 2 elements in array, index 0 is the name of the security schema object and index 1 is it's type + */ +function findSecuritySchema(securityName, components) { + const secSchemes = components && components.securitySchemes; + const secSchemesMap = secSchemes ? new Map(Object.entries(secSchemes)) : new Map(); + const schemaInfo = []; + + //using for loop here as there is no point to iterate over all entries as it is enough to find first matching element + for (const [schemaName, schema] of secSchemesMap.entries()) { + if (schemaName === securityName) { + schemaInfo.push(schemaName, schema.type); + return schemaInfo; + } + } + return schemaInfo; +} + +/** + * Validates if given server security is a proper empty array when security type requires it + * @private + * @param {String} schemaType security type, like httpApiKey or userPassword + * @param {Array[String]} specialSecTypes list of special types that do not have to be an empty array + * @param {Object} secObj server security object + * @param {String} secName name os server security object + * @returns {Array[String]} there are 2 elements in array, index 0 is the name of the security schema object and index 1 is it's type + */ +function isSrvrSecProperArray(schemaType, specialSecTypes, secObj, secName) { + if (!specialSecTypes.includes(schemaType)) { + const securityObjValue = secObj[String(secName)]; + + return !securityObjValue.length; + } + + return true; +} + module.exports = { validateChannelParams, validateServerVariables, - validateOperationId + validateOperationId, + validateServerSecurity }; \ No newline at end of file diff --git a/lib/parser.js b/lib/parser.js index 9ef43fab4..d7b1a2a4a 100644 --- a/lib/parser.js +++ b/lib/parser.js @@ -5,12 +5,14 @@ const asyncapi = require('@asyncapi/specs'); const $RefParser = require('@apidevtools/json-schema-ref-parser'); const mergePatch = require('tiny-merge-patch').apply; const ParserError = require('./errors/parser-error'); -const { validateChannelParams, validateServerVariables, validateOperationId } = require('./customValidators.js'); +const { validateChannelParams, validateServerVariables, validateOperationId, validateServerSecurity } = require('./customValidators.js'); const { toJS, findRefs, getLocationOf, improveAjvErrors } = require('./utils'); const AsyncAPIDocument = require('./models/asyncapi'); const DEFAULT_SCHEMA_FORMAT = 'application/vnd.aai.asyncapi;version=2.0.0'; const OPERATIONS = ['publish', 'subscribe']; +//the only security types that can have a non empty array in the server security item +const SPECIAL_SECURITY_TYPES = ['oauth2', 'openIdConnect']; const PARSERS = {}; /** @@ -149,7 +151,9 @@ function parseFromUrl(url, fetchOptions = {}, options) { } async function customDocumentOperations(js, asyncapiYAMLorJSON, initialFormat, options) { - if (js.servers) validateServerVariables(js, asyncapiYAMLorJSON, initialFormat); + validateServerVariables(js, asyncapiYAMLorJSON, initialFormat); + validateServerSecurity(js, asyncapiYAMLorJSON, initialFormat, SPECIAL_SECURITY_TYPES); + if (!js.channels) return; validateChannelParams(js, asyncapiYAMLorJSON, initialFormat); diff --git a/lib/utils.js b/lib/utils.js index 04c4a66f9..c8f804c8e 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -28,9 +28,17 @@ const findNodeInAST = (ast, location) => { let obj = ast; for (const key of location) { if (!Array.isArray(obj.children)) return; - const child = obj.children.find(c => c && c.type === 'Property' && c.key && c.key.value === utils.untilde(key)); + let childArray; + + const child = obj.children.find(c => { + if (!c) return; + + if (c.type === 'Object') return childArray = c.children.find(a => a.key.value === utils.untilde(key)); + return c.type === 'Property' && c.key && c.key.value === utils.untilde(key); + }); + if (!child) return; - obj = child.value; + obj = childArray ? childArray.value : child.value; } return obj; }; @@ -281,7 +289,7 @@ utils.getMissingProps = (arr, obj) => { * @param {String} root name of the root element in the AsyncAPI document, for example channels * @param {String} errorMessage the text of the custom error message that will follow the path that points the error * @param {Map} errorElements map of error elements cause the validation error might happen in many places in the document. - * The key should have a path information where the error was found, the value holds information about error element + * The key should have a path information where the error was found, the value holds information about error element but it is not mandatory * @param {String} asyncapiYAMLorJSON AsyncAPI document in string * @param {String} initialFormat information of the document was oryginally JSON or YAML * @returns {Array} Object has always 2 keys, title and location. Title is a combination of errorElement key + errorMessage + errorElement value. @@ -294,7 +302,7 @@ utils.groupValidationErrors = (root, errorMessage, errorElements, asyncapiYAMLor if (typeof val === 'string') val = utils.untilde(val); errors.push({ - title: `${ utils.untilde(key) } ${errorMessage}: ${val}`, + title: val ? `${ utils.untilde(key) } ${errorMessage}: ${val}` : `${ utils.untilde(key) } ${errorMessage}`, location: utils.getLocationOf(`/${root}/${key}`, asyncapiYAMLorJSON, initialFormat) }); }); diff --git a/test/customValidators_test.js b/test/customValidators_test.js index a10f74575..038f07d41 100644 --- a/test/customValidators_test.js +++ b/test/customValidators_test.js @@ -1,4 +1,4 @@ -const {validateChannelParams, validateServerVariables, validateOperationId} = require('../lib/customValidators.js'); +const { validateChannelParams, validateServerVariables, validateOperationId, validateServerSecurity } = require('../lib/customValidators.js'); const chai = require('chai'); const expect = chai.expect; @@ -402,4 +402,257 @@ describe('validateOperationId()', function() { ]); } }); +}); + +describe('validateServerSecurity()', function() { + const specialSecTypes = ['oauth2', 'openIdConnect']; + + it('should successfully validate server security', async function() { + const inputString = `{ + "asyncapi": "2.0.0", + "info": { + "version": "1.0.0" + }, + "servers": { + "dummy": { + "url": "http://localhost", + "protocol": "kafka", + "security": [ + { + "simple": [] + } + ] + } + }, + "components": { + "securitySchemes": { + "simple": { + "type": "httpApiKey", + "name": "Api-Key", + "in": "header" + } + } + } + }`; + const parsedInput = JSON.parse(inputString); + + expect(validateServerSecurity(parsedInput, inputString, input, specialSecTypes)).to.equal(true); + }); + + it('should successfully validate if server security not provided', async function() { + const inputString = `{ + "asyncapi": "2.0.0", + "info": { + "version": "1.0.0" + }, + "servers": { + "dummy": { + "url": "http://localhost", + "protocol": "kafka" + } + } + }`; + const parsedInput = JSON.parse(inputString); + + expect(validateServerSecurity(parsedInput, inputString, input, specialSecTypes)).to.equal(true); + }); + + it('should successfully validate server security of special security type like oauth2', async function() { + const inputString = `{ + "asyncapi": "2.0.0", + "info": { + "version": "1.0.0" + }, + "servers": { + "dummy": { + "url": "http://localhost", + "protocol": "kafka", + "security": [ + { + "oauth2": [ + "write:test", + "read:test" + ] + } + ] + } + }, + "components": { + "securitySchemes": { + "oauth2": { + "type": "oauth2", + "flows": {} + } + } + } + }`; + const parsedInput = JSON.parse(inputString); + + expect(validateServerSecurity(parsedInput, inputString, input, specialSecTypes)).to.equal(true); + }); + + it('should throw error that server has no security schema provided when components schema object is there but missing proper values', async function() { + const inputString = `{ + "asyncapi": "2.0.0", + "info": { + "version": "1.0.0" + }, + "servers": { + "dummy": { + "url": "http://localhost", + "protocol": "kafka", + "security": [ + { + "complex": [] + } + ] + } + }, + "components": { + "securitySchemes": { + "simple": { + "type": "httpApiKey", + "name": "Api-Key", + "in": "header" + } + } + } + }`; + const parsedInput = JSON.parse(inputString); + + try { + validateServerSecurity(parsedInput, inputString, input, specialSecTypes); + } catch (e) { + expect(e.type).to.equal('https://github.com/asyncapi/parser-js/validation-errors'); + expect(e.title).to.equal('Server security name must correspond to a security scheme which is declared in the security schemes under the components object.'); + expect(e.parsedJSON).to.deep.equal(parsedInput); + expect(e.validationErrors).to.deep.equal([ + { + title: 'dummy/security/complex doesn\'t have a corresponding security schema under the components object', + location: { + jsonPointer: '/servers/dummy/security/complex', + startLine: 12, + startColumn: 27, + startOffset: 250, + endLine: 12, + endColumn: 29, + endOffset: 252 + } + } + ]); + } + }); + + it('should throw error that server has no security schema provided when components schema object is not in the document', async function() { + const inputString = `{ + "asyncapi": "2.0.0", + "info": { + "version": "1.0.0" + }, + "servers": { + "dummy": { + "url": "http://localhost", + "protocol": "kafka", + "security": [ + { + "complex": [] + } + ] + } + }, + "components": { + } + }`; + const parsedInput = JSON.parse(inputString); + + try { + validateServerSecurity(parsedInput, inputString, input, specialSecTypes); + } catch (e) { + expect(e.type).to.equal('https://github.com/asyncapi/parser-js/validation-errors'); + expect(e.title).to.equal('Server security name must correspond to a security scheme which is declared in the security schemes under the components object.'); + expect(e.parsedJSON).to.deep.equal(parsedInput); + expect(e.validationErrors).to.deep.equal([ + { + title: 'dummy/security/complex doesn\'t have a corresponding security schema under the components object', + location: { + jsonPointer: '/servers/dummy/security/complex', + startLine: 12, + startColumn: 27, + startOffset: 250, + endLine: 12, + endColumn: 29, + endOffset: 252 + } + } + ]); + } + }); + + it('should throw error that server security is not declared as empty array', async function() { + const inputString = `{ + "asyncapi": "2.0.0", + "info": { + "version": "1.0.0" + }, + "servers": { + "dummy": { + "url": "http://localhost", + "protocol": "kafka", + "security": [ + { + "basic": ["user", "password"] + }, + { + "apikey": [12345678] + } + ] + } + }, + "components": { + "securitySchemes": { + "basic": { + "type": "userPassword" + }, + "apikey": { + "type": "httpApiKey" + } + } + } + }`; + const parsedInput = JSON.parse(inputString); + + try { + validateServerSecurity(parsedInput, inputString, input, specialSecTypes); + } catch (e) { + expect(e.type).to.equal('https://github.com/asyncapi/parser-js/validation-errors'); + expect(e.title).to.equal('Server security value must be an empty array if corresponding security schema type is not oauth2 or openIdConnect.'); + expect(e.parsedJSON).to.deep.equal(parsedInput); + expect(e.validationErrors).to.deep.equal([ + { + title: 'dummy/security/basic security info must have an empty array because its corresponding security schema type is: userPassword', + location: { + jsonPointer: '/servers/dummy/security/basic', + startLine: 12, + startColumn: 25, + startOffset: 248, + endLine: 12, + endColumn: 45, + endOffset: 268 + } + }, + { + title: 'dummy/security/apikey security info must have an empty array because its corresponding security schema type is: httpApiKey', + location: { + jsonPointer: '/servers/dummy/security/apikey', + startLine: 15, + startColumn: 26, + startOffset: 322, + endLine: 15, + endColumn: 36, + endOffset: 332 + } + } + ]); + } + }); }); \ No newline at end of file