UNPKG

@ibm-cloud/openapi-ruleset

Version:

Spectral ruleset for validating IBM Cloud services

670 lines (619 loc) 20.5 kB
/** * Copyright 2017 - 2024 IBM Corporation. * SPDX-License-Identifier: Apache2.0 */ const { stringAttributes } = require('../../src/rules'); const { makeCopy, rootDocument, testRule, severityCodes, } = require('../test-utils'); const rule = stringAttributes; const ruleId = 'ibm-string-attributes'; const expectedSeverity = severityCodes.warning; // To enable debug logging in the rule function, copy this statement to an it() block: // LoggerFactory.getInstance().addLoggerSetting(ruleId, 'debug'); // and uncomment this import statement: // const LoggerFactory = require('../../src/utils/logger-factory'); describe(`Spectral rule: ${ruleId}`, () => { describe('Should not yield errors', () => { it('Clean spec', async () => { const results = await testRule(ruleId, rule, rootDocument); expect(results).toHaveLength(0); }); it('String schema has only an enum', async () => { const testDocument = makeCopy(rootDocument); testDocument.paths['/v1/movies'].post.parameters = [ { name: 'filter', in: 'query', schema: { type: 'string', enum: ['fiction', 'nonfiction'], }, }, { name: 'sort_order', in: 'query', schema: { type: ['string'], enum: ['asc', 'desc'], }, }, ]; const results = await testRule(ruleId, rule, testDocument); expect(results).toHaveLength(0); }); it('String schema is in a not', async () => { const testDocument = makeCopy(rootDocument); testDocument.paths['/v1/movies'].post.parameters = [ { name: 'filter', in: 'query', schema: { type: 'integer', not: { type: 'string', }, }, }, ]; const results = await testRule(ruleId, rule, testDocument); expect(results).toHaveLength(0); }); it('Missing pattern when format is binary, byte, date, date-time, or url', async () => { const testDocument = makeCopy(rootDocument); testDocument.paths['/v1/movies'].post.parameters = [ { name: 'hash', in: 'query', schema: { type: 'string', format: 'binary', minLength: 0, maxLength: 15, }, }, { name: 'trailer', in: 'query', schema: { type: ['string'], format: 'byte', minLength: 0, maxLength: 1024, }, }, { name: 'before_date', in: 'query', schema: { type: 'string', format: 'date', }, }, { name: 'after_date', in: 'query', schema: { type: 'string', format: 'date-time', minLength: 1, maxLength: 15, }, }, { name: 'imdb_url', in: 'query', schema: { type: 'string', format: 'url', maxLength: 1024, }, }, ]; const results = await testRule(ruleId, rule, testDocument); expect(results).toHaveLength(0); }); it('String schema specifies pattern in oneOf schemas', async () => { const testDocument = makeCopy(rootDocument); testDocument.paths['/v1/movies'].post.parameters = [ { name: 'ruleTester', in: 'query', schema: { $ref: '#/components/schemas/RuleTester', }, }, ]; testDocument.components.schemas['RuleTester'] = { description: 'Tests pattern field within oneOf', type: 'string', minLength: 1, maxLength: 38, oneOf: [ { pattern: 'pattern1', }, { pattern: 'pattern2', }, { pattern: 'pattern3', }, ], example: 'example string', }; const results = await testRule(ruleId, rule, testDocument); expect(results).toHaveLength(0); }); it('String schema specifies fields in allOf schemas', async () => { const testDocument = makeCopy(rootDocument); testDocument.paths['/v1/movies'].post.parameters = [ { name: 'ruleTester1', in: 'query', schema: { $ref: '#/components/schemas/RuleTester1', }, }, { name: 'ruleTester2', in: 'query', schema: { $ref: '#/components/schemas/RuleTester2', }, }, ]; testDocument.components.schemas['RuleTester1'] = { description: 'Tests string fields within allOf', type: 'string', minLength: 1, maxLength: 38, allOf: [ { minLength: 1, }, { maxLength: 38, }, { pattern: 'id:.*', }, ], example: 'example string', }; testDocument.components.schemas['RuleTester2'] = { description: 'Tests string fields within allOf', type: ['string'], minLength: 1, maxLength: 38, allOf: [ { minLength: 1, }, { maxLength: 38, }, { pattern: 'id:.*', }, ], example: 'example string', }; const results = await testRule(ruleId, rule, testDocument); expect(results).toHaveLength(0); }); }); describe('Should yield errors', () => { it('String schema is missing a `pattern` field', async () => { const testDocument = makeCopy(rootDocument); testDocument.paths['/v1/movies'].post.parameters = [ { name: 'filter', in: 'query', schema: { type: 'string', minLength: 1, maxLength: 15, }, }, ]; const results = await testRule(ruleId, rule, testDocument); expect(results).toHaveLength(1); const validation = results[0]; expect(validation.code).toBe(ruleId); expect(validation.message).toBe( `String schemas should define property 'pattern'` ); expect(validation.path.join('.')).toBe( 'paths./v1/movies.post.parameters.0.schema' ); expect(validation.severity).toBe(expectedSeverity); }); it('String schema is missing a `minLength` field', async () => { const testDocument = makeCopy(rootDocument); testDocument.paths['/v1/movies'].post.parameters = [ { name: 'metadata', in: 'header', content: { 'text/plain': { schema: { type: ['string'], pattern: '[a-zA-Z0-9]+', maxLength: 15, }, }, }, }, ]; const results = await testRule(ruleId, rule, testDocument); expect(results).toHaveLength(1); const validation = results[0]; expect(validation.code).toBe(ruleId); expect(validation.message).toBe( `String schemas should define property 'minLength'` ); expect(validation.path.join('.')).toBe( 'paths./v1/movies.post.parameters.0.content.text/plain.schema' ); expect(validation.severity).toBe(expectedSeverity); }); it('String schema is missing a `maxLength` field', async () => { const testDocument = makeCopy(rootDocument); testDocument.paths['/v1/movies'].post.requestBody.content['text/plain'] = { schema: { type: 'string', pattern: '[a-zA-Z0-9]+', minLength: 15, }, }; const results = await testRule(ruleId, rule, testDocument); expect(results).toHaveLength(1); const validation = results[0]; expect(validation.code).toBe(ruleId); expect(validation.message).toBe( `String schemas should define property 'maxLength'` ); expect(validation.path.join('.')).toBe( 'paths./v1/movies.post.requestBody.content.text/plain.schema' ); expect(validation.severity).toBe(expectedSeverity); }); it('Non-string schema defines a `pattern` field', async () => { const testDocument = makeCopy(rootDocument); testDocument.paths['/v1/movies'].post.requestBody.content['text/plain'] = { schema: { type: 'integer', pattern: '.*', }, }; const results = await testRule(ruleId, rule, testDocument); expect(results).toHaveLength(1); const validation = results[0]; expect(validation.code).toBe(ruleId); expect(validation.message).toBe( `'pattern' should not be defined for non-string schemas` ); expect(validation.path.join('.')).toBe( 'paths./v1/movies.post.requestBody.content.text/plain.schema.pattern' ); expect(validation.severity).toBe(expectedSeverity); }); it('Non-string schema within patternProperties defines a `pattern` field', async () => { const testDocument = makeCopy(rootDocument); testDocument.paths['/v1/movies'].post.requestBody.content[ 'application/json' ] = { schema: { type: 'object', patternProperties: { '^foo.*$': { type: 'integer', pattern: '^fooValue.*$', }, }, }, }; const results = await testRule(ruleId, rule, testDocument); expect(results).toHaveLength(1); const validation = results[0]; expect(validation.code).toBe(ruleId); expect(validation.message).toBe( `'pattern' should not be defined for non-string schemas` ); expect(validation.path.join('.')).toBe( 'paths./v1/movies.post.requestBody.content.application/json.schema.patternProperties.^foo.*$.pattern' ); expect(validation.severity).toBe(expectedSeverity); }); it('Non-string schema defines a `minLength` field', async () => { const testDocument = makeCopy(rootDocument); testDocument.paths['/v1/movies'].post.responses['201'].content[ 'text/plain' ] = { schema: { type: ['integer', 'null', 'boolean'], minLength: 15, }, }; const results = await testRule(ruleId, rule, testDocument); expect(results).toHaveLength(1); const validation = results[0]; expect(validation.code).toBe(ruleId); expect(validation.message).toBe( `'minLength' should not be defined for non-string schemas` ); expect(validation.path.join('.')).toBe( 'paths./v1/movies.post.responses.201.content.text/plain.schema.minLength' ); expect(validation.severity).toBe(expectedSeverity); }); it('Non-string schema defines a `maxLength` field', async () => { const testDocument = makeCopy(rootDocument); testDocument.paths['/v1/movies'].post.requestBody.content['text/plain'] = { schema: { type: 'integer', maxLength: 15, }, }; const results = await testRule(ruleId, rule, testDocument); expect(results).toHaveLength(1); const validation = results[0]; expect(validation.code).toBe(ruleId); expect(validation.message).toBe( `'maxLength' should not be defined for non-string schemas` ); expect(validation.path.join('.')).toBe( 'paths./v1/movies.post.requestBody.content.text/plain.schema.maxLength' ); expect(validation.severity).toBe(expectedSeverity); }); it('String schema has a `minLength` value greater than the `maxLength` value', async () => { const testDocument = makeCopy(rootDocument); testDocument.paths['/v1/movies'].post.requestBody.content['text/plain'] = { schema: { type: 'string', pattern: '[a-zA-Z0-9]+', maxLength: 10, minLength: 15, }, }; const results = await testRule(ruleId, rule, testDocument); expect(results).toHaveLength(1); const validation = results[0]; expect(validation.code).toBe(ruleId); expect(validation.message).toBe( `'minLength' cannot be greater than 'maxLength'` ); expect(validation.path.join('.')).toBe( 'paths./v1/movies.post.requestBody.content.text/plain.schema' ); expect(validation.severity).toBe(expectedSeverity); }); it('Invalid string schema is part of a composed schema', async () => { const testDocument = makeCopy(rootDocument); testDocument.paths['/v1/movies'].post.requestBody.content['text/plain'] = { schema: { allOf: [ { type: 'string', maxLength: 10, minLength: 1, }, { anyOf: [ { type: ['string'], maxLength: 10, minLength: 1, }, { oneOf: [ { type: ['string'], maxLength: 10, minLength: 1, }, ], }, ], }, ], }, }; const results = await testRule(ruleId, rule, testDocument); expect(results).toHaveLength(1); expect(results[0].code).toBe(ruleId); expect(results[0].message).toBe( `String schemas should define property 'pattern'` ); expect(results[0].severity).toBe(expectedSeverity); expect(results[0].path.join('.')).toBe( 'paths./v1/movies.post.requestBody.content.text/plain.schema' ); }); it('Invalid string schema is defined at path level', async () => { const testDocument = makeCopy(rootDocument); testDocument.paths['/v1/movies'].parameters = [ { name: 'filter', in: 'query', schema: { type: ['string'], minLength: 1, maxLength: 15, }, }, ]; const results = await testRule(ruleId, rule, testDocument); expect(results).toHaveLength(1); const validation = results[0]; expect(validation.code).toBe(ruleId); expect(validation.message).toBe( `String schemas should define property 'pattern'` ); expect(validation.path.join('.')).toBe( 'paths./v1/movies.parameters.0.schema' ); expect(validation.severity).toBe(expectedSeverity); }); it('String schema specifies maxLength in only SOME oneOf schemas', async () => { const testDocument = makeCopy(rootDocument); testDocument.paths['/v1/movies'].post.parameters = [ { name: 'ruleTester', in: 'query', schema: { $ref: '#/components/schemas/RuleTester', }, }, ]; testDocument.components.schemas['RuleTester'] = { description: 'Tests maxLength field within oneOf', type: 'string', minLength: 1, pattern: '.*', oneOf: [ { maxLength: 38, }, { maxLength: 74, }, { description: 'No maxLength field in this schema', }, ], example: 'example string', }; const results = await testRule(ruleId, rule, testDocument); expect(results).toHaveLength(1); const validation = results[0]; expect(validation.code).toBe(ruleId); expect(validation.message).toBe( `String schemas should define property 'maxLength'` ); expect(validation.path.join('.')).toBe( 'paths./v1/movies.post.parameters.0.schema' ); expect(validation.severity).toBe(expectedSeverity); }); it('String schema specifies pattern in only SOME anyOf schemas', async () => { const testDocument = makeCopy(rootDocument); testDocument.paths['/v1/movies'].post.parameters = [ { name: 'ruleTester', in: 'query', schema: { $ref: '#/components/schemas/RuleTester', }, }, ]; testDocument.components.schemas['RuleTester'] = { description: 'Tests pattern field within anyOf', type: ['string'], minLength: 1, maxLength: 38, oneOf: [ { pattern: '.*', }, { pattern: 'id-[0-9]+.*', }, { description: 'No pattern field in this schema', }, ], example: 'example string', }; const results = await testRule(ruleId, rule, testDocument); expect(results).toHaveLength(1); const validation = results[0]; expect(validation.code).toBe(ruleId); expect(validation.message).toBe( `String schemas should define property 'pattern'` ); expect(validation.path.join('.')).toBe( 'paths./v1/movies.post.parameters.0.schema' ); expect(validation.severity).toBe(expectedSeverity); }); it('Response body string schema has no keywords', async () => { const testDocument = makeCopy(rootDocument); testDocument.paths['/v1/movies/{movie_id}'].get.responses['200'] = { content: { 'application/json': { schema: { properties: { name: { type: 'string', description: 'no validation', }, }, }, }, }, }; const results = await testRule(ruleId, rule, testDocument); expect(results).toHaveLength(3); const expectedPath = 'paths./v1/movies/{movie_id}.get.responses.200.content.application/json.schema.properties.name'; const expectedMessages = [ `String schemas should define property 'pattern'`, `String schemas should define property 'minLength'`, `String schemas should define property 'maxLength'`, ]; for (let i = 0; i < results.length; i++) { expect(results[i].code).toBe(ruleId); expect(results[i].message).toBe(expectedMessages[i]); expect(results[i].severity).toBe(expectedSeverity); expect(results[i].path.join('.')).toBe(expectedPath); } }); it('Response header string schema has no keywords', async () => { const testDocument = makeCopy(rootDocument); testDocument.components.responses['MovieWithETag'].headers[ 'X-IBM-Something' ] = { description: 'A non-compliant response header.', schema: { type: 'string', description: 'no validation', }, }; const results = await testRule(ruleId, rule, testDocument); expect(results).toHaveLength(6); const expectedPaths = [ 'paths./v1/movies/{movie_id}.get.responses.200.headers.X-IBM-Something.schema', 'paths./v1/movies/{movie_id}.get.responses.200.headers.X-IBM-Something.schema', 'paths./v1/movies/{movie_id}.get.responses.200.headers.X-IBM-Something.schema', 'paths./v1/movies/{movie_id}.put.responses.200.headers.X-IBM-Something.schema', 'paths./v1/movies/{movie_id}.put.responses.200.headers.X-IBM-Something.schema', 'paths./v1/movies/{movie_id}.put.responses.200.headers.X-IBM-Something.schema', ]; const expectedMessages = [ `String schemas should define property 'pattern'`, `String schemas should define property 'minLength'`, `String schemas should define property 'maxLength'`, `String schemas should define property 'pattern'`, `String schemas should define property 'minLength'`, `String schemas should define property 'maxLength'`, ]; for (let i = 0; i < results.length; i++) { expect(results[i].code).toBe(ruleId); expect(results[i].message).toBe(expectedMessages[i]); expect(results[i].severity).toBe(expectedSeverity); expect(results[i].path.join('.')).toBe(expectedPaths[i]); } }); }); });