diff --git a/packages/bruno-js/src/runtime/assert-runtime.js b/packages/bruno-js/src/runtime/assert-runtime.js index f826b37b5..22078efe2 100644 --- a/packages/bruno-js/src/runtime/assert-runtime.js +++ b/packages/bruno-js/src/runtime/assert-runtime.js @@ -8,6 +8,7 @@ const { interpolateString } = require('../interpolate-string'); const { executeQuickJsVm } = require('../sandbox/quickjs'); const Ajv = require('ajv'); +const addFormats = require('ajv-formats'); const { expect } = chai; chai.use(require('chai-string')); chai.use(function (chai, utils) { @@ -26,9 +27,30 @@ chai.use(function (chai, utils) { }); // Custom assertion for JSON Schema validation +const defaultAjv = new Ajv({ allErrors: true }); +addFormats(defaultAjv); + +const SUPPORTED_SCHEMA_VERSIONS = [ + 'http://json-schema.org/draft-07/schema#', + 'http://json-schema.org/draft-07/schema' +]; + chai.use(function (chai) { chai.Assertion.addMethod('jsonSchema', function (schema, ajvOptions) { - const ajv = new Ajv({ allErrors: true, ...ajvOptions }); + if (schema && schema.$schema && !SUPPORTED_SCHEMA_VERSIONS.includes(schema.$schema)) { + this.assert( + false, + `Unsupported JSON Schema version: "${schema.$schema}". Bruno currently only supports Draft-07 (http://json-schema.org/draft-07/schema#). Please update your schema to be Draft-07 compatible and remove the $schema property.`, + `Unsupported JSON Schema version: "${schema.$schema}".` + ); + } + let ajv; + if (ajvOptions) { + ajv = new Ajv({ allErrors: true, ...ajvOptions }); + addFormats(ajv); + } else { + ajv = defaultAjv; + } let validate; try { validate = ajv.compile(schema); diff --git a/packages/bruno-js/src/sandbox/bundle-libraries.js b/packages/bruno-js/src/sandbox/bundle-libraries.js index 34041363f..58abf568d 100644 --- a/packages/bruno-js/src/sandbox/bundle-libraries.js +++ b/packages/bruno-js/src/sandbox/bundle-libraries.js @@ -15,6 +15,7 @@ const bundleLibraries = async () => { import * as cryptoJs from 'crypto-js'; import tv4 from "tv4"; import Ajv from "ajv"; + import addFormats from "ajv-formats"; globalThis.expect = expect; globalThis.assert = assert; globalThis.moment = moment; @@ -23,6 +24,7 @@ const bundleLibraries = async () => { globalThis.Buffer = Buffer; globalThis.tv4 = tv4; globalThis.Ajv = Ajv; + globalThis.addFormats = addFormats; globalThis.requireObject = { ...(globalThis.requireObject || {}), 'chai': { expect, assert }, @@ -32,7 +34,8 @@ const bundleLibraries = async () => { 'atob': atob, 'crypto-js': cryptoJs, 'tv4': tv4, - 'ajv': Ajv + 'ajv': Ajv, + 'ajv-formats': addFormats }; `; diff --git a/packages/bruno-js/src/sandbox/quickjs/shims/test.js b/packages/bruno-js/src/sandbox/quickjs/shims/test.js index e3666e5c5..d2c7f7dbb 100644 --- a/packages/bruno-js/src/sandbox/quickjs/shims/test.js +++ b/packages/bruno-js/src/sandbox/quickjs/shims/test.js @@ -84,9 +84,29 @@ const addBruShimToContext = (vm, __brunoTestResults) => { ` (function() { var Ajv = require('ajv'); + var addFormats = require('ajv-formats'); + var defaultAjv = new Ajv({ allErrors: true }); + addFormats(defaultAjv); + var SUPPORTED_SCHEMA_VERSIONS = [ + 'http://json-schema.org/draft-07/schema#', + 'http://json-schema.org/draft-07/schema' + ]; var proto = Object.getPrototypeOf(expect(null)); proto.jsonSchema = function(schema, ajvOptions) { - var ajv = new Ajv(Object.assign({ allErrors: true }, ajvOptions || {})); + if (schema && schema.$schema && !SUPPORTED_SCHEMA_VERSIONS.includes(schema.$schema)) { + this.assert( + false, + 'Unsupported JSON Schema version: "' + schema.$schema + '". Bruno currently only supports Draft-07 (http://json-schema.org/draft-07/schema#). Please update your schema to be Draft-07 compatible and remove the $schema property.', + 'Unsupported JSON Schema version: "' + schema.$schema + '".' + ); + } + var ajv; + if (ajvOptions) { + ajv = new Ajv(Object.assign({ allErrors: true }, ajvOptions)); + addFormats(ajv); + } else { + ajv = defaultAjv; + } var validate; try { validate = ajv.compile(schema); diff --git a/packages/bruno-js/tests/runtime.spec.js b/packages/bruno-js/tests/runtime.spec.js index 4b00b4391..0ed6733c6 100644 --- a/packages/bruno-js/tests/runtime.spec.js +++ b/packages/bruno-js/tests/runtime.spec.js @@ -501,6 +501,40 @@ describe('runtime', () => { const schema = { type: 'array' }; chai.expect(body).to.not.have.jsonSchema(schema); }); + + it('should throw a clear error for unsupported Draft 2020-12 $schema', () => { + const body = { name: 'John' }; + const schema = { + $schema: 'https://json-schema.org/draft/2020-12/schema', + type: 'object', + properties: { name: { type: 'string' } } + }; + expect(() => chai.expect(body).to.have.jsonSchema(schema)).toThrow(/Unsupported JSON Schema version.*2020-12.*only supports Draft-07/); + }); + + it('should throw a clear error for unsupported Draft 2019-09 $schema', () => { + const body = { name: 'John' }; + const schema = { + $schema: 'https://json-schema.org/draft/2019-09/schema', + type: 'object', + properties: { name: { type: 'string' } } + }; + expect(() => chai.expect(body).to.have.jsonSchema(schema)).toThrow(/Unsupported JSON Schema version.*2019-09.*only supports Draft-07/); + }); + + it('should allow explicit Draft-07 $schema', () => { + const body = { name: 'John', age: 30 }; + const schema = { + $schema: 'http://json-schema.org/draft-07/schema#', + type: 'object', + properties: { + name: { type: 'string' }, + age: { type: 'number' } + }, + required: ['name', 'age'] + }; + chai.expect(body).to.have.jsonSchema(schema); + }); }); }); }); diff --git a/packages/bruno-tests/collection/scripting/api/res/jsonSchema.bru b/packages/bruno-tests/collection/scripting/api/res/jsonSchema.bru index 027d9c9e7..0eb68dee9 100644 --- a/packages/bruno-tests/collection/scripting/api/res/jsonSchema.bru +++ b/packages/bruno-tests/collection/scripting/api/res/jsonSchema.bru @@ -23,7 +23,27 @@ body:json { "street": "123 Main St", "city": "Springfield", "zip": "62701" - } + }, + "website": "https://example.com/john", + "createdAt": "2024-01-15T10:30:00Z", + "birthDate": "1994-05-20", + "loginTime": "10:30:00Z", + "ipv4": "192.168.1.1", + "ipv6": "::1", + "id": "550e8400-e29b-41d4-a716-446655440000", + "encodedData": "SGVsbG8gV29ybGQ=", + "int32Val": 2147483647, + "int64Val": 2147483648, + "floatVal": 3.14, + "doubleVal": 1.7976931348623157e+308, + "duration": "P3Y6M4DT12H30M5S", + "hostname": "example.com", + "regexPattern": "^[a-z]+$", + "jsonPointer": "/foo/bar/0", + "uriRef": "/relative/path", + "uriTemplate": "https://example.com/{user}", + "invalidRegex": "[invalid", + "invalidUriTemplate": "https://example.com/{invalid" } } @@ -166,7 +186,7 @@ tests { expect(res.getBody()).to.have.jsonSchema(schema); }); - test("jsonSchema with ajvOptions", function() { + test("jsonSchema with ajvOptions - allErrors", function() { const schema = { type: 'object', properties: { @@ -178,6 +198,525 @@ tests { expect(res.getBody()).to.have.jsonSchema(schema, { allErrors: true }); }); + test("jsonSchema with ajvOptions - format validation with allErrors", function() { + const schema = { + type: 'object', + properties: { + email: { type: 'string', format: 'email' }, + website: { type: 'string', format: 'uri' } + }, + required: ['email', 'website'] + }; + expect(res.getBody()).to.have.jsonSchema(schema, { allErrors: true }); + }); + + test("jsonSchema with ajvOptions - format rejection with allErrors", function() { + const schema = { + type: 'object', + properties: { + name: { type: 'string', format: 'email' }, + age: { type: 'string', format: 'uri' } + }, + required: ['name', 'age'] + }; + expect(res.getBody()).to.not.have.jsonSchema(schema, { allErrors: true }); + }); + + test("jsonSchema with ajvOptions - additionalProperties false", function() { + const schema = { + type: 'object', + properties: { + name: { type: 'string' } + }, + additionalProperties: false + }; + expect(res.getBody()).to.not.have.jsonSchema(schema, { allErrors: true }); + }); + + test("jsonSchema with ajvOptions - coerceTypes allows string as number", function() { + const schema = { + type: 'object', + properties: { + zip: { type: 'integer' } + }, + required: ['zip'] + }; + // zip is "62701" (string) - fails without coercion + expect(res.getBody().address).to.not.have.jsonSchema(schema); + // passes with coerceTypes since "62701" can be coerced to integer + expect(res.getBody().address).to.have.jsonSchema(schema, { coerceTypes: true }); + }); + + test("jsonSchema with ajvOptions - coerceTypes allows number as string", function() { + const schema = { + type: 'object', + properties: { + age: { type: 'string' } + }, + required: ['age'] + }; + // age is 30 (number) - fails without coercion + expect(res.getBody()).to.not.have.jsonSchema(schema); + // passes with coerceTypes since 30 can be coerced to "30" + expect(res.getBody()).to.have.jsonSchema(schema, { coerceTypes: true }); + }); + + test("jsonSchema with ajvOptions - strict false allows unknown keywords", function() { + const schema = { + type: 'object', + properties: { + name: { type: 'string', customKeyword: true } + }, + required: ['name'] + }; + // unknown keyword "customKeyword" throws in strict mode (default) + expect(() => expect(res.getBody()).to.have.jsonSchema(schema)).to.throw('JSON schema compile error'); + // passes with strict: false + expect(res.getBody()).to.have.jsonSchema(schema, { strict: false }); + }); + + // --- ajv-formats: Passing validations --- + + test("format: email - valid email", function() { + const schema = { + type: 'object', + properties: { + email: { type: 'string', format: 'email' } + }, + required: ['email'] + }; + expect(res.getBody()).to.have.jsonSchema(schema); + }); + + test("format: uri - valid URI", function() { + const schema = { + type: 'object', + properties: { + website: { type: 'string', format: 'uri' } + }, + required: ['website'] + }; + expect(res.getBody()).to.have.jsonSchema(schema); + }); + + test("format: date-time - valid ISO 8601 date-time", function() { + const schema = { + type: 'object', + properties: { + createdAt: { type: 'string', format: 'date-time' } + }, + required: ['createdAt'] + }; + expect(res.getBody()).to.have.jsonSchema(schema); + }); + + test("format: date - valid date", function() { + const schema = { + type: 'object', + properties: { + birthDate: { type: 'string', format: 'date' } + }, + required: ['birthDate'] + }; + expect(res.getBody()).to.have.jsonSchema(schema); + }); + + test("format: time - valid time", function() { + const schema = { + type: 'object', + properties: { + loginTime: { type: 'string', format: 'time' } + }, + required: ['loginTime'] + }; + expect(res.getBody()).to.have.jsonSchema(schema); + }); + + test("format: ipv4 - valid IPv4 address", function() { + const schema = { + type: 'object', + properties: { + ipv4: { type: 'string', format: 'ipv4' } + }, + required: ['ipv4'] + }; + expect(res.getBody()).to.have.jsonSchema(schema); + }); + + test("format: ipv6 - valid IPv6 address", function() { + const schema = { + type: 'object', + properties: { + ipv6: { type: 'string', format: 'ipv6' } + }, + required: ['ipv6'] + }; + expect(res.getBody()).to.have.jsonSchema(schema); + }); + + test("format: uuid - valid UUID", function() { + const schema = { + type: 'object', + properties: { + id: { type: 'string', format: 'uuid' } + }, + required: ['id'] + }; + expect(res.getBody()).to.have.jsonSchema(schema); + }); + + test("format: byte - valid base64 string", function() { + const schema = { + type: 'object', + properties: { + encodedData: { type: 'string', format: 'byte' } + }, + required: ['encodedData'] + }; + expect(res.getBody()).to.have.jsonSchema(schema); + }); + + test("format: int32 - valid 32-bit integer", function() { + const schema = { + type: 'object', + properties: { + int32Val: { type: 'integer', format: 'int32' } + }, + required: ['int32Val'] + }; + expect(res.getBody()).to.have.jsonSchema(schema); + }); + + test("format: int64 - valid 64-bit integer", function() { + const schema = { + type: 'object', + properties: { + int64Val: { type: 'integer', format: 'int64' } + }, + required: ['int64Val'] + }; + expect(res.getBody()).to.have.jsonSchema(schema); + }); + + test("format: float - valid float", function() { + const schema = { + type: 'object', + properties: { + floatVal: { type: 'number', format: 'float' } + }, + required: ['floatVal'] + }; + expect(res.getBody()).to.have.jsonSchema(schema); + }); + + test("format: double - valid double", function() { + const schema = { + type: 'object', + properties: { + doubleVal: { type: 'number', format: 'double' } + }, + required: ['doubleVal'] + }; + expect(res.getBody()).to.have.jsonSchema(schema); + }); + + test("format: duration - valid ISO 8601 duration", function() { + const schema = { + type: 'object', + properties: { + duration: { type: 'string', format: 'duration' } + }, + required: ['duration'] + }; + expect(res.getBody()).to.have.jsonSchema(schema); + }); + + test("format: hostname - valid hostname", function() { + const schema = { + type: 'object', + properties: { + hostname: { type: 'string', format: 'hostname' } + }, + required: ['hostname'] + }; + expect(res.getBody()).to.have.jsonSchema(schema); + }); + + test("format: regex - valid regex pattern", function() { + const schema = { + type: 'object', + properties: { + regexPattern: { type: 'string', format: 'regex' } + }, + required: ['regexPattern'] + }; + expect(res.getBody()).to.have.jsonSchema(schema); + }); + + test("format: json-pointer - valid JSON pointer", function() { + const schema = { + type: 'object', + properties: { + jsonPointer: { type: 'string', format: 'json-pointer' } + }, + required: ['jsonPointer'] + }; + expect(res.getBody()).to.have.jsonSchema(schema); + }); + + test("format: uri-reference - valid URI reference", function() { + const schema = { + type: 'object', + properties: { + uriRef: { type: 'string', format: 'uri-reference' } + }, + required: ['uriRef'] + }; + expect(res.getBody()).to.have.jsonSchema(schema); + }); + + test("format: uri-template - valid URI template", function() { + const schema = { + type: 'object', + properties: { + uriTemplate: { type: 'string', format: 'uri-template' } + }, + required: ['uriTemplate'] + }; + expect(res.getBody()).to.have.jsonSchema(schema); + }); + + test("Multiple formats in one schema", function() { + const schema = { + type: 'object', + properties: { + email: { type: 'string', format: 'email' }, + website: { type: 'string', format: 'uri' }, + createdAt: { type: 'string', format: 'date-time' }, + ipv4: { type: 'string', format: 'ipv4' }, + id: { type: 'string', format: 'uuid' }, + encodedData: { type: 'string', format: 'byte' }, + int32Val: { type: 'integer', format: 'int32' }, + floatVal: { type: 'number', format: 'float' }, + duration: { type: 'string', format: 'duration' }, + hostname: { type: 'string', format: 'hostname' } + }, + required: ['email', 'website', 'createdAt', 'ipv4', 'id', 'encodedData', 'int32Val', 'floatVal', 'duration', 'hostname'] + }; + expect(res.getBody()).to.have.jsonSchema(schema); + }); + + // --- ajv-formats: Failure validations --- + + test("format: email - rejects non-email string", function() { + const schema = { + type: 'object', + properties: { + name: { type: 'string', format: 'email' } + }, + required: ['name'] + }; + expect(res.getBody()).to.not.have.jsonSchema(schema); + }); + + test("format: uri - rejects non-URI string", function() { + const schema = { + type: 'object', + properties: { + name: { type: 'string', format: 'uri' } + }, + required: ['name'] + }; + expect(res.getBody()).to.not.have.jsonSchema(schema); + }); + + test("format: date-time - rejects plain date string", function() { + const schema = { + type: 'object', + properties: { + birthDate: { type: 'string', format: 'date-time' } + }, + required: ['birthDate'] + }; + expect(res.getBody()).to.not.have.jsonSchema(schema); + }); + + test("format: ipv4 - rejects non-IP string", function() { + const schema = { + type: 'object', + properties: { + name: { type: 'string', format: 'ipv4' } + }, + required: ['name'] + }; + expect(res.getBody()).to.not.have.jsonSchema(schema); + }); + + test("format: uuid - rejects non-UUID string", function() { + const schema = { + type: 'object', + properties: { + name: { type: 'string', format: 'uuid' } + }, + required: ['name'] + }; + expect(res.getBody()).to.not.have.jsonSchema(schema); + }); + + test("format: byte - rejects non-base64 string", function() { + const schema = { + type: 'object', + properties: { + email: { type: 'string', format: 'byte' } + }, + required: ['email'] + }; + expect(res.getBody()).to.not.have.jsonSchema(schema); + }); + + test("format: int32 - rejects value exceeding 32-bit range", function() { + const schema = { + type: 'object', + properties: { + int64Val: { type: 'integer', format: 'int32' } + }, + required: ['int64Val'] + }; + expect(res.getBody()).to.not.have.jsonSchema(schema); + }); + + test("format: float - rejects non-number field", function() { + const schema = { + type: 'object', + properties: { + name: { type: 'number', format: 'float' } + }, + required: ['name'] + }; + expect(res.getBody()).to.not.have.jsonSchema(schema); + }); + + test("format: duration - rejects non-duration string", function() { + const schema = { + type: 'object', + properties: { + name: { type: 'string', format: 'duration' } + }, + required: ['name'] + }; + expect(res.getBody()).to.not.have.jsonSchema(schema); + }); + + test("format: hostname - rejects invalid hostname", function() { + const schema = { + type: 'object', + properties: { + email: { type: 'string', format: 'hostname' } + }, + required: ['email'] + }; + expect(res.getBody()).to.not.have.jsonSchema(schema); + }); + + test("format: json-pointer - rejects non-pointer string", function() { + const schema = { + type: 'object', + properties: { + name: { type: 'string', format: 'json-pointer' } + }, + required: ['name'] + }; + expect(res.getBody()).to.not.have.jsonSchema(schema); + }); + + test("format: date - rejects non-date string", function() { + const schema = { + type: 'object', + properties: { + name: { type: 'string', format: 'date' } + }, + required: ['name'] + }; + expect(res.getBody()).to.not.have.jsonSchema(schema); + }); + + test("format: time - rejects non-time string", function() { + const schema = { + type: 'object', + properties: { + name: { type: 'string', format: 'time' } + }, + required: ['name'] + }; + expect(res.getBody()).to.not.have.jsonSchema(schema); + }); + + test("format: ipv6 - rejects non-IPv6 string", function() { + const schema = { + type: 'object', + properties: { + name: { type: 'string', format: 'ipv6' } + }, + required: ['name'] + }; + expect(res.getBody()).to.not.have.jsonSchema(schema); + }); + + test("format: int64 - rejects non-integer field", function() { + const schema = { + type: 'object', + properties: { + name: { type: 'integer', format: 'int64' } + }, + required: ['name'] + }; + expect(res.getBody()).to.not.have.jsonSchema(schema); + }); + + test("format: double - rejects non-number field", function() { + const schema = { + type: 'object', + properties: { + name: { type: 'number', format: 'double' } + }, + required: ['name'] + }; + expect(res.getBody()).to.not.have.jsonSchema(schema); + }); + + test("format: regex - rejects invalid regex pattern", function() { + const schema = { + type: 'object', + properties: { + invalidRegex: { type: 'string', format: 'regex' } + }, + required: ['invalidRegex'] + }; + expect(res.getBody()).to.not.have.jsonSchema(schema); + }); + + test("format: uri-reference - rejects invalid URI reference string", function() { + const schema = { + type: 'object', + properties: { + invalidRegex: { type: 'string', format: 'uri-reference' } + }, + required: ['invalidRegex'] + }; + expect(res.getBody()).to.not.have.jsonSchema(schema); + }); + + test("format: uri-template - rejects invalid URI template string", function() { + const schema = { + type: 'object', + properties: { + invalidUriTemplate: { type: 'string', format: 'uri-template' } + }, + required: ['invalidUriTemplate'] + }; + expect(res.getBody()).to.not.have.jsonSchema(schema); + }); + // --- Failure validations --- test("Type mismatch - schema expects array, response is object", function() {