Files
schema-tools/tests/schema/model.test.ts
T
2026-09-01 14:58:06 +02:00

381 lines
12 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
SCHEMA_LIMITS,
compareSchemas,
generateSample,
inspectWorkspace,
parseSchemaDocument,
validateJsonInstance,
} from "../../src/schema/model";
const entry = {
name: "person.schema.json",
language: "json-schema" as const,
source: JSON.stringify({
$schema: "https://json-schema.org/draft/2020-12/schema",
type: "object",
required: ["name", "address"],
properties: {
name: { type: "string", minLength: 1 },
address: { $ref: "defs/address.json#/$defs/address" },
},
additionalProperties: false,
}),
};
const support = {
name: "defs/address.json",
language: "json-schema" as const,
source: JSON.stringify({
$schema: "https://json-schema.org/draft/2020-12/schema",
$defs: {
address: {
type: "object",
required: ["city"],
properties: { city: { type: "string" } },
},
},
}),
};
describe("schema workspace", () => {
it("resolves local references, generates a sample, and validates instances", () => {
const workspace = inspectWorkspace([entry, support], entry.name);
expect(workspace.references).toEqual([
expect.objectContaining({ target: support.name, status: "resolved" }),
]);
expect(JSON.parse(generateSample(workspace).output)).toEqual({
name: "string",
address: { city: "string" },
});
expect(
validateJsonInstance(
workspace,
JSON.stringify({ name: "Ada", address: { city: "London" } }),
).valid,
).toBe(true);
const invalid = validateJsonInstance(
workspace,
JSON.stringify({ name: "Ada" }),
);
expect(invalid.valid).toBe(false);
expect(invalid.diagnostics[0]?.message).toContain("required");
});
it("bounds repeated large literal samples reached through local references", () => {
const properties = Object.fromEntries(
Array.from({ length: 100 }, (_, index) => [
`value${index}`,
{ $ref: "literal.json#/$defs/large" },
]),
);
const workspace = inspectWorkspace([
{
name: "root.json",
source: JSON.stringify({
type: "object",
required: Object.keys(properties),
properties,
}),
},
{
name: "literal.json",
source: JSON.stringify({
$defs: { large: { default: "x".repeat(100_000) } },
}),
},
]);
const sample = generateSample(workspace);
const values = Object.values(JSON.parse(sample.output) as object);
expect(sample.output.length).toBeLessThanOrEqual(
SCHEMA_LIMITS.sampleOutputChars,
);
expect(values).toHaveLength(100);
expect(
values.every(
(value) => String(value).length <= SCHEMA_LIMITS.sampleValueChars,
),
).toBe(true);
expect(sample.notices.join("\n")).toMatch(/truncated.*safety bound/iu);
});
it("refuses to claim a sample for the always-invalid false schema", () => {
const workspace = inspectWorkspace([
{ name: "impossible.json", source: "false" },
]);
expect(() => generateSample(workspace)).toThrow(/no valid sample/iu);
});
it("blocks remote and missing references without attempting resolution", () => {
const workspace = inspectWorkspace([
{
name: "unsafe.json",
source: JSON.stringify({ $ref: "https://example.test/schema.json" }),
},
{ name: "missing.json", source: JSON.stringify({ $ref: "other.json" }) },
]);
expect(workspace.references.map((item) => item.status)).toEqual([
"blocked",
"unresolved",
]);
expect(workspace.diagnostics).toEqual(
expect.arrayContaining([
expect.objectContaining({
level: "error",
message: expect.stringContaining("blocked"),
}),
expect.objectContaining({
level: "error",
message: expect.stringContaining("not supplied"),
}),
]),
);
});
it("refuses executable regex keywords in the bounded validator", () => {
const workspace = inspectWorkspace([
{
name: "pattern.json",
source: JSON.stringify({ type: "string", pattern: "^(a+)+$" }),
},
]);
const result = validateJsonInstance(workspace, JSON.stringify("aaaa"));
expect(result.valid).toBe(false);
expect(result.diagnostics[0]?.message).toMatch(/pattern.*not executed/iu);
});
it("also refuses regex and dynamic-reference keywords in supplied reference documents", () => {
const regexWorkspace = inspectWorkspace([
{ name: "root.json", source: JSON.stringify({ $ref: "defs.json" }) },
{
name: "defs.json",
source: JSON.stringify({ type: "string", pattern: "^(a+)+$" }),
},
]);
expect(
validateJsonInstance(regexWorkspace, JSON.stringify("aaaa")).valid,
).toBe(false);
const dynamicWorkspace = inspectWorkspace([
{
name: "dynamic.json",
source: JSON.stringify({ $dynamicRef: "#node" }),
},
]);
expect(
validateJsonInstance(dynamicWorkspace, JSON.stringify({})).diagnostics[0]
?.message,
).toContain("$dynamicRef");
});
it("validates the documented 2020-12 object, array, dependency, and combiner subset", () => {
const workspace = inspectWorkspace([
{
name: "bounded.json",
source: JSON.stringify({
$schema: "https://json-schema.org/draft/2020-12/schema",
type: "object",
required: ["kind", "values"],
properties: {
kind: { enum: ["measurement"] },
note: { type: "string", minLength: 2, maxLength: 8 },
values: {
type: "array",
prefixItems: [{ type: "integer" }],
items: { type: "number", minimum: 0 },
contains: { const: 2 },
minContains: 1,
uniqueItems: true,
},
},
dependentRequired: { note: ["kind"] },
allOf: [{ minProperties: 2 }],
additionalProperties: false,
}),
},
]);
expect(
validateJsonInstance(
workspace,
JSON.stringify({ kind: "measurement", note: "ok", values: [1, 2] }),
).valid,
).toBe(true);
const invalid = validateJsonInstance(
workspace,
JSON.stringify({ kind: "measurement", values: [1, 1], extra: true }),
);
expect(invalid.valid).toBe(false);
expect(invalid.diagnostics.map((item) => item.message).join("\n")).toMatch(
/uniqueItems|contains|false schema/u,
);
});
it("supports Draft 6 local files even when root identifiers are remote-looking", () => {
const workspace = inspectWorkspace([
{
name: "root.json",
source: JSON.stringify({
$schema: "http://json-schema.org/draft-06/schema#",
$id: "https://schemas.example.test/root.json",
$ref: "defs.json#/$defs/value",
}),
},
{
name: "defs.json",
source: JSON.stringify({
$schema: "http://json-schema.org/draft-06/schema#",
$defs: { value: { type: "integer", minimum: 1 } },
}),
},
]);
expect(validateJsonInstance(workspace, "2").valid).toBe(true);
expect(validateJsonInstance(workspace, "0").valid).toBe(false);
});
it("rejects active XML constructs before structural inspection", () => {
expect(() =>
parseSchemaDocument({
name: "unsafe.xsd",
source: `<!DOCTYPE schema [<!ENTITY x "value">]><schema xmlns="http://www.w3.org/2001/XMLSchema"/>`,
}),
).toThrow(/DTD and entity/u);
});
});
describe("schema languages", () => {
it("inventories XSD and generates a clearly heuristic XML instance", () => {
const workspace = inspectWorkspace([
{
name: "person.xsd",
source: `<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"><xs:element name="person"><xs:complexType><xs:sequence><xs:element name="name" type="xs:string"/><xs:element name="age" type="xs:integer"/></xs:sequence></xs:complexType></xs:element></xs:schema>`,
},
]);
expect(workspace.documents.get("person.xsd")?.language).toBe("xsd");
expect(
workspace.diagnostics.some((item) =>
item.message.includes("not provided"),
),
).toBe(true);
const sample = generateSample(workspace);
expect(sample.output).toContain("<person>");
expect(sample.output).toContain("<name>string</name>");
expect(sample.notices[0]).toContain("Heuristic XSD");
});
it("inventories Relax NG and Schematron without executing expressions", () => {
const rng = inspectWorkspace([
{
name: "note.rng",
source: `<grammar xmlns="http://relaxng.org/ns/structure/1.0"><start><element name="note"><element name="body"><text/></element></element></start></grammar>`,
},
]);
expect(generateSample(rng).output).toContain(
"<note><body>string</body></note>",
);
const sch = inspectWorkspace([
{
name: "rules.sch",
source: `<schema xmlns="http://purl.oclc.org/dsdl/schematron"><pattern id="required"><rule context="person"><assert test="name">A name is required.</assert></rule></pattern></schema>`,
},
]);
expect(sch.documents.get("rules.sch")?.language).toBe("schematron");
expect(
sch.diagnostics.some(
(item) =>
item.message.includes("never executed") ||
item.message.includes("not executed"),
),
).toBe(true);
});
it("parses OpenAPI YAML, inventories operations, and derives component samples", () => {
const workspace = inspectWorkspace([
{
name: "openapi.yaml",
source: `openapi: 3.1.0
info:
title: Local API
version: 1.0.0
paths:
/people:
get:
responses:
"200": { description: ok }
components:
schemas:
Person:
type: object
required: [name]
properties:
name: { type: string }
`,
},
]);
const document = workspace.documents.get("openapi.yaml")!;
expect(document.language).toBe("openapi");
expect(document.summary).toContainEqual({
label: "Operations",
value: "1",
});
expect(JSON.parse(generateSample(workspace).output)).toEqual({
name: "string",
});
});
});
describe("conservative comparison", () => {
it("signals newly required fields and removed enum values", () => {
const changes = compareSchemas(
{
name: "before.json",
source: JSON.stringify({
type: "object",
properties: { state: { enum: ["on", "off"] } },
}),
},
{
name: "after.json",
source: JSON.stringify({
type: "object",
required: ["state"],
properties: { state: { enum: ["on"] } },
}),
},
);
expect(changes).toEqual(
expect.arrayContaining([
expect.objectContaining({
level: "incompatible",
message: expect.stringContaining("required"),
}),
expect.objectContaining({
level: "incompatible",
path: "/properties/state/enum",
message: expect.stringContaining("removed"),
}),
]),
);
});
it("signals removed OpenAPI operations", () => {
const base = {
openapi: "3.1.0",
info: { title: "Example", version: "1" },
};
const changes = compareSchemas(
{
name: "before.json",
source: JSON.stringify({
...base,
paths: {
"/items": { get: { responses: { "200": { description: "ok" } } } },
},
}),
},
{ name: "after.json", source: JSON.stringify({ ...base, paths: {} }) },
);
expect(changes).toContainEqual(
expect.objectContaining({ level: "incompatible", path: "GET /items" }),
);
});
});