ExodusMovement / schemasafe

A reasonably safe JSON Schema validator with draft-04/06/07/2019-09/2020-12 support.
https://npmjs.com/@exodus/schemasafe
MIT License
161 stars 12 forks source link

Validator adds `oneOf` error to `errors` array even when schema is valid #185

Open mdmower-csnw opened 3 months ago

mdmower-csnw commented 3 months ago

When validating a schema that includes oneOf with a ref, an error is pushed to the final validator.errors array even if the oneOf schema passes validation. The overall validation result (true/false) is correct, but when validation fails, one of the errors is not correct.

Steps to reproduce

sample.schema.json

{
  "type": "object",
  "$defs": {
    "level": {
      "enum": ["error", "info"]
    }
  },
  "properties": {
    "some_name": { "type": "string" },
    "some_level": {
      "oneOf": [{ "const": null }, { "$ref": "#/$defs/level" }]
    }
  }
}

sample.json

{
  "some_name": 3,
  "some_level": null
}

main.js

import { validator } from "@exodus/schemasafe";
import { readFileSync } from "fs";

const schema = JSON.parse(readFileSync("sample.schema.json", "utf-8"));
const sample = JSON.parse(readFileSync("sample.json", "utf-8"));
const validate = validator(schema, { includeErrors: true, allErrors: true });
if (!validate(sample)) {
  console.error(validate.errors);
} else {
  console.log("config ok");
}

Output

some_level is reported to have an error even though it doesn't.

[
  {
    keywordLocation: '#/properties/some_name/type',
    instanceLocation: '#/some_name'
  },
  {
    keywordLocation: '#/properties/some_level/oneOf/1/$ref/enum',
    instanceLocation: '#/some_level'
  }
]

Notes

When I inspect the compiled validator, it looks like the error from the failed $ref is pushed onto validate.errors instead of being held in suberr1 to determine later whether it should land in validate.errors depending on the overall oneOf status. See comments in code snippet below:

    if ("some_level" in data && hasOwn(data, "some_level")) {
      let passes0 = 0
      let suberr0 = null
      const sub0 = (() => {
        let errorCount = 0
        if (!(data.some_level === null)) {
          if (suberr0 === null) suberr0 = []
          suberr0.push({ keywordLocation: "#/properties/some_level/oneOf/0/const", instanceLocation: "#/some_level" })
          errorCount++
        }
        return errorCount === 0
      })()
      if (sub0) passes0++
      const sub1 = (() => {
        let errorCount = 0
        const err0 = validate.errors
        const res0 = ref1(data.some_level)
        const suberr1 = ref1.errors
        validate.errors = err0
        if (!res0) {
          if (validate.errors === null) validate.errors = []
/** START: Possible reason for issue */
          validate.errors.push(...suberr1.map(e => errorMerge(e, "#/properties/some_level/oneOf/1/$ref", "#/some_level")))
/** END: Possible reason for issue */
          errorCount++
        }
        return errorCount === 0
      })()
      if (sub1) passes0++
      if (passes0 !== 1) {
        if (validate.errors === null) validate.errors = []
        validate.errors.push({ keywordLocation: "#/properties/some_level/oneOf", instanceLocation: "#/some_level" })
        errorCount++
      }
      if (passes0 === 0) {
        if (suberr0) validate.errors.push(...suberr0)
      }
    }