From e2cc37e4749f0a5c84f84c54174b97ad1cf5cf34 Mon Sep 17 00:00:00 2001 From: Patrick Ruhkopf Date: Fri, 10 May 2024 21:34:22 -0400 Subject: [PATCH 1/5] fix: check content type --- .../ajv-openapi-request-response-validator.ts | 3 + .../test/fixtures/example-api.yaml | 110 +++++++++++++++++- .../unit/ajv-validator-example-api.spec.ts | 20 ++++ 3 files changed, 132 insertions(+), 1 deletion(-) diff --git a/packages/ajv-openapi-request-response-validator/src/ajv-openapi-request-response-validator.ts b/packages/ajv-openapi-request-response-validator/src/ajv-openapi-request-response-validator.ts index fdeed5f..89cc661 100644 --- a/packages/ajv-openapi-request-response-validator/src/ajv-openapi-request-response-validator.ts +++ b/packages/ajv-openapi-request-response-validator/src/ajv-openapi-request-response-validator.ts @@ -320,6 +320,9 @@ export class AjvOpenApiValidator { if (this.validatorOpts.setAdditionalPropertiesToFalse) { if (!isValidReferenceObject(schema) && schema.additionalProperties === undefined && schema.discriminator === undefined) { schema.additionalProperties = false + if (schema.type === undefined) { + schema.type = 'object' // avoid missing type "object" for keyword "additionalProperties" error + } } } diff --git a/packages/ajv-openapi-request-response-validator/test/fixtures/example-api.yaml b/packages/ajv-openapi-request-response-validator/test/fixtures/example-api.yaml index b00de88..63f5e1e 100644 --- a/packages/ajv-openapi-request-response-validator/test/fixtures/example-api.yaml +++ b/packages/ajv-openapi-request-response-validator/test/fixtures/example-api.yaml @@ -113,6 +113,44 @@ paths: application/json: schema: $ref: '#/components/schemas/OffsetPagination' + + '/multipart': + post: + requestBody: + content: + image/gif: + schema: + type: string + format: binary + multipart/form-data: + schema: + type: object + additionalProperties: true + /users/{uid}/state/{sid}: + put: + operationId: put-user-state + summary: Upserts a user state + parameters: + - name: uid + in: path + required: true + schema: + type: string + - name: sid + in: path + description: The state id or key + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/UserStateValue' + required: true + responses: + '200': + description: OK components: responses: ResponseError: @@ -320,4 +358,74 @@ components: status: type: string enum: [pending, approved, rejected] - \ No newline at end of file + UserStateValue: + type: "object" + oneOf: + - $ref: '#/components/schemas/UserStateCoffeeCx' + - $ref: '#/components/schemas/UserUploads' + discriminator: + propertyName: type + UserStateCoffeeCx: + type: object + required: + - type + - enabled + properties: + type: + type: string + enum: [coffeeCx] + enabled: + type: boolean + description: Whether a certain flag, feature, etc. is enabled or not + UserUploads: + type: object + required: + - type + - entries + properties: + type: + type: string + enum: [userUploads] + entries: + type: array + maxItems: 1000 + items: + $ref: '#/components/schemas/UserUploadEntry' + UserUploadEntry: + type: object + required: + - id + - status + - name + - communityId + properties: + id: + type: string + uploadId: + type: string + status: + type: string + enum: + - ongoing + - completed + - error + name: + type: string + communityId: + type: string + path: + description: >- + path to which the file will be uploaded (e.g. General (default), BCP + Article Files...) + $ref: '#/components/schemas/SafePath' + blobUrl: + type: string + url: + type: string + SafePath: + type: string + pattern: (?=(^(?!.*\.\.\/).+))(?=(^(?!.*\/\/).+)) + description: >- + A valid path that matches any sequence of characters except if it + contains '../', '//' (to avoid directory traversal attacks) + maxLength: 255 \ No newline at end of file diff --git a/packages/ajv-openapi-request-response-validator/test/unit/ajv-validator-example-api.spec.ts b/packages/ajv-openapi-request-response-validator/test/unit/ajv-validator-example-api.spec.ts index 82cc370..12443fa 100644 --- a/packages/ajv-openapi-request-response-validator/test/unit/ajv-validator-example-api.spec.ts +++ b/packages/ajv-openapi-request-response-validator/test/unit/ajv-validator-example-api.spec.ts @@ -331,4 +331,24 @@ describe('The api validator for the user api spec', () => { }, }) }) + + it('demonstrate multipart request issue. we should properly implement this at some point', () => { + expect( + validator.validateRequestBody( + '/multipart', + 'post', + `------WebKitFormBoundaryMFTG70c7i7lAFI6f + Content-Disposition: form-data; name="file"; filename="blob" + Content-Type: image/png + + + ------WebKitFormBoundaryMFTG70c7i7lAFI6f-- + ` + ) + ).toEqual([{ code: 'Validation-unexpected-request-body', status: 400, title: 'A request body is not supported' }]) + }) + + fit('should validate user state 1', () => { + expect(validator.validateRequestBody('/users/{uid}/state/{sid}', 'put', { enabled: true })).toBeUndefined() + }) }) From 232e72a73c660907100ead006961987e979f31e4 Mon Sep 17 00:00:00 2001 From: Patrick Ruhkopf Date: Fri, 10 May 2024 21:38:40 -0400 Subject: [PATCH 2/5] feat: more config options --- .../src/helper.ts | 2 - .../src/validation-hook-setup.ts | 288 +++++++++++++----- .../test/.eslintrc.js | 3 +- .../test/unit/validation-hook-setup.spec.ts | 224 +++++++++++++- 4 files changed, 442 insertions(+), 75 deletions(-) diff --git a/packages/azure-functions-openapi-validator/src/helper.ts b/packages/azure-functions-openapi-validator/src/helper.ts index 5ecb19c..0b44226 100644 --- a/packages/azure-functions-openapi-validator/src/helper.ts +++ b/packages/azure-functions-openapi-validator/src/helper.ts @@ -2,8 +2,6 @@ import { HttpResponseInit, InvocationContext } from '@azure/functions' import { HeadersInit } from 'undici' export const STATUS_CODE_OK = 200 -export const STATUS_CODE_BAD_REQUEST = 400 -export const STATUS_CODE_INTERNAL_SERVER_ERROR = 500 export const createJsonResponse = (body: unknown, status = STATUS_CODE_OK, headers?: HeadersInit): HttpResponseInit => { const finalHeaders: HeadersInit = headers ?? {} diff --git a/packages/azure-functions-openapi-validator/src/validation-hook-setup.ts b/packages/azure-functions-openapi-validator/src/validation-hook-setup.ts index 24c94a0..1c5e513 100644 --- a/packages/azure-functions-openapi-validator/src/validation-hook-setup.ts +++ b/packages/azure-functions-openapi-validator/src/validation-hook-setup.ts @@ -10,8 +10,8 @@ import { PreInvocationContext, PreInvocationHandler, } from '@azure/functions' -import { AjvOpenApiValidator } from '@restfulhead/ajv-openapi-request-response-validator' -import { STATUS_CODE_BAD_REQUEST, STATUS_CODE_INTERNAL_SERVER_ERROR, STATUS_CODE_OK, createJsonResponse, logMessage } from './helper' +import { AjvOpenApiValidator, EC_VALIDATION, HttpStatus } from '@restfulhead/ajv-openapi-request-response-validator' +import { LogLevel, STATUS_CODE_OK, createJsonResponse, logMessage } from './helper' export const HOOK_DATA_QUERY_PARAM_VALIDATION_ERROR_KEY = '@restfulhead/azure-functions-openapi-validator/query-param-validation-error' export const HOOK_DATA_REQUEST_BODY_VALIDATION_ERROR_KEY = '@restfulhead/azure-functions-openapi-validator/request-body-validation-error' @@ -26,10 +26,16 @@ export interface ValidationMode { strict: boolean } +export interface ExclusionValidationOpts { + queryParameter: boolean | ValidationMode + requestBody: boolean | ValidationMode + responseBody: boolean | ValidationMode +} + export interface ExcludeByPathAndMethod { path: string method: string - validation: false | { queryParmeter: boolean; requestBody: boolean; responseBody: boolean } + validation: false | ExclusionValidationOpts } export interface ValidatorHookOptions { @@ -79,8 +85,68 @@ export const DEFAULT_HOOK_OPTIONS: ValidatorHookOptions = { }, } -function isHttpResponseWithBody(response: HttpResponseInit | HttpResponse): response is HttpResponse { - return response && (response as HttpResponse).body !== undefined && (response as HttpResponse).body !== null +const GENERIC_RESPONSE_VALIDATION_ERR_MSG = 'Response body validation failed' + +function hasRequestBody(request: HttpRequest): boolean { + return request && request.body !== undefined && request.body !== null +} + +function hasResponseBody(response: HttpResponse): boolean { + return response && response.body !== undefined && response.body !== null +} + +function getExclusion( + context: InvocationContext, + path: string, + method: string, + exclude?: ExcludeByPathAndMethod[] +): ExcludeByPathAndMethod | undefined { + const result = exclude?.find((ex) => { + const exPath = ex.path.startsWith('/') ? ex.path : `/${ex.path}` + return exPath.toLowerCase() === path.toLowerCase() && ex.method.toLowerCase() === method.toLowerCase() + }) + if (result) { + context.debug('Found exclusion configuration', { method, path, validation: result.validation }) + } + return result +} + +function getStrictValidationMode(exclusion: undefined | boolean | ValidationMode, defaultValidation: ValidationMode): boolean { + if (exclusion === false) { + return false + } else if (exclusion === true) { + return true + } + if (typeof exclusion === 'object') { + return exclusion.strict + } + return defaultValidation.strict +} + +function getLogLevelValidationMode(exclusion: undefined | boolean | ValidationMode, defaultValidation: ValidationMode): LogLevel { + if (exclusion === false) { + return 'off' + } + if (exclusion === true) { + return 'warn' + } + + if (typeof exclusion === 'object') { + return exclusion.logLevel + } + return defaultValidation.logLevel +} + +function getReturnErrorResponseValidationMode(exclusion: undefined | boolean | ValidationMode, defaultValidation: ValidationMode): boolean { + if (exclusion === false) { + return false + } else if (exclusion === true) { + return true + } + if (typeof exclusion === 'object') { + return exclusion.returnErrorResponse + } + return defaultValidation.returnErrorResponse } export function configureValidationPreInvocationHandler( @@ -95,17 +161,15 @@ export function configureValidationPreInvocationHandler( preContext.functionHandler = async (origRequest: HttpRequest, context: InvocationContext): Promise => { let request = origRequest const method = request.method - const exclusion = opts.exclude?.find( - (ex) => ex.path.toLowerCase() === path.toLowerCase() && ex.method.toLowerCase() === method.toLowerCase() - ) + const exclusion = getExclusion(context, path, method, opts.exclude) - if (opts.queryParameterValidationMode && (!exclusion || (exclusion.validation !== false && exclusion.validation.queryParmeter))) { + if (opts.queryParameterValidationMode && (!exclusion || (exclusion.validation !== false && exclusion.validation.queryParameter))) { context.debug(`Validating query parameters '${path}', '${method}'`) const reqParamsValResult = validator.validateQueryParams( path, method, request.query, - opts.queryParameterValidationMode.strict, + getStrictValidationMode((exclusion?.validation as ExclusionValidationOpts)?.queryParameter, opts.queryParameterValidationMode), opts.queryParameterValidationMode.strictExclusions, context ) @@ -113,54 +177,97 @@ export function configureValidationPreInvocationHandler( preContext.hookData[HOOK_DATA_NORMALIZED_QUERY_PARAMS_KEY] = reqParamsValResult.normalizedParams if (reqParamsValResult?.errors) { - if (opts.queryParameterValidationMode.logLevel !== 'off') { - logMessage( - `Query param validation error: ${JSON.stringify(reqParamsValResult.errors)}`, - opts.queryParameterValidationMode.logLevel, - context - ) + const logLevel = getLogLevelValidationMode( + (exclusion?.validation as ExclusionValidationOpts)?.queryParameter, + opts.queryParameterValidationMode + ) + if (logLevel !== 'off') { + logMessage(`Query param validation error: ${JSON.stringify(reqParamsValResult.errors)}`, logLevel, context) } - if (opts.queryParameterValidationMode.returnErrorResponse) { + if ( + getReturnErrorResponseValidationMode( + (exclusion?.validation as ExclusionValidationOpts)?.queryParameter, + opts.queryParameterValidationMode + ) + ) { preContext.hookData[HOOK_DATA_QUERY_PARAM_VALIDATION_ERROR_KEY] = true - return Promise.resolve(createJsonResponse({ errors: reqParamsValResult.errors }, STATUS_CODE_BAD_REQUEST)) + return Promise.resolve(createJsonResponse({ errors: reqParamsValResult.errors }, HttpStatus.BAD_REQUEST)) } } } - if ( - opts.requestBodyValidationMode && - request.headers.get('content-type')?.includes('application/json') && - (!exclusion || (exclusion.validation !== false && exclusion.validation.requestBody)) - ) { + if (opts.requestBodyValidationMode && (!exclusion || (exclusion.validation !== false && exclusion.validation.requestBody))) { context.debug(`Validating request body for '${path}', '${method}'`) - let parsedBody - if (request.body) { - // a copy is necessary, because the request body can only be consumed once - // see https://github.com/Azure/azure-functions-nodejs-library/issues/79#issuecomment-1875214147 - request = origRequest.clone() + const logLevel = getLogLevelValidationMode( + (exclusion?.validation as ExclusionValidationOpts)?.requestBody, + opts.requestBodyValidationMode + ) - parsedBody = await origRequest.json() - } + const contentType = request.headers.get('Content-Type') + if (hasRequestBody(request) && (!contentType || !contentType.includes('application/json'))) { + const msg = contentType + ? `Request body of type '${contentType}' is not supported and won't be validated. You should exclude this route from validation to avoid this warning. (Request: ${method} ${path})` + : `Request header 'Content-Type' is missing. Request body won't be validated. Send 'application/json' in request header or exclude this route from validation to avoid this error. (Request: ${method} ${path})` - const reqBodyValResult = validator.validateRequestBody(path, method, parsedBody, opts.requestBodyValidationMode.strict, context) - if (reqBodyValResult) { - if (opts.requestBodyValidationMode.logLevel !== 'off') { - logMessage( - `Request body validation error: ${JSON.stringify(reqBodyValResult)}`, - opts.requestBodyValidationMode.logLevel, - context - ) + if (logLevel !== 'off') { + logMessage(msg, logLevel, context) } if (opts.requestBodyValidationMode.returnErrorResponse) { preContext.hookData[HOOK_DATA_REQUEST_BODY_VALIDATION_ERROR_KEY] = true - return Promise.resolve(createJsonResponse({ errors: reqBodyValResult }, STATUS_CODE_BAD_REQUEST)) + return Promise.resolve( + createJsonResponse( + { + errors: [ + { + status: HttpStatus.BAD_REQUEST, + code: contentType ? `${EC_VALIDATION}-invalid-content-type-header` : `${EC_VALIDATION}-missing-content-type-header`, + title: contentType + ? `The content type '${contentType}' is not supported.` + : "The request header 'Content-Type' is missing", + }, + ], + }, + HttpStatus.BAD_REQUEST + ) + ) + } + } else { + let parsedBody + if (hasRequestBody(request)) { + // a copy is necessary, because the request body can only be consumed once + // see https://github.com/Azure/azure-functions-nodejs-library/issues/79#issuecomment-1875214147 + request = origRequest.clone() + + parsedBody = await origRequest.json() + } + + const reqBodyValResult = validator.validateRequestBody( + path, + method, + parsedBody, + getStrictValidationMode((exclusion?.validation as ExclusionValidationOpts)?.requestBody, opts.requestBodyValidationMode), + context + ) + if (reqBodyValResult) { + if (logLevel !== 'off') { + logMessage(`Request body validation error: ${JSON.stringify(reqBodyValResult)}`, logLevel, context) + } + + if ( + getReturnErrorResponseValidationMode( + (exclusion?.validation as ExclusionValidationOpts)?.requestBody, + opts.requestBodyValidationMode + ) + ) { + preContext.hookData[HOOK_DATA_REQUEST_BODY_VALIDATION_ERROR_KEY] = true + return Promise.resolve(createJsonResponse({ errors: reqBodyValResult }, HttpStatus.BAD_REQUEST)) + } } } } - return await originalHandler(request, context) } } @@ -191,47 +298,88 @@ export function configureValidationPostInvocationHandler( const path = '/' + context.options.trigger.route const request = postContext.inputs[0] as HttpRequest const method = request.method - const exclusion = opts.exclude?.find( - (ex) => ex.path.toLowerCase() === path.toLowerCase() && ex.method.toLowerCase() === method.toLowerCase() - ) - + const exclusion = getExclusion(context, path, method, opts.exclude) const origResponse = new HttpResponse(postContext.result as HttpResponseInit) if (opts.responseBodyValidationMode && (!exclusion || (exclusion.validation !== false && exclusion.validation.responseBody))) { context.debug(`Validating response body for '${path}', '${method}', '${origResponse.status}'`) - let responseBody - if (isHttpResponseWithBody(origResponse)) { - // a copy is necessary, because the response body can only be consumed once - const clonedResponse = origResponse.clone() + const logLevel = getLogLevelValidationMode( + (exclusion?.validation as ExclusionValidationOpts)?.responseBody, + opts.responseBodyValidationMode + ) + + const contentType = origResponse.headers.get('Content-Type') + if (hasResponseBody(origResponse) && (!contentType || !contentType.includes('application/json'))) { + const msg = contentType + ? `Response body of type '${contentType}' is not supported and won't be validated. You should exclude this route from validation to avoid this warning. (Request: ${method} ${path})` + : `Response header 'Content-Type' is missing. Response body won't be validated. Send 'application/json' in the response header or exclude this route from validation to avoid this error. (Request: ${method} ${path})` - try { - responseBody = await clonedResponse.json() - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } catch (err: any) { - throw new Error(`Error parsing response body of ${method} ${path}: ${err.message}`) + if (logLevel !== 'off') { + logMessage(msg, logLevel, context) } - } - const respBodyValResult = validator.validateResponseBody( - path, - method, - origResponse.status ?? STATUS_CODE_OK, - responseBody, - opts.responseBodyValidationMode.strict, - context - ) - if (respBodyValResult) { - if (opts.responseBodyValidationMode.logLevel !== 'off') { - logMessage( - `Response body validation error: ${JSON.stringify(respBodyValResult)}`, - opts.responseBodyValidationMode.logLevel, - context + if (opts.responseBodyValidationMode.returnErrorResponse) { + postContext.result = createJsonResponse( + { + errors: [ + { + status: HttpStatus.INTERNAL_SERVER_ERROR, + code: contentType ? `${EC_VALIDATION}-invalid-content-type-header` : `${EC_VALIDATION}-missing-content-type-header`, + title: GENERIC_RESPONSE_VALIDATION_ERR_MSG, + }, + ], + }, + HttpStatus.INTERNAL_SERVER_ERROR ) } + } else { + let responseBody + if (hasResponseBody(origResponse)) { + // a copy is necessary, because the response body can only be consumed once + const clonedResponse = origResponse.clone() + + try { + responseBody = await clonedResponse.json() + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (err: any) { + throw new Error(`Error parsing response body of ${method} ${path}: ${err.message}`) + } + } - if (opts.responseBodyValidationMode.returnErrorResponse) { - postContext.result = createJsonResponse({ errors: respBodyValResult }, STATUS_CODE_INTERNAL_SERVER_ERROR) + const respBodyValResult = validator.validateResponseBody( + path, + method, + origResponse.status ?? STATUS_CODE_OK, + responseBody, + getStrictValidationMode((exclusion?.validation as ExclusionValidationOpts)?.responseBody, opts.responseBodyValidationMode), + context + ) + + if (respBodyValResult) { + if (logLevel !== 'off') { + logMessage(`Response body validation error: ${JSON.stringify(respBodyValResult)}`, logLevel, context) + } + + if ( + getReturnErrorResponseValidationMode( + (exclusion?.validation as ExclusionValidationOpts)?.responseBody, + opts.responseBodyValidationMode + ) + ) { + postContext.result = createJsonResponse( + { + errors: [ + { + status: HttpStatus.INTERNAL_SERVER_ERROR, + code: EC_VALIDATION, + title: GENERIC_RESPONSE_VALIDATION_ERR_MSG, + }, + ], + }, + HttpStatus.INTERNAL_SERVER_ERROR + ) + } } } } diff --git a/packages/azure-functions-openapi-validator/test/.eslintrc.js b/packages/azure-functions-openapi-validator/test/.eslintrc.js index f1c9399..86adc13 100644 --- a/packages/azure-functions-openapi-validator/test/.eslintrc.js +++ b/packages/azure-functions-openapi-validator/test/.eslintrc.js @@ -8,6 +8,7 @@ module.exports = { extends: ["../.eslintrc.js"], rules: { "@typescript-eslint/no-magic-numbers": "off", - "no-console": "off" + "@typescript-eslint/no-explicit-any": "off", + "no-console": "off", } } \ No newline at end of file diff --git a/packages/azure-functions-openapi-validator/test/unit/validation-hook-setup.spec.ts b/packages/azure-functions-openapi-validator/test/unit/validation-hook-setup.spec.ts index c43b202..d010b17 100644 --- a/packages/azure-functions-openapi-validator/test/unit/validation-hook-setup.spec.ts +++ b/packages/azure-functions-openapi-validator/test/unit/validation-hook-setup.spec.ts @@ -1,4 +1,4 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ +import { ReadableStream } from 'stream/web' import { MockProxy, mock, mockReset } from 'jest-mock-extended' import { AjvOpenApiValidator } from '@restfulhead/ajv-openapi-request-response-validator' import { HttpRequest, HttpResponseInit, PostInvocationContext, PreInvocationContext } from '@azure/functions' @@ -6,6 +6,7 @@ import { ValidatorHookOptions, configureValidationPostInvocationHandler, configureValidationPreInvocationHandler, + DEFAULT_HOOK_OPTIONS, } from '../../src/validation-hook-setup' describe('The app validator', () => { @@ -191,7 +192,7 @@ describe('The app validator', () => { ['code'], expect.anything() ) - expect(mockValidator.validateRequestBody).toHaveBeenCalledTimes(0) + expect(mockValidator.validateRequestBody).toHaveBeenCalledWith('/api/v1/health', 'GET', undefined, true, expect.anything()) }) it('should fail with query parameter validation error', async () => { @@ -215,6 +216,65 @@ describe('The app validator', () => { expect(mockValidator.validateRequestBody).toHaveBeenCalledTimes(0) }) + it('should fail with request body validation error', async () => { + mockValidator.validateQueryParams.mockReturnValueOnce({ errors: undefined, normalizedParams: {} }) + mockValidator.validateRequestBody.mockReturnValueOnce([MOCK_ERROR]) + const handler = configureValidationPreInvocationHandler(mockValidator) + const ctx = getMockPreContext('api/v1/health?something', JSON.stringify({ status: 'ok' })) + await handler(ctx) + + const request: HttpRequest = { ...DEFAULT_HTTP_GET_REQUEST, query: new URLSearchParams('something') } + const functionResult = await ctx.functionHandler(request, MOCK_PRE_CONTEXT.invocationContext) + + expect(functionResult).toEqual({ status: 400, body: JSON.stringify({ errors: [MOCK_ERROR] }), headers: JSON_HEADERS }) + expect(mockValidator.validateRequestBody).toHaveBeenCalledWith('/api/v1/health?something', 'GET', undefined, true, expect.anything()) + }) + + it('should exlcude request body validation error', async () => { + mockValidator.validateQueryParams.mockReturnValueOnce({ errors: undefined, normalizedParams: {} }) + mockValidator.validateRequestBody.mockReturnValueOnce([MOCK_ERROR]) + const handler = configureValidationPreInvocationHandler(mockValidator, { + ...DEFAULT_HOOK_OPTIONS, + exclude: [ + { + path: '/api/v1/health', + method: 'GET', + validation: false, + }, + ], + }) + const ctx = getMockPreContext('api/v1/health', JSON.stringify({ status: 'ok' })) + await handler(ctx) + + const request: HttpRequest = { ...DEFAULT_HTTP_GET_REQUEST, query: new URLSearchParams('something') } + const functionResult = await ctx.functionHandler(request, MOCK_PRE_CONTEXT.invocationContext) + + expect(functionResult).toEqual({ status: 200, body: '{"status":"ok"}' }) + expect(mockValidator.validateRequestBody).not.toHaveBeenCalled() + }) + + it('should exlcude query validation', async () => { + mockValidator.validateQueryParams.mockReturnValueOnce({ errors: [MOCK_ERROR], normalizedParams: {} }) + const handler = configureValidationPreInvocationHandler(mockValidator, { + ...DEFAULT_HOOK_OPTIONS, + exclude: [ + { + path: 'api/v1/health', + method: 'GET', + validation: false, + }, + ], + }) + const ctx = getMockPreContext('api/v1/health', JSON.stringify({ status: 'ok' })) + await handler(ctx) + + const request: HttpRequest = { ...DEFAULT_HTTP_GET_REQUEST, query: new URLSearchParams('something') } + const functionResult = await ctx.functionHandler(request, MOCK_PRE_CONTEXT.invocationContext) + + expect(functionResult).toEqual({ status: 200, body: '{"status":"ok"}' }) + expect(mockValidator.validateRequestBody).not.toHaveBeenCalled() + }) + it('should pass post request without body', async () => { mockValidator.validateQueryParams.mockReturnValueOnce({ errors: undefined, normalizedParams: {} }) const handler = configureValidationPreInvocationHandler(mockValidator) @@ -257,6 +317,44 @@ describe('The app validator', () => { expect(mockValidator.validateRequestBody).toHaveBeenCalledWith('/api/v1/messages', 'POST', { hello: 'world' }, true, expect.anything()) }) + it('should fail missing request content type', async () => { + mockValidator.validateQueryParams.mockReturnValueOnce({ errors: undefined, normalizedParams: {} }) + const handler = configureValidationPreInvocationHandler(mockValidator) + const ctx = getMockPreContext('api/v1/messages', JSON.stringify({ status: 'ok' })) + await handler(ctx) + + const request: HttpRequest = DEFAULT_HTTP_POST_REQUEST({ hello: 'world' }) + request.headers.delete('Content-Type') + const functionResult = await ctx.functionHandler(request, MOCK_PRE_CONTEXT.invocationContext) + + expect(functionResult).toEqual({ + status: 400, + body: '{"errors":[{"status":400,"code":"Validation-missing-content-type-header","title":"The request header \'Content-Type\' is missing"}]}', + headers: { 'Content-Type': 'application/json' }, + }) + + expect(mockValidator.validateRequestBody).not.toHaveBeenCalled() + }) + + it('should fail wrong request content type', async () => { + mockValidator.validateQueryParams.mockReturnValueOnce({ errors: undefined, normalizedParams: {} }) + const handler = configureValidationPreInvocationHandler(mockValidator) + const ctx = getMockPreContext('api/v1/messages', JSON.stringify({ status: 'ok' })) + await handler(ctx) + + const request: HttpRequest = DEFAULT_HTTP_POST_REQUEST({ hello: 'world' }) + request.headers.set('Content-Type', 'text/plain') + const functionResult = await ctx.functionHandler(request, MOCK_PRE_CONTEXT.invocationContext) + + expect(functionResult).toEqual({ + status: 400, + body: '{"errors":[{"status":400,"code":"Validation-invalid-content-type-header","title":"The content type \'text/plain\' is not supported."}]}', + headers: { 'Content-Type': 'application/json' }, + }) + + expect(mockValidator.validateRequestBody).not.toHaveBeenCalled() + }) + it('should fail with post request body validation error', async () => { mockValidator.validateQueryParams.mockReturnValueOnce({ errors: undefined, normalizedParams: {} }) mockValidator.validateRequestBody.mockReturnValueOnce([MOCK_ERROR]) @@ -305,10 +403,132 @@ describe('The app validator', () => { expect(ctx.result).toEqual({ status: 500, + body: '{"errors":[{"status":500,"code":"Validation","title":"Response body validation failed"}]}', + headers: { + 'Content-Type': 'application/json', + }, + }) + expect(mockValidator.validateResponseBody).toHaveBeenCalledWith('/api/v1/health', 'GET', 200, { hello: 'ok' }, true, expect.anything()) + }) + + it('should pass with warning for non json response body', async () => { + const handler = configureValidationPostInvocationHandler(mockValidator, { + responseBodyValidationMode: { returnErrorResponse: false, strict: true, logLevel: 'info' }, + queryParameterValidationMode: false, + requestBodyValidationMode: false, + }) + const ctx = getMockPostContext('api/v1/health', { ...DEFAULT_HTTP_GET_REQUEST }, { status: 200, body: 'hello\nworld' }) + await handler(ctx) + + expect(ctx.result).toEqual({ status: 200, body: 'hello\nworld' }) + expect(mockValidator.validateResponseBody).not.toHaveBeenCalled() + }) + + it('should fail for non json response body', async () => { + const handler = configureValidationPostInvocationHandler(mockValidator, withResponseValidation) + const ctx = getMockPostContext('api/v1/health', { ...DEFAULT_HTTP_GET_REQUEST }, { status: 200, body: 'hello\nworld' }) + await handler(ctx) + + expect(ctx.result).toEqual({ + status: 500, + body: '{"errors":[{"status":500,"code":"Validation-invalid-content-type-header","title":"Response body validation failed"}]}', + headers: { + 'Content-Type': 'application/json', + }, + }) + expect(mockValidator.validateResponseBody).not.toHaveBeenCalled() + }) + + it('should handle path request exclusions 1', async () => { + mockValidator.validateQueryParams.mockReturnValueOnce({ errors: [MOCK_ERROR], normalizedParams: {} }) + mockValidator.validateRequestBody.mockReturnValueOnce([MOCK_ERROR]) + const handler = configureValidationPreInvocationHandler(mockValidator, { + ...DEFAULT_HOOK_OPTIONS, + exclude: [ + { + path: '/api/v1/health', + method: 'GET', + validation: { + queryParameter: false, + requestBody: false, + responseBody: true, + }, + }, + ], + }) + const ctx = getMockPreContext('api/v1/health', JSON.stringify({ status: 'ok' })) + await handler(ctx) + + const request: HttpRequest = { ...DEFAULT_HTTP_GET_REQUEST, query: new URLSearchParams('something') } + const functionResult = await ctx.functionHandler(request, MOCK_PRE_CONTEXT.invocationContext) + + expect(functionResult).toEqual({ status: 200, body: '{"status":"ok"}' }) + expect(mockValidator.validateRequestBody).not.toHaveBeenCalled() + expect(mockValidator.validateQueryParams).not.toHaveBeenCalled() + }) + + it('should handle path request exclusions2', async () => { + mockValidator.validateQueryParams.mockReturnValueOnce({ errors: [MOCK_ERROR], normalizedParams: {} }) + mockValidator.validateRequestBody.mockReturnValueOnce([MOCK_ERROR]) + const handler = configureValidationPreInvocationHandler(mockValidator, { + ...DEFAULT_HOOK_OPTIONS, + exclude: [ + { + path: '/api/v1/health', + method: 'GET', + validation: { + queryParameter: false, + requestBody: { + returnErrorResponse: true, + strict: false, + logLevel: 'info', + }, + responseBody: true, + }, + }, + ], + }) + const ctx = getMockPreContext('api/v1/health', JSON.stringify({ status: 'ok' })) + await handler(ctx) + + const request: HttpRequest = { ...DEFAULT_HTTP_GET_REQUEST, query: new URLSearchParams('something') } + const functionResult = await ctx.functionHandler(request, MOCK_PRE_CONTEXT.invocationContext) + + expect(functionResult).toEqual({ body: '{"errors":[{"status":400,"code":"ValidationError","title":"Validation failed"}]}', headers: { 'Content-Type': 'application/json', }, + status: 400, + }) + expect(mockValidator.validateRequestBody).toHaveBeenCalledWith('/api/v1/health', 'GET', undefined, false, expect.anything()) + expect(mockValidator.validateQueryParams).not.toHaveBeenCalled() + }) + + it('should handle path response exclusions', async () => { + mockValidator.validateResponseBody.mockReturnValueOnce([MOCK_ERROR]) + const handler = configureValidationPostInvocationHandler(mockValidator, { + ...DEFAULT_HOOK_OPTIONS, + exclude: [ + { + path: '/api/v1/health', + method: 'GET', + validation: { + queryParameter: false, + requestBody: false, + responseBody: true, + }, + }, + ], + }) + const ctx = getMockPostContext('api/v1/health', { ...DEFAULT_HTTP_GET_REQUEST }, { status: 200, jsonBody: { hello: 'ok' } }) + await handler(ctx) + expect(ctx.result).toEqual({ + body: '{"errors":[{"status":500,"code":"Validation","title":"Response body validation failed"}]}', + headers: { + 'Content-Type': 'application/json', + }, + status: 500, }) expect(mockValidator.validateResponseBody).toHaveBeenCalledWith('/api/v1/health', 'GET', 200, { hello: 'ok' }, true, expect.anything()) }) From 2943bbbb64a0600d0286d02e5a2028e715224dcc Mon Sep 17 00:00:00 2001 From: Patrick Ruhkopf Date: Fri, 10 May 2024 21:41:18 -0400 Subject: [PATCH 3/5] fix: misc bug fixes --- .../ajv-openapi-request-response-validator.ts | 15 +- .../src/openapi-validator.ts | 3 +- .../test/.eslintrc.js | 3 + .../test/fixtures/example-api.yaml | 153 +++++++++++++- .../fail-requestBody-allof-with-schema.js.txt | 1 + .../fail-requestBody-anyof-with-schema.js.txt | 2 + .../fail-requestBody-oneof-with-schema.js.txt | 2 +- ...requestBody-with-components-default.js.txt | 3 +- ...ail-requestBody-with-components-ref.js.txt | 3 +- .../test/unit/ajv-validator-api.spec.ts | 13 +- .../unit/ajv-validator-example-api.spec.ts | 196 +++++++++++++++++- .../test/unit/openapi-validator.spec.ts | 19 ++ 12 files changed, 390 insertions(+), 23 deletions(-) create mode 100644 packages/ajv-openapi-request-response-validator/test/unit/openapi-validator.spec.ts diff --git a/packages/ajv-openapi-request-response-validator/src/ajv-openapi-request-response-validator.ts b/packages/ajv-openapi-request-response-validator/src/ajv-openapi-request-response-validator.ts index 89cc661..dc35950 100644 --- a/packages/ajv-openapi-request-response-validator/src/ajv-openapi-request-response-validator.ts +++ b/packages/ajv-openapi-request-response-validator/src/ajv-openapi-request-response-validator.ts @@ -63,11 +63,20 @@ function mapValidatorErrors(validatorErrors: ErrorObject[] | null | undefined, s title: ajvErr.message ?? ET_VALIDATION, } - if (ajvErr.schemaPath) { - mappedErr.source = { - pointer: ajvErr.schemaPath, + if (ajvErr.schemaPath || ajvErr.params) { + mappedErr.source = {} + + if (ajvErr.schemaPath) { + mappedErr.source.pointer = ajvErr.schemaPath + } + if (ajvErr.params) { + const filteredParams = Object.entries(ajvErr.params).filter(([key]) => key === 'additionalProperty' || key === 'missingProperty') + if (filteredParams.length > 0) { + mappedErr.source.parameter = Object.values(ajvErr.params).join(', ') + } } } + mapped.push(mappedErr) }) return mapped diff --git a/packages/ajv-openapi-request-response-validator/src/openapi-validator.ts b/packages/ajv-openapi-request-response-validator/src/openapi-validator.ts index 324aafc..c441ff4 100644 --- a/packages/ajv-openapi-request-response-validator/src/openapi-validator.ts +++ b/packages/ajv-openapi-request-response-validator/src/openapi-validator.ts @@ -143,7 +143,7 @@ export function unserializeParameters(parameters: Record): Re const lastKeyIndex = splitKey.length - 1 splitKey.forEach((part, index) => { - const cleanPart = part.replace(']', '') + const cleanPart = part.replace(/]/g, '') // part.replace(']', '') if (index === lastKeyIndex) { target[cleanPart] = value @@ -156,6 +156,7 @@ export function unserializeParameters(parameters: Record): Re }) } } + return result } diff --git a/packages/ajv-openapi-request-response-validator/test/.eslintrc.js b/packages/ajv-openapi-request-response-validator/test/.eslintrc.js index 8707918..fa148e7 100644 --- a/packages/ajv-openapi-request-response-validator/test/.eslintrc.js +++ b/packages/ajv-openapi-request-response-validator/test/.eslintrc.js @@ -7,6 +7,9 @@ module.exports = { }, extends: ["../.eslintrc.js"], rules: { + "@typescript-eslint/no-magic-numbers": "off", + "@typescript-eslint/no-explicit-any": "off", + "no-console": "off", "@typescript-eslint/naming-convention": "off", "@typescript-eslint/no-non-null-assertion": "off", } diff --git a/packages/ajv-openapi-request-response-validator/test/fixtures/example-api.yaml b/packages/ajv-openapi-request-response-validator/test/fixtures/example-api.yaml index 63f5e1e..b8ab614 100644 --- a/packages/ajv-openapi-request-response-validator/test/fixtures/example-api.yaml +++ b/packages/ajv-openapi-request-response-validator/test/fixtures/example-api.yaml @@ -76,6 +76,16 @@ paths: schema: $ref: '#/components/schemas/OneOfExample' + '/one-of-example-list': + get: + responses: + '200': + description: Ok + content: + application/json: + schema: + $ref: '#/components/schemas/OneOfExampleList' + '/all-of-example': post: requestBody: @@ -126,7 +136,8 @@ paths: schema: type: object additionalProperties: true - /users/{uid}/state/{sid}: + + '/users/{uid}/state/{sid}': put: operationId: put-user-state summary: Upserts a user state @@ -151,6 +162,47 @@ paths: responses: '200': description: OK + patch: + operationId: patch-user-state + summary: Patch a user state + parameters: + - name: uid + in: path + required: true + schema: + type: string + - name: sid + in: path + description: The state id or key + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/PatchUserStateValue' + + /webhooks/mytest/{provision}: + post: + parameters: + - name: provision + in: path + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/MyTestProvisionResponse' + required: true + responses: + '200': + description: OK + components: responses: ResponseError: @@ -347,6 +399,14 @@ components: discriminator: propertyName: objType + OneOfExampleList: + type: object + properties: + items: + type: array + items: + $ref: '#/components/schemas/OneOfExample' + AllOfExample: type: object description: Example using allOf @@ -358,11 +418,13 @@ components: status: type: string enum: [pending, approved, rejected] + UserStateValue: type: "object" oneOf: - $ref: '#/components/schemas/UserStateCoffeeCx' - $ref: '#/components/schemas/UserUploads' + - $ref: '#/components/schemas/UserStateWidgets' discriminator: propertyName: type UserStateCoffeeCx: @@ -376,7 +438,10 @@ components: enum: [coffeeCx] enabled: type: boolean - description: Whether a certain flag, feature, etc. is enabled or not + description: Whether a certain flag, feature, etc. is enabled or not + nullableTest: + type: string + nullable: true UserUploads: type: object required: @@ -428,4 +493,86 @@ components: description: >- A valid path that matches any sequence of characters except if it contains '../', '//' (to avoid directory traversal attacks) - maxLength: 255 \ No newline at end of file + maxLength: 255 + UserStateWidgets: + type: object + required: + - type + - widgets + properties: + type: + type: string + enum: + - widgets + widgets: + type: array + items: + type: string + oneOf: + - minItems: 4 + maxItems: 4 + - minItems: 3 + maxItems: 3 + - minItems: 2 + maxItems: 2 + AnyValue: + description: Accepts any valid JSON value + additionalProperties: true + oneOf: + - type: string + - type: number + - type: integer + - type: boolean + - type: array + items: + $ref: '#/components/schemas/AnyValue' + - type: object + additionalProperties: true + PatchUserStateValue: + type: object + required: + - op + - path + - value + properties: + op: + type: string + enum: + - replace + - add + - remove + path: + type: string + value: + $ref: '#/components/schemas/AnyValue' + MyTestProvisionResponse: + type: object + required: + - taskId + properties: + taskId: + type: string + stateBag: + type: object + required: + - contentEntryId + properties: + contentEntryId: + type: string + guestUserUid: + type: string + siteInfo: + type: object + required: + - url + properties: + url: + type: string + teamId: + type: string + groupId: + type: string + errorMessage: + description: Reason why the job failed. + type: string + \ No newline at end of file diff --git a/packages/ajv-openapi-request-response-validator/test/fixtures/fail-requestBody-allof-with-schema.js.txt b/packages/ajv-openapi-request-response-validator/test/fixtures/fail-requestBody-allof-with-schema.js.txt index f1fe872..7c89bc0 100644 --- a/packages/ajv-openapi-request-response-validator/test/fixtures/fail-requestBody-allof-with-schema.js.txt +++ b/packages/ajv-openapi-request-response-validator/test/fixtures/fail-requestBody-allof-with-schema.js.txt @@ -109,6 +109,7 @@ module.exports = { { "code": "Validation-additionalProperties", "source": { + "parameter": "bark", "pointer": "#/components/schemas/Cat/additionalProperties", }, "status": 400, diff --git a/packages/ajv-openapi-request-response-validator/test/fixtures/fail-requestBody-anyof-with-schema.js.txt b/packages/ajv-openapi-request-response-validator/test/fixtures/fail-requestBody-anyof-with-schema.js.txt index ae4c6bd..d68c8e3 100644 --- a/packages/ajv-openapi-request-response-validator/test/fixtures/fail-requestBody-anyof-with-schema.js.txt +++ b/packages/ajv-openapi-request-response-validator/test/fixtures/fail-requestBody-anyof-with-schema.js.txt @@ -76,6 +76,7 @@ module.exports = { { "code": "Validation-required", "source": { + "parameter": "age", "pointer": "#/components/schemas/PetByAge/required", }, "status": 400, @@ -84,6 +85,7 @@ module.exports = { { "code": "Validation-required", "source": { + "parameter": "pet_type", "pointer": "#/components/schemas/PetByType/required", }, "status": 400, diff --git a/packages/ajv-openapi-request-response-validator/test/fixtures/fail-requestBody-oneof-with-schema.js.txt b/packages/ajv-openapi-request-response-validator/test/fixtures/fail-requestBody-oneof-with-schema.js.txt index 92bc1f7..ac5314f 100644 --- a/packages/ajv-openapi-request-response-validator/test/fixtures/fail-requestBody-oneof-with-schema.js.txt +++ b/packages/ajv-openapi-request-response-validator/test/fixtures/fail-requestBody-oneof-with-schema.js.txt @@ -66,6 +66,6 @@ module.exports = { }, }, expectedErrors: [ - {"code": "Validation-additionalProperties", "source": {"pointer": "#/components/schemas/Cat/additionalProperties"}, "status": 400, "title": "must NOT have additional properties"}, {"code": "Validation-additionalProperties", "source": {"pointer": "#/components/schemas/Dog/additionalProperties"}, "status": 400, "title": "must NOT have additional properties"}, {"code": "Validation-oneOf", "source": {"pointer": "#/oneOf"}, "status": 400, "title": "must match exactly one schema in oneOf"} + {"code": "Validation-additionalProperties", "source": {"pointer": "#/components/schemas/Cat/additionalProperties", "parameter": "breed",}, "status": 400, "title": "must NOT have additional properties"}, {"code": "Validation-additionalProperties", "source": {"pointer": "#/components/schemas/Dog/additionalProperties", "parameter": "age"}, "status": 400, "title": "must NOT have additional properties"}, {"code": "Validation-oneOf", "source": {"pointer": "#/oneOf"}, "status": 400, "title": "must match exactly one schema in oneOf"} ] }; \ No newline at end of file diff --git a/packages/ajv-openapi-request-response-validator/test/fixtures/fail-requestBody-with-components-default.js.txt b/packages/ajv-openapi-request-response-validator/test/fixtures/fail-requestBody-with-components-default.js.txt index 0960e91..cec7e77 100644 --- a/packages/ajv-openapi-request-response-validator/test/fixtures/fail-requestBody-with-components-default.js.txt +++ b/packages/ajv-openapi-request-response-validator/test/fixtures/fail-requestBody-with-components-default.js.txt @@ -31,7 +31,8 @@ module.exports = { expectedErrors: [ { code: 'Validation-additionalProperties', - source: { + source: { + "parameter": "something-else", pointer: '#/components/schemas/Test1/additionalProperties' }, status: 400, diff --git a/packages/ajv-openapi-request-response-validator/test/fixtures/fail-requestBody-with-components-ref.js.txt b/packages/ajv-openapi-request-response-validator/test/fixtures/fail-requestBody-with-components-ref.js.txt index b6b1b2a..4908289 100644 --- a/packages/ajv-openapi-request-response-validator/test/fixtures/fail-requestBody-with-components-ref.js.txt +++ b/packages/ajv-openapi-request-response-validator/test/fixtures/fail-requestBody-with-components-ref.js.txt @@ -28,7 +28,8 @@ module.exports = { expectedErrors: [ { code: 'Validation-required', - source: { + source: { + "parameter": "foo", pointer: '#/components/schemas/Test1/required' }, status: 400, diff --git a/packages/ajv-openapi-request-response-validator/test/unit/ajv-validator-api.spec.ts b/packages/ajv-openapi-request-response-validator/test/unit/ajv-validator-api.spec.ts index 27eb836..3b58bfe 100644 --- a/packages/ajv-openapi-request-response-validator/test/unit/ajv-validator-api.spec.ts +++ b/packages/ajv-openapi-request-response-validator/test/unit/ajv-validator-api.spec.ts @@ -1,6 +1,5 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ import * as fs from 'fs' -import * as path from 'path' +import * as nodepath from 'path' import { OpenAPIV3 } from 'openapi-types' import copy from 'fast-copy' import { createAjvInstance } from '../../src/ajv-factory' @@ -64,8 +63,8 @@ const files = fs.readdirSync(fixtureDir) const testCases: { [key: string]: TestFixture } = {} for (const file of files) { if (file.endsWith('.js.txt')) { - const testName = path.basename(file, '.js.txt').replace(/-/g, ' ') - const fixtureContent = fs.readFileSync(path.resolve(fixtureDir, file), { encoding: 'utf-8' }) + const testName = nodepath.basename(file, '.js.txt').replace(/-/g, ' ') + const fixtureContent = fs.readFileSync(nodepath.resolve(fixtureDir, file), { encoding: 'utf-8' }) try { // eslint-disable-next-line no-eval const fixture: TestFixture = eval(fixtureContent) @@ -116,14 +115,14 @@ describe('The api validator', () => { if (fixture.validateArgs.paths) { const params = fixture.request.query ? fixture.request.query : {} - for (const [methodPath, method] of Object.entries(fixture.validateArgs.paths)) { + for (const [path, method] of Object.entries(fixture.validateArgs.paths)) { if (method) { for (const [methodName, methodDef] of Object.entries(method)) { if (Object.values(OpenAPIV3.HttpMethods).includes(methodName as OpenAPIV3.HttpMethods)) { const operation: OpenAPIV3.OperationObject = methodDef if (operation.parameters) { const result = validator.validateQueryParams( - methodPath, + path, methodName, params, fixture.requestOpts?.strictQueryParamValidation ?? true @@ -136,7 +135,7 @@ describe('The api validator', () => { } if (operation.requestBody && fixture.request.body) { const result = validator.validateRequestBody( - methodPath, + path, methodName, fixture.request.body, fixture.requestOpts?.strictRequestBodyValidation ?? true diff --git a/packages/ajv-openapi-request-response-validator/test/unit/ajv-validator-example-api.spec.ts b/packages/ajv-openapi-request-response-validator/test/unit/ajv-validator-example-api.spec.ts index 12443fa..458b754 100644 --- a/packages/ajv-openapi-request-response-validator/test/unit/ajv-validator-example-api.spec.ts +++ b/packages/ajv-openapi-request-response-validator/test/unit/ajv-validator-example-api.spec.ts @@ -26,7 +26,7 @@ describe('The api validator for the user api spec', () => { code: 'Validation-required', status: 500, title: "must have required property 'code'", - source: { pointer: '#/required' }, + source: { pointer: '#/required', parameter: 'code' }, }, ]) }) @@ -52,7 +52,7 @@ describe('The api validator for the user api spec', () => { ).toEqual([ { code: 'Validation-additionalProperties', - source: { pointer: '#/additionalProperties' }, + source: { pointer: '#/additionalProperties', parameter: 'unknownPro' }, status: 500, title: 'must NOT have additional properties', }, @@ -73,12 +73,44 @@ describe('The api validator for the user api spec', () => { expect(validator.validateResponseBody('/one-of-example', 'get', '200', dataWithExtra)).toEqual(undefined) }) + it('should succeed oneOf list with mixed results', () => { + const dataWithExtraA = { name: 'test', description: 'hello', objType: 'a' } + const dataWithExtraB = { somethingElse: 123, objType: 'b' } + expect( + validator.validateResponseBody('/one-of-example-list', 'get', '200', { + items: [dataWithExtraA, dataWithExtraB, dataWithExtraA], + }) + ).toEqual(undefined) + }) + + it('should fail oneOf list with mixed results', () => { + const dataWithExtraA = { name: 'test', description: 'hello', objType: 'a' } + expect( + validator.validateResponseBody('/one-of-example-list', 'get', '200', { + items: [dataWithExtraA, { something: 'test', objType: 'a' }], + }) + ).toEqual([ + { + code: 'Validation-required', + source: { pointer: '#/components/schemas/TestRequestA/required', parameter: 'name' }, + status: 500, + title: "must have required property 'name'", + }, + { + code: 'Validation-additionalProperties', + source: { pointer: '#/components/schemas/TestRequestA/additionalProperties', parameter: 'something' }, + status: 500, + title: 'must NOT have additional properties', + }, + ]) + }) + it('should fail oneOf AB', () => { const dataWithExtra = { name: 'test', description: 'hello', objType: 'a', somethingElse: 1 } expect(validator.validateResponseBody('/one-of-example', 'get', '200', dataWithExtra)).toEqual([ { code: 'Validation-additionalProperties', - source: { pointer: '#/components/schemas/TestRequestA/additionalProperties' }, + source: { pointer: '#/components/schemas/TestRequestA/additionalProperties', parameter: 'somethingElse' }, status: 500, title: 'must NOT have additional properties', }, @@ -102,7 +134,7 @@ describe('The api validator for the user api spec', () => { expect(validator.validateRequestBody('/all-of-example', 'post', dataWithExtra)).toEqual([ { code: 'Validation-required', - source: { pointer: '#/components/schemas/AllOfExample/required' }, + source: { pointer: '#/components/schemas/AllOfExample/required', parameter: 'somethingElse' }, status: 400, title: "must have required property 'somethingElse'", }, @@ -348,7 +380,159 @@ describe('The api validator for the user api spec', () => { ).toEqual([{ code: 'Validation-unexpected-request-body', status: 400, title: 'A request body is not supported' }]) }) - fit('should validate user state 1', () => { - expect(validator.validateRequestBody('/users/{uid}/state/{sid}', 'put', { enabled: true })).toBeUndefined() + it('should fail to validate user state without type', () => { + expect(validator.validateRequestBody('/users/{uid}/state/{sid}', 'put', { enabled: true })).toEqual([ + { code: 'Validation-discriminator', source: { pointer: '#/discriminator' }, status: 400, title: 'tag "type" must be string' }, + ]) + }) + + it('should validate user state 1', () => { + expect( + validator.validateRequestBody('/users/{uid}/state/{sid}', 'put', { type: 'coffeeCx', enabled: true, nullableTest: null }) + ).toBeUndefined() + }) + + it('should validate user state 2', () => { + expect( + validator.validateRequestBody('/users/{uid}/state/{sid}', 'put', { + type: 'userUploads', + entries: [ + { + id: '123', + name: 'test', + communityId: '123', + status: 'ongoing', + path: 'mycommunity/sub/dir', + }, + ], + }) + ).toBeUndefined() + }) + + it('should fail to validate user state invalid path', () => { + expect( + validator.validateRequestBody('/users/{uid}/state/{sid}', 'put', { + type: 'userUploads', + entries: [ + { + id: '123', + name: 'test', + communityId: '123', + status: 'ongoing', + path: '../../', + }, + ], + }) + ).toEqual([ + { + code: 'Validation-pattern', + source: { pointer: '#/components/schemas/SafePath/pattern' }, + status: 400, + title: 'must match pattern "(?=(^(?!.*\\.\\.\\/).+))(?=(^(?!.*\\/\\/).+))"', + }, + ]) + }) + + it('should validate min max for arrays', () => { + expect(validator.validateRequestBody('/users/{uid}/state/{sid}', 'put', { type: 'widgets', widgets: ['1', '2'] })).toBeUndefined() + expect(validator.validateRequestBody('/users/{uid}/state/{sid}', 'put', { type: 'widgets', widgets: ['1', '2', '3'] })).toBeUndefined() + expect( + validator.validateRequestBody('/users/{uid}/state/{sid}', 'put', { type: 'widgets', widgets: ['1', '2', '3', '4'] }) + ).toBeUndefined() + }) + + it('should fail to validate min max with empty array', () => { + expect(validator.validateRequestBody('/users/{uid}/state/{sid}', 'put', { type: 'widgets', widgets: [] })).toEqual([ + { + code: 'Validation-minItems', + source: { pointer: '#/components/schemas/UserStateWidgets/properties/widgets/oneOf/0/minItems' }, + status: 400, + title: 'must NOT have fewer than 4 items', + }, + { + code: 'Validation-minItems', + source: { pointer: '#/components/schemas/UserStateWidgets/properties/widgets/oneOf/1/minItems' }, + status: 400, + title: 'must NOT have fewer than 3 items', + }, + { + code: 'Validation-minItems', + source: { pointer: '#/components/schemas/UserStateWidgets/properties/widgets/oneOf/2/minItems' }, + status: 400, + title: 'must NOT have fewer than 2 items', + }, + { + code: 'Validation-oneOf', + source: { pointer: '#/components/schemas/UserStateWidgets/properties/widgets/oneOf' }, + status: 400, + title: 'must match exactly one schema in oneOf', + }, + ]) + }) + it('should fail to validate min max for arrays', () => { + expect(validator.validateRequestBody('/users/{uid}/state/{sid}', 'put', { type: 'widgets', widgets: ['1'] })).toEqual([ + { + code: 'Validation-minItems', + source: { pointer: '#/components/schemas/UserStateWidgets/properties/widgets/oneOf/0/minItems' }, + status: 400, + title: 'must NOT have fewer than 4 items', + }, + { + code: 'Validation-minItems', + source: { pointer: '#/components/schemas/UserStateWidgets/properties/widgets/oneOf/1/minItems' }, + status: 400, + title: 'must NOT have fewer than 3 items', + }, + { + code: 'Validation-minItems', + source: { pointer: '#/components/schemas/UserStateWidgets/properties/widgets/oneOf/2/minItems' }, + status: 400, + title: 'must NOT have fewer than 2 items', + }, + { + code: 'Validation-oneOf', + source: { pointer: '#/components/schemas/UserStateWidgets/properties/widgets/oneOf' }, + status: 400, + title: 'must match exactly one schema in oneOf', + }, + ]) + }) + + it('should patch user state', () => { + expect( + validator.validateRequestBody('/users/{uid}/state/{sid}', 'patch', [ + { + op: 'add', + path: '/entries', + value: { + id: 'HoPnVO3QD1uHdzJXpqD2U', + title: 'you ok?', + date: '2024-04-04T18:50:54.419Z', + messages: [ + { role: 'user', content: 'you ok?' }, + { + content: + "As an artificial intelligence, I don't have feelings, but I'm functioning as expected. Thank you for asking! How can I assist you today?", + role: 'assistant', + }, + ], + }, + }, + ]) + ).toBeUndefined() + }) + + it('should validate webhook response', () => { + expect( + validator.validateRequestBody('/webhooks/mytest/{provision}', 'post', { + siteInfo: { + url: 'https://bertelsmann.sharepoint.com/sites/bportal_bcp_dev_privatecommunitytopublic', + groupId: '9767a68f-842f-4197-a588-5e639358281b', + teamId: '9767a68f-842f-4197-a588-5e639358281b', + }, + taskId: '88a4984e-cf31-4b46-b849-035eb76feaf5', + stateBag: { contentEntryId: 'wNC62LREKjzmLnUnxPjHHW' }, + }) + ).toBeUndefined() }) }) diff --git a/packages/ajv-openapi-request-response-validator/test/unit/openapi-validator.spec.ts b/packages/ajv-openapi-request-response-validator/test/unit/openapi-validator.spec.ts new file mode 100644 index 0000000..f73a325 --- /dev/null +++ b/packages/ajv-openapi-request-response-validator/test/unit/openapi-validator.spec.ts @@ -0,0 +1,19 @@ +import { Primitive, unserializeParameters } from '../../src/openapi-validator' + +describe('The api validator', () => { + it.each([ + [ + { filter: 'test', 'page[offset]': '0', 'page[limit]': '12' }, + { filter: 'test', page: { offset: '0', limit: '12' } }, + ], + [{ 'page[one][two]': '12', 'page[one][three]': '123' }, { page: { one: { two: '12', three: '123' } } }], + [ + { 'another[one[two]]': 'test', 'page[[one][two]': '12', 'empty[]': '123' }, + { another: { one: { two: 'test' } }, page: { '': { one: { two: '12' } } }, empty: { '': '123' } }, + ], + ])('should unserialize parameters', (parameters: Record, expected: Record) => { + const actual = unserializeParameters(parameters) + JSON.stringify(actual) + expect(actual).toEqual(expected) + }) +}) From e398c985e6923dd983b07ee7717214d3dc3eb2ed Mon Sep 17 00:00:00 2001 From: Patrick Ruhkopf Date: Fri, 10 May 2024 21:41:51 -0400 Subject: [PATCH 4/5] chore: apply npm audit fix --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5e789ed..0b3cd60 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8752,9 +8752,9 @@ } }, "node_modules/undici": { - "version": "5.28.3", - "resolved": "https://registry.npmjs.org/undici/-/undici-5.28.3.tgz", - "integrity": "sha512-3ItfzbrhDlINjaP0duwnNsKpDQk3acHI3gVJ1z4fmwMK31k5G9OVIAMLSIaP6w4FaGkaAkN6zaQO9LUvZ1t7VA==", + "version": "5.28.4", + "resolved": "https://registry.npmjs.org/undici/-/undici-5.28.4.tgz", + "integrity": "sha512-72RFADWFqKmUb2hmmvNODKL3p9hcB6Gt2DOQMis1SEBaV6a4MH8soBvzg+95CYhCKPFedut2JY9bMfrDl9D23g==", "dependencies": { "@fastify/busboy": "^2.0.0" }, From 8f7c1b7f16be0aaec747962c1959ebd6bc19ecae Mon Sep 17 00:00:00 2001 From: Patrick Ruhkopf Date: Fri, 10 May 2024 21:45:07 -0400 Subject: [PATCH 5/5] chore: remove commented out code --- .../src/openapi-validator.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ajv-openapi-request-response-validator/src/openapi-validator.ts b/packages/ajv-openapi-request-response-validator/src/openapi-validator.ts index c441ff4..ef685c9 100644 --- a/packages/ajv-openapi-request-response-validator/src/openapi-validator.ts +++ b/packages/ajv-openapi-request-response-validator/src/openapi-validator.ts @@ -143,7 +143,7 @@ export function unserializeParameters(parameters: Record): Re const lastKeyIndex = splitKey.length - 1 splitKey.forEach((part, index) => { - const cleanPart = part.replace(/]/g, '') // part.replace(']', '') + const cleanPart = part.replace(/]/g, '') if (index === lastKeyIndex) { target[cleanPart] = value