diff --git a/.changeset/863-nullable-message.md b/.changeset/863-nullable-message.md new file mode 100644 index 000000000..9a9ebc081 --- /dev/null +++ b/.changeset/863-nullable-message.md @@ -0,0 +1,5 @@ +--- +'@asyncapi/parser': patch +--- + +Guard nullable `message` fields in v2 ruleset selectors to prevent validation crashes (issue #863). diff --git a/packages/parser/src/ruleset/v2/functions/messageExamples-spectral-rule-v2.ts b/packages/parser/src/ruleset/v2/functions/messageExamples-spectral-rule-v2.ts index 2d964762b..e41187032 100644 --- a/packages/parser/src/ruleset/v2/functions/messageExamples-spectral-rule-v2.ts +++ b/packages/parser/src/ruleset/v2/functions/messageExamples-spectral-rule-v2.ts @@ -17,9 +17,9 @@ export function asyncApi2MessageExamplesParserRule(parser: Parser): RuleDefiniti recommended: true, given: [ // messages - '$.channels.*.[publish,subscribe][?(@property === \'message\' && @.schemaFormat !== void 0)]', + '$.channels.*.[publish,subscribe][?(@property === \'message\' && !@null && @.schemaFormat !== void 0)]', '$.channels.*.[publish,subscribe].message.oneOf[?(!@null && @.schemaFormat !== void 0)]', - '$.components.channels.*.[publish,subscribe].message[?(@property === \'message\' && @.schemaFormat !== void 0)]', + '$.components.channels.*.[publish,subscribe].message[?(@property === \'message\' && !@null && @.schemaFormat !== void 0)]', '$.components.channels.*.[publish,subscribe].message.oneOf[?(!@null && @.schemaFormat !== void 0)]', '$.components.messages[?(!@null && @.schemaFormat !== void 0)]', // message traits diff --git a/packages/parser/src/ruleset/v2/ruleset.ts b/packages/parser/src/ruleset/v2/ruleset.ts index b03a7549f..2a0ff1c9b 100644 --- a/packages/parser/src/ruleset/v2/ruleset.ts +++ b/packages/parser/src/ruleset/v2/ruleset.ts @@ -123,9 +123,9 @@ export const v2CoreRuleset = { recommended: true, given: [ // messages - '$.channels.*.[publish,subscribe][?(@property === \'message\' && @.schemaFormat === void 0)]', + '$.channels.*.[publish,subscribe][?(@property === \'message\' && !@null && @.schemaFormat === void 0)]', '$.channels.*.[publish,subscribe].message.oneOf[?(!@null && @.schemaFormat === void 0)]', - '$.components.channels.*.[publish,subscribe][?(@property === \'message\' && @.schemaFormat === void 0)]', + '$.components.channels.*.[publish,subscribe][?(@property === \'message\' && !@null && @.schemaFormat === void 0)]', '$.components.channels.*.[publish,subscribe].message.oneOf[?(!@null && @.schemaFormat === void 0)]', '$.components.messages[?(!@null && @.schemaFormat === void 0)]', // message traits @@ -201,9 +201,9 @@ export const v2SchemasRuleset = (parser: Parser) => { severity: 'error', recommended: true, given: [ - '$.channels[*][publish,subscribe][?(@property === \'message\' && @.schemaFormat === void 0)].payload.default^', + '$.channels[*][publish,subscribe][?(@property === \'message\' && !@null && @.schemaFormat === void 0)].payload.default^', '$.channels.*.parameters.*.schema.default^', - '$.components.channels[*][publish,subscribe][?(@property === \'message\' && @.schemaFormat === void 0)].payload.default^', + '$.components.channels[*][publish,subscribe][?(@property === \'message\' && !@null && @.schemaFormat === void 0)].payload.default^', '$.components.channels.*.parameters.*.schema.default^', '$.components.schemas.*.default^', '$.components.parameters.*.schema.default^', @@ -223,9 +223,9 @@ export const v2SchemasRuleset = (parser: Parser) => { severity: 'error', recommended: true, given: [ - '$.channels[*][publish,subscribe][?(@property === \'message\' && @.schemaFormat === void 0)].payload.examples^', + '$.channels[*][publish,subscribe][?(@property === \'message\' && !@null && @.schemaFormat === void 0)].payload.examples^', '$.channels.*.parameters.*.schema.examples^', - '$.components.channels[*][publish,subscribe][?(@property === \'message\' && @.schemaFormat === void 0)].payload.examples^', + '$.components.channels[*][publish,subscribe][?(@property === \'message\' && !@null && @.schemaFormat === void 0)].payload.examples^', '$.components.channels.*.parameters.*.schema.examples^', '$.components.schemas.*.examples^', '$.components.parameters.*.schema.examples^', @@ -338,9 +338,9 @@ export const v2RecommendedRuleset = { recommended: true, formats: AsyncAPIFormats.filterByMajorVersions(['2']).excludeByVersions(['2.0.0', '2.1.0', '2.2.0', '2.3.0']).formats(), // message.messageId is available starting from v2.4. given: [ - '$.channels.*.[publish,subscribe][?(@property === "message" && @.oneOf == void 0)]', + '$.channels.*.[publish,subscribe][?(@property === "message" && !@null && @.oneOf == void 0)]', '$.channels.*.[publish,subscribe].message.oneOf.*', - '$.components.channels.*.[publish,subscribe][?(@property === "message" && @.oneOf == void 0)]', + '$.components.channels.*.[publish,subscribe][?(@property === "message" && !@null && @.oneOf == void 0)]', '$.components.channels.*.[publish,subscribe].message.oneOf.*', '$.components.messages.*', ], diff --git a/packages/parser/test/nullable-message-863.spec.ts b/packages/parser/test/nullable-message-863.spec.ts new file mode 100644 index 000000000..37bf290b9 --- /dev/null +++ b/packages/parser/test/nullable-message-863.spec.ts @@ -0,0 +1,66 @@ +import { Parser } from '../src/parser'; + +/** + * Regression test for issue #863: a payload property literally named `message` + * with a null example value crashed document validation. + * + * The v2 ruleset selected messages with JSONPath expressions of the form + * + * $.channels.*.[publish,subscribe][?(@property === 'message' && @.schemaFormat === void 0)] + * + * `@property === 'message'` also matches a *schema property* named `message`, + * not just a message object. When that property's example value is `null`, + * `@.schemaFormat` dereferences null and Spectral aborts the whole validation: + * + * jsonPath: Cannot read properties of null (reading 'schemaFormat') + * + * Adding `!@null` to those expressions skips null values before the member + * access. This test drives the public `parser.validate()` path with the exact + * document shape from the issue, so it fails on master with the thrown error and + * passes once the guards are in place. + */ +describe('issue #863: payload property named "message" with a null example', function () { + const parser = new Parser(); + + const documentRaw = { + asyncapi: '2.6.0', + info: { title: 'Nullable message property', version: '1.0.0' }, + channels: { + 'user/signup': { + publish: { + message: { + payload: { + type: 'object', + properties: { + // A schema property that happens to be named `message`. + message: { type: ['string', 'null'], maxLength: 50 }, + }, + required: ['message'], + }, + examples: [{ name: 'return', payload: { message: null } }], + }, + }, + }, + }, + }; + + it('validates without throwing on the null example value', async function () { + // The bug surfaced as a thrown Error, not as a diagnostic, so the assertion + // is that validate() resolves at all. Asserting only "no diagnostics" would + // not catch it: on master this rejects before diagnostics are produced. + await expect(parser.validate(documentRaw)).resolves.toBeDefined(); + }); + + it('does not report the jsonPath null-dereference as a diagnostic either', async function () { + const diagnostics = await parser.validate(documentRaw); + const nullDeref = diagnostics.filter((d: { message: string }) => + /Cannot read properties of null/.test(d.message), + ); + expect(nullDeref).toEqual([]); + }); + + it('still parses the document into a model', async function () { + const { document } = await parser.parse(documentRaw); + expect(document).toBeDefined(); + }); +});