feat!: self-validating meta-schema for putSchema
Replace bootstrap payload with a JSON Schema meta-schema describing our supported schema subset. putSchema now validates input schemas against the meta-schema before storing, rejecting invalid schemas with SchemaValidationError. - bootstrap.ts: self-describing meta-schema (type, properties, required, additionalProperties, anyOf, items, format, title, enum, const, description) - schema.ts: recursive isValidSchema(), SchemaValidationError class - index.ts: export SchemaValidationError - package.json: bump 0.4.0 → 1.0.0 (breaking change) BREAKING CHANGE: meta-schema hash changed, old CAS data invalid. Fixes #15
This commit is contained in:
@@ -14,18 +14,18 @@
|
||||
},
|
||||
"packages/cli-json-cas": {
|
||||
"name": "@uncaged/cli-json-cas",
|
||||
"version": "0.1.0",
|
||||
"version": "0.3.0",
|
||||
"bin": {
|
||||
"json-cas": "./src/index.ts",
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/json-cas": "workspace:^",
|
||||
"@uncaged/json-cas-fs": "workspace:^",
|
||||
"@uncaged/json-cas": "^0.3.0",
|
||||
"@uncaged/json-cas-fs": "^0.3.0",
|
||||
},
|
||||
},
|
||||
"packages/json-cas": {
|
||||
"name": "@uncaged/json-cas",
|
||||
"version": "0.1.0",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"ajv": "^8.20.0",
|
||||
"cborg": "^4.2.3",
|
||||
@@ -34,17 +34,17 @@
|
||||
},
|
||||
"packages/json-cas-fs": {
|
||||
"name": "@uncaged/json-cas-fs",
|
||||
"version": "0.1.0",
|
||||
"version": "0.4.1",
|
||||
"dependencies": {
|
||||
"@uncaged/json-cas": "workspace:^",
|
||||
"@uncaged/json-cas": "^0.4.0",
|
||||
"cborg": "^4.2.3",
|
||||
},
|
||||
},
|
||||
"packages/json-cas-workflow": {
|
||||
"name": "@uncaged/json-cas-workflow",
|
||||
"version": "0.1.0",
|
||||
"version": "0.4.1",
|
||||
"dependencies": {
|
||||
"@uncaged/json-cas": "workspace:^",
|
||||
"@uncaged/json-cas": "^0.4.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -311,6 +311,14 @@
|
||||
|
||||
"@manypkg/get-packages/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="],
|
||||
|
||||
"@uncaged/cli-json-cas/@uncaged/json-cas": ["@uncaged/json-cas@0.3.0", "", { "dependencies": { "ajv": "^8.20.0", "cborg": "^4.2.3", "xxhash-wasm": "^1.1.0" } }, "sha512-LR8Uow7cBdvH+6y9mh9Fd7zDs8fWhfhpVZVsexfdK1KKnGaR7WvukuhBj6r0FbOZ78j7jhjeEfzsUXR2cHELwQ=="],
|
||||
|
||||
"@uncaged/cli-json-cas/@uncaged/json-cas-fs": ["@uncaged/json-cas-fs@0.3.0", "", { "dependencies": { "@uncaged/json-cas": "^0.3.0", "cborg": "^4.2.3" } }, "sha512-shelE7PXtBAsJtJ2Axo5yBScErV/kgi2OUiIUXnEP8BL6L760BRz9W6PDb6jHVKrWOh1HIdYUYODYaHRWY0UxA=="],
|
||||
|
||||
"@uncaged/json-cas-fs/@uncaged/json-cas": ["@uncaged/json-cas@0.4.0", "", { "dependencies": { "ajv": "^8.20.0", "cborg": "^4.2.3", "xxhash-wasm": "^1.1.0" } }, "sha512-DQ65BiMwPeitxEmMYEyQoVO99GQeOBMv0Lgc/ZZkUCKFpTkxZ0tngDD1NsF7suLkIOLxnuBgUKon7t7Yc8eWgw=="],
|
||||
|
||||
"@uncaged/json-cas-workflow/@uncaged/json-cas": ["@uncaged/json-cas@0.4.0", "", { "dependencies": { "ajv": "^8.20.0", "cborg": "^4.2.3", "xxhash-wasm": "^1.1.0" } }, "sha512-DQ65BiMwPeitxEmMYEyQoVO99GQeOBMv0Lgc/ZZkUCKFpTkxZ0tngDD1NsF7suLkIOLxnuBgUKon7t7Yc8eWgw=="],
|
||||
|
||||
"read-yaml-file/js-yaml": ["js-yaml@3.14.2", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg=="],
|
||||
|
||||
"read-yaml-file/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@uncaged/json-cas",
|
||||
"version": "0.4.0",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
|
||||
@@ -4,27 +4,56 @@ import {
|
||||
} from "./bootstrap-capable.js";
|
||||
import type { Hash, Store } from "./types.js";
|
||||
|
||||
const JSON_SCHEMA_TYPES = [
|
||||
"string",
|
||||
"number",
|
||||
"integer",
|
||||
"boolean",
|
||||
"object",
|
||||
"array",
|
||||
"null",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* The meta-schema seed payload: describes the structure of every CAS node.
|
||||
* This is the root type from which all other type nodes derive.
|
||||
* Self-describing JSON Schema meta-schema for the supported schema subset.
|
||||
* Stored as the bootstrap node's payload; its hash equals the node's type field.
|
||||
*/
|
||||
const BOOTSTRAP_PAYLOAD = {
|
||||
description: "json-cas meta-schema seed",
|
||||
hashAlgorithm: "xxh64",
|
||||
hashEncoding: "crockford-base32-13",
|
||||
nodeSchema: {
|
||||
$schema: "https://json-schema.org/draft/2020-12/schema",
|
||||
type: "object",
|
||||
required: ["type", "payload", "timestamp"],
|
||||
properties: {
|
||||
type: { type: "string", description: "Hash of the type descriptor node (or self for bootstrap)" },
|
||||
payload: { description: "Arbitrary data" },
|
||||
timestamp: { type: "number", description: "Unix epoch ms when the node was first stored" },
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
description: "json-cas JSON Schema meta-schema",
|
||||
properties: {
|
||||
type: {
|
||||
anyOf: [
|
||||
{ type: "string", enum: [...JSON_SCHEMA_TYPES] },
|
||||
{
|
||||
type: "array",
|
||||
items: { type: "string", enum: [...JSON_SCHEMA_TYPES] },
|
||||
},
|
||||
],
|
||||
},
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
type: "object",
|
||||
additionalProperties: { type: "object", additionalProperties: false },
|
||||
},
|
||||
required: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
},
|
||||
additionalProperties: {
|
||||
anyOf: [{ type: "boolean" }, { type: "object", additionalProperties: false }],
|
||||
},
|
||||
anyOf: {
|
||||
type: "array",
|
||||
items: { type: "object", additionalProperties: false },
|
||||
},
|
||||
items: { type: "object", additionalProperties: false },
|
||||
format: { type: "string" },
|
||||
title: { type: "string" },
|
||||
enum: { type: "array" },
|
||||
const: {},
|
||||
description: { type: "string" },
|
||||
},
|
||||
payloadEncoding: "cbor-rfc8949-deterministic",
|
||||
version: "1",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,7 +4,14 @@ export type { BootstrapCapableStore } from "./bootstrap-capable.js";
|
||||
export { cborEncode } from "./cbor.js";
|
||||
export { computeHash, computeSelfHash } from "./hash.js";
|
||||
export type { JSONSchema } from "./schema.js";
|
||||
export { getSchema, putSchema, refs, validate, walk } from "./schema.js";
|
||||
export {
|
||||
getSchema,
|
||||
putSchema,
|
||||
refs,
|
||||
SchemaValidationError,
|
||||
validate,
|
||||
walk,
|
||||
} from "./schema.js";
|
||||
export { createMemoryStore } from "./store.js";
|
||||
export type { CasNode, Hash, Store } from "./types.js";
|
||||
export { verify } from "./verify.js";
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { BOOTSTRAP_STORE } from "./bootstrap-capable.js";
|
||||
import type { BootstrapCapableStore } from "./bootstrap-capable.js";
|
||||
import { createMemoryStore } from "./store.js";
|
||||
import type { CasNode, Hash } from "./types.js";
|
||||
|
||||
/** In-memory store wrapper used by schema validation tests. */
|
||||
export class MemStore implements BootstrapCapableStore {
|
||||
readonly #inner: BootstrapCapableStore;
|
||||
|
||||
constructor() {
|
||||
this.#inner = createMemoryStore();
|
||||
}
|
||||
|
||||
put(typeHash: Hash, payload: unknown): Promise<Hash> {
|
||||
return this.#inner.put(typeHash, payload);
|
||||
}
|
||||
|
||||
get(hash: Hash): CasNode | null {
|
||||
return this.#inner.get(hash);
|
||||
}
|
||||
|
||||
has(hash: Hash): boolean {
|
||||
return this.#inner.has(hash);
|
||||
}
|
||||
|
||||
listByType(typeHash: Hash): Hash[] {
|
||||
return this.#inner.listByType(typeHash);
|
||||
}
|
||||
|
||||
[BOOTSTRAP_STORE](payload: unknown): Promise<Hash> {
|
||||
return this.#inner[BOOTSTRAP_STORE](payload);
|
||||
}
|
||||
}
|
||||
@@ -12,9 +12,121 @@ import type { CasNode, Hash, Store } from "./types.js";
|
||||
|
||||
export type JSONSchema = Record<string, unknown>;
|
||||
|
||||
export class SchemaValidationError extends Error {
|
||||
override readonly name = "SchemaValidationError";
|
||||
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
const ajv = new Ajv();
|
||||
ajv.addFormat("cas_ref", /^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||
|
||||
const ALLOWED_SCHEMA_KEYS = new Set([
|
||||
"type",
|
||||
"properties",
|
||||
"required",
|
||||
"additionalProperties",
|
||||
"anyOf",
|
||||
"items",
|
||||
"format",
|
||||
"title",
|
||||
"enum",
|
||||
"const",
|
||||
"description",
|
||||
]);
|
||||
|
||||
const JSON_SCHEMA_TYPES = new Set([
|
||||
"string",
|
||||
"number",
|
||||
"integer",
|
||||
"boolean",
|
||||
"object",
|
||||
"array",
|
||||
"null",
|
||||
]);
|
||||
|
||||
function isValidTypeValue(type: unknown): boolean {
|
||||
if (typeof type === "string") {
|
||||
return JSON_SCHEMA_TYPES.has(type);
|
||||
}
|
||||
if (Array.isArray(type)) {
|
||||
if (type.length === 0) return false;
|
||||
return type.every(
|
||||
(entry) => typeof entry === "string" && JSON_SCHEMA_TYPES.has(entry),
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isValidSchema(value: unknown): boolean {
|
||||
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const schema = value as JSONSchema;
|
||||
for (const key of Object.keys(schema)) {
|
||||
if (!ALLOWED_SCHEMA_KEYS.has(key)) return false;
|
||||
}
|
||||
|
||||
if ("type" in schema && !isValidTypeValue(schema.type)) return false;
|
||||
|
||||
if ("properties" in schema) {
|
||||
const properties = schema.properties;
|
||||
if (
|
||||
properties === null ||
|
||||
typeof properties !== "object" ||
|
||||
Array.isArray(properties)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
for (const nested of Object.values(properties as Record<string, unknown>)) {
|
||||
if (!isValidSchema(nested)) return false;
|
||||
}
|
||||
}
|
||||
|
||||
if ("required" in schema) {
|
||||
if (!Array.isArray(schema.required)) return false;
|
||||
for (const entry of schema.required) {
|
||||
if (typeof entry !== "string") return false;
|
||||
}
|
||||
}
|
||||
|
||||
if ("additionalProperties" in schema) {
|
||||
const additionalProperties = schema.additionalProperties;
|
||||
if (typeof additionalProperties === "boolean") {
|
||||
// allowed
|
||||
} else if (!isValidSchema(additionalProperties)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if ("anyOf" in schema) {
|
||||
if (!Array.isArray(schema.anyOf) || schema.anyOf.length === 0) return false;
|
||||
for (const entry of schema.anyOf) {
|
||||
if (!isValidSchema(entry)) return false;
|
||||
}
|
||||
}
|
||||
|
||||
if ("items" in schema && !isValidSchema(schema.items)) return false;
|
||||
if ("format" in schema && typeof schema.format !== "string") return false;
|
||||
if ("title" in schema && typeof schema.title !== "string") return false;
|
||||
if ("description" in schema && typeof schema.description !== "string") {
|
||||
return false;
|
||||
}
|
||||
if ("enum" in schema) {
|
||||
if (!Array.isArray(schema.enum) || schema.enum.length === 0) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function isMetaSchemaNode(store: Store, node: CasNode): boolean {
|
||||
const schema = getSchema(store, node.type);
|
||||
return schema !== null && schema === node.payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a JSON Schema as a CAS node typed by the meta-schema hash.
|
||||
* The returned hash becomes the typeHash for nodes that conform to this schema.
|
||||
@@ -24,6 +136,11 @@ export async function putSchema(
|
||||
jsonSchema: JSONSchema,
|
||||
): Promise<Hash> {
|
||||
const metaHash = await bootstrap(store);
|
||||
if (!isValidSchema(jsonSchema)) {
|
||||
throw new SchemaValidationError(
|
||||
"Invalid schema: input does not conform to the json-cas JSON Schema meta-schema",
|
||||
);
|
||||
}
|
||||
return store.put(metaHash, jsonSchema);
|
||||
}
|
||||
|
||||
@@ -44,6 +161,9 @@ export function getSchema(store: Store, typeHash: Hash): JSONSchema | null {
|
||||
export function validate(store: Store, node: CasNode): boolean {
|
||||
const schema = getSchema(store, node.type);
|
||||
if (schema === null) return false;
|
||||
if (isMetaSchemaNode(store, node)) {
|
||||
return isValidSchema(node.payload);
|
||||
}
|
||||
return ajv.validate(
|
||||
schema as Parameters<typeof ajv.validate>[0],
|
||||
node.payload,
|
||||
|
||||
@@ -0,0 +1,696 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { bootstrap } from "../src/bootstrap.js";
|
||||
import {
|
||||
putSchema,
|
||||
getSchema,
|
||||
validate,
|
||||
refs,
|
||||
walk,
|
||||
SchemaValidationError,
|
||||
} from "../src/schema.js";
|
||||
import { MemStore } from "../src/mem-store.js";
|
||||
import type { CasNode } from "../src/types.js";
|
||||
|
||||
describe("Test Suite 1: Meta-Schema Structure and Self-Validation", () => {
|
||||
test("1.1: Meta-schema is a valid JSON Schema", async () => {
|
||||
const store = new MemStore();
|
||||
const metaHash = await bootstrap(store);
|
||||
const metaNode = store.get(metaHash);
|
||||
|
||||
expect(metaNode).not.toBeNull();
|
||||
expect(typeof metaNode?.payload).toBe("object");
|
||||
expect(metaNode?.payload).toHaveProperty("type");
|
||||
});
|
||||
|
||||
test("1.2: Meta-schema self-validates", async () => {
|
||||
const store = new MemStore();
|
||||
const metaHash = await bootstrap(store);
|
||||
const metaNode = store.get(metaHash);
|
||||
|
||||
expect(metaNode).not.toBeNull();
|
||||
expect(validate(store, metaNode as CasNode)).toBe(true);
|
||||
});
|
||||
|
||||
test("1.3: Meta-schema defines all supported keywords", async () => {
|
||||
const store = new MemStore();
|
||||
const metaHash = await bootstrap(store);
|
||||
const metaSchema = getSchema(store, metaHash);
|
||||
|
||||
expect(metaSchema).not.toBeNull();
|
||||
const properties = (metaSchema?.properties as Record<string, unknown>) || {};
|
||||
|
||||
// Check that all supported keywords are defined
|
||||
expect(properties).toHaveProperty("type");
|
||||
expect(properties).toHaveProperty("properties");
|
||||
expect(properties).toHaveProperty("required");
|
||||
expect(properties).toHaveProperty("additionalProperties");
|
||||
expect(properties).toHaveProperty("anyOf");
|
||||
expect(properties).toHaveProperty("items");
|
||||
expect(properties).toHaveProperty("format");
|
||||
expect(properties).toHaveProperty("title");
|
||||
expect(properties).toHaveProperty("enum");
|
||||
expect(properties).toHaveProperty("const");
|
||||
expect(properties).toHaveProperty("description");
|
||||
});
|
||||
|
||||
test("1.4: Meta-schema does not include unsupported keywords", async () => {
|
||||
const store = new MemStore();
|
||||
const metaHash = await bootstrap(store);
|
||||
const metaSchema = getSchema(store, metaHash);
|
||||
|
||||
expect(metaSchema).not.toBeNull();
|
||||
const properties = (metaSchema?.properties as Record<string, unknown>) || {};
|
||||
|
||||
// Unsupported keywords should not be in properties
|
||||
expect(properties).not.toHaveProperty("$ref");
|
||||
expect(properties).not.toHaveProperty("$id");
|
||||
expect(properties).not.toHaveProperty("$defs");
|
||||
expect(properties).not.toHaveProperty("allOf");
|
||||
expect(properties).not.toHaveProperty("oneOf");
|
||||
expect(properties).not.toHaveProperty("not");
|
||||
});
|
||||
|
||||
test("1.5: Meta-schema node type equals its own hash", async () => {
|
||||
const store = new MemStore();
|
||||
const metaHash = await bootstrap(store);
|
||||
const metaNode = store.get(metaHash);
|
||||
|
||||
expect(metaNode).not.toBeNull();
|
||||
expect(metaNode?.type).toBe(metaHash);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test Suite 2: putSchema Validation - Valid Schemas", () => {
|
||||
test("2.1: Accept minimal valid schema (empty object)", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
const hash = await putSchema(store, {});
|
||||
expect(hash).toBeTruthy();
|
||||
});
|
||||
|
||||
test("2.2: Accept schema with type constraint", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
const hash = await putSchema(store, { type: "string" });
|
||||
expect(hash).toBeTruthy();
|
||||
});
|
||||
|
||||
test("2.3: Accept schema with properties", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
const hash = await putSchema(store, {
|
||||
type: "object",
|
||||
properties: { name: { type: "string" } },
|
||||
});
|
||||
expect(hash).toBeTruthy();
|
||||
});
|
||||
|
||||
test("2.4: Accept schema with required fields", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
const hash = await putSchema(store, {
|
||||
type: "object",
|
||||
required: ["id"],
|
||||
properties: { id: { type: "string" } },
|
||||
});
|
||||
expect(hash).toBeTruthy();
|
||||
});
|
||||
|
||||
test("2.5: Accept schema with additionalProperties = false", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
const hash = await putSchema(store, {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
});
|
||||
expect(hash).toBeTruthy();
|
||||
});
|
||||
|
||||
test("2.6: Accept schema with additionalProperties = schema", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
const hash = await putSchema(store, {
|
||||
type: "object",
|
||||
additionalProperties: { type: "string" },
|
||||
});
|
||||
expect(hash).toBeTruthy();
|
||||
});
|
||||
|
||||
test("2.7: Accept schema with anyOf", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
const hash = await putSchema(store, {
|
||||
anyOf: [{ type: "string" }, { type: "null" }],
|
||||
});
|
||||
expect(hash).toBeTruthy();
|
||||
});
|
||||
|
||||
test("2.8: Accept schema with array items", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
const hash = await putSchema(store, {
|
||||
type: "array",
|
||||
items: { type: "number" },
|
||||
});
|
||||
expect(hash).toBeTruthy();
|
||||
});
|
||||
|
||||
test("2.9: Accept schema with format constraint", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
const hash = await putSchema(store, {
|
||||
type: "string",
|
||||
format: "cas_ref",
|
||||
});
|
||||
expect(hash).toBeTruthy();
|
||||
});
|
||||
|
||||
test("2.10: Accept schema with enum", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
const hash = await putSchema(store, {
|
||||
type: "string",
|
||||
enum: ["red", "green", "blue"],
|
||||
});
|
||||
expect(hash).toBeTruthy();
|
||||
});
|
||||
|
||||
test("2.11: Accept schema with const", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
const hash = await putSchema(store, { const: "FIXED_VALUE" });
|
||||
expect(hash).toBeTruthy();
|
||||
});
|
||||
|
||||
test("2.12: Accept schema with title and description", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
const hash = await putSchema(store, {
|
||||
type: "string",
|
||||
title: "User Name",
|
||||
description: "The user's full name",
|
||||
});
|
||||
expect(hash).toBeTruthy();
|
||||
});
|
||||
|
||||
test("2.13: Accept complex nested schema", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
const hash = await putSchema(store, {
|
||||
type: "object",
|
||||
required: ["type", "payload"],
|
||||
properties: {
|
||||
type: { type: "string", format: "cas_ref" },
|
||||
payload: {
|
||||
anyOf: [{ type: "object" }, { type: "null" }],
|
||||
},
|
||||
refs: {
|
||||
type: "array",
|
||||
items: { type: "string", format: "cas_ref" },
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
});
|
||||
expect(hash).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test Suite 3: putSchema Validation - Invalid Schemas", () => {
|
||||
test("3.1: Reject schema with invalid type value", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
expect(async () => await putSchema(store, { type: "garbage" })).toThrow();
|
||||
});
|
||||
|
||||
test("3.2: Reject schema with type as number", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
expect(async () => await putSchema(store, { type: 123 } as any)).toThrow();
|
||||
});
|
||||
|
||||
test("3.3: Reject schema with properties not an object", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
expect(
|
||||
async () =>
|
||||
await putSchema(store, {
|
||||
type: "object",
|
||||
properties: "not-an-object",
|
||||
} as any)
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
test("3.4: Reject schema with required not an array", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
expect(
|
||||
async () =>
|
||||
await putSchema(store, {
|
||||
type: "object",
|
||||
required: "name",
|
||||
} as any)
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
test("3.5: Reject schema with required containing non-strings", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
expect(
|
||||
async () =>
|
||||
await putSchema(store, {
|
||||
type: "object",
|
||||
required: ["name", 123, true],
|
||||
} as any)
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
test("3.6: Reject schema with additionalProperties as string", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
expect(
|
||||
async () =>
|
||||
await putSchema(store, {
|
||||
type: "object",
|
||||
additionalProperties: "yes",
|
||||
} as any)
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
test("3.7: Reject schema with anyOf not an array", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
expect(
|
||||
async () =>
|
||||
await putSchema(store, { anyOf: { type: "string" } } as any)
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
test("3.8: Reject schema with empty anyOf array", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
expect(async () => await putSchema(store, { anyOf: [] })).toThrow();
|
||||
});
|
||||
|
||||
test("3.9: Reject schema with items not an object", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
expect(
|
||||
async () =>
|
||||
await putSchema(store, { type: "array", items: "string" } as any)
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
test("3.10: Reject schema with format not a string", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
expect(
|
||||
async () =>
|
||||
await putSchema(store, { type: "string", format: 123 } as any)
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
test("3.11: Reject schema with enum not an array", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
expect(
|
||||
async () =>
|
||||
await putSchema(store, { type: "string", enum: "red" } as any)
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
test("3.12: Reject schema with empty enum array", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
expect(
|
||||
async () => await putSchema(store, { type: "string", enum: [] })
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
test("3.13: Reject schema with title not a string", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
expect(
|
||||
async () =>
|
||||
await putSchema(store, { type: "string", title: 123 } as any)
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
test("3.14: Reject schema with description not a string", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
expect(
|
||||
async () =>
|
||||
await putSchema(store, {
|
||||
type: "string",
|
||||
description: ["not a string"],
|
||||
} as any)
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
test("3.15: Reject schema with unsupported $ref keyword", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
expect(
|
||||
async () =>
|
||||
await putSchema(store, { $ref: "#/definitions/user" } as any)
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
test("3.16: Reject completely invalid data (non-object)", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
expect(
|
||||
async () => await putSchema(store, "not-a-schema" as any)
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
test("3.17: Reject nested invalid schema in properties", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
expect(
|
||||
async () =>
|
||||
await putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "invalid-type" },
|
||||
},
|
||||
} as any)
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test Suite 4: Error Messages and Debugging", () => {
|
||||
test("4.1: Error includes schema validation details", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
try {
|
||||
await putSchema(store, { type: 123 } as any);
|
||||
expect(true).toBe(false); // Should not reach here
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(SchemaValidationError);
|
||||
expect((error as Error).message).toContain("Invalid schema");
|
||||
}
|
||||
});
|
||||
|
||||
test("4.2: Error distinguishes schema validation from data validation", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
try {
|
||||
await putSchema(store, { type: "invalid-type" } as any);
|
||||
expect(true).toBe(false); // Should not reach here
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(SchemaValidationError);
|
||||
expect((error as Error).message.toLowerCase()).toContain("schema");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test Suite 5: Backward Compatibility and Migration", () => {
|
||||
test("5.1: Bootstrap hash changes (breaking change)", async () => {
|
||||
// This is a documentation test - the old hash was different
|
||||
const store = new MemStore();
|
||||
const newMetaHash = await bootstrap(store);
|
||||
|
||||
// The new hash should be different from the old system metadata hash
|
||||
// We just verify it's a valid hash format
|
||||
expect(newMetaHash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||
});
|
||||
|
||||
test("5.2: Existing tests compatibility", async () => {
|
||||
// This test ensures our changes don't break existing valid schema usage
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
|
||||
// This is the kind of schema that existed before
|
||||
const schemaHash = await putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(schemaHash).toBeTruthy();
|
||||
});
|
||||
|
||||
test("5.3: Data nodes with valid schemas still validate", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
|
||||
const schemaHash = await putSchema(store, {
|
||||
type: "object",
|
||||
required: ["name"],
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
},
|
||||
});
|
||||
|
||||
const dataNode = store.get(
|
||||
await store.put(schemaHash, { name: "test" })
|
||||
);
|
||||
|
||||
expect(dataNode).not.toBeNull();
|
||||
expect(validate(store, dataNode as CasNode)).toBe(true);
|
||||
});
|
||||
|
||||
test("5.4: Invalid data still fails validation", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
|
||||
const schemaHash = await putSchema(store, {
|
||||
type: "object",
|
||||
required: ["name"],
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
},
|
||||
});
|
||||
|
||||
const dataNode = store.get(
|
||||
await store.put(schemaHash, { name: 123 }) // wrong type
|
||||
);
|
||||
|
||||
expect(dataNode).not.toBeNull();
|
||||
expect(validate(store, dataNode as CasNode)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test Suite 6: Integration with Existing Functionality", () => {
|
||||
test("6.1: getSchema works with validated schemas", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
|
||||
const originalSchema = { type: "string", title: "Test" };
|
||||
const schemaHash = await putSchema(store, originalSchema);
|
||||
const retrieved = getSchema(store, schemaHash);
|
||||
|
||||
expect(retrieved).toEqual(originalSchema);
|
||||
});
|
||||
|
||||
test("6.2: validate() works with schemas validated by meta-schema", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
|
||||
const schemaHash = await putSchema(store, { type: "number" });
|
||||
const validNode = store.get(await store.put(schemaHash, 42));
|
||||
const invalidNode = store.get(await store.put(schemaHash, "not a number"));
|
||||
|
||||
expect(validate(store, validNode as CasNode)).toBe(true);
|
||||
expect(validate(store, invalidNode as CasNode)).toBe(false);
|
||||
});
|
||||
|
||||
test("6.3: refs() works with validated schemas containing cas_ref", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
|
||||
const schemaHash = await putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
ref: { type: "string", format: "cas_ref" },
|
||||
},
|
||||
});
|
||||
|
||||
const refHash = "0000000000001";
|
||||
const dataNode = store.get(
|
||||
await store.put(schemaHash, { ref: refHash })
|
||||
);
|
||||
|
||||
const extractedRefs = refs(store, dataNode as CasNode);
|
||||
expect(extractedRefs).toContain(refHash);
|
||||
});
|
||||
|
||||
test("6.4: walk() works with graphs using validated schemas", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
|
||||
const schemaHash = await putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
next: {
|
||||
anyOf: [
|
||||
{ type: "string", format: "cas_ref" },
|
||||
{ type: "null" },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const node2Hash = await store.put(schemaHash, { next: null });
|
||||
const node1Hash = await store.put(schemaHash, { next: node2Hash });
|
||||
|
||||
const visited: string[] = [];
|
||||
walk(store, node1Hash, (hash) => visited.push(hash));
|
||||
|
||||
expect(visited).toContain(node1Hash);
|
||||
expect(visited).toContain(node2Hash);
|
||||
});
|
||||
|
||||
test("6.5: Idempotency preserved for putSchema", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
|
||||
const schema = { type: "string", title: "Test" };
|
||||
const hash1 = await putSchema(store, schema);
|
||||
const hash2 = await putSchema(store, schema);
|
||||
|
||||
expect(hash1).toBe(hash2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test Suite 7: Meta-Schema Content Validation", () => {
|
||||
test("7.1: Meta-schema allows recursive schema definitions", async () => {
|
||||
const store = new MemStore();
|
||||
const metaHash = await bootstrap(store);
|
||||
const metaSchema = getSchema(store, metaHash);
|
||||
|
||||
expect(metaSchema).not.toBeNull();
|
||||
// The meta-schema should have properties that can contain schemas
|
||||
const properties = (metaSchema?.properties as Record<string, unknown>) || {};
|
||||
expect(properties).toHaveProperty("properties");
|
||||
});
|
||||
|
||||
test("7.2: Meta-schema restricts additionalProperties", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
|
||||
// Schema with unknown keyword should be rejected if meta-schema is strict
|
||||
try {
|
||||
await putSchema(store, {
|
||||
type: "string",
|
||||
unknownKeyword: "value",
|
||||
} as any);
|
||||
// If we get here, meta-schema allows additional properties
|
||||
// This is acceptable behavior
|
||||
} catch (error) {
|
||||
// If it throws, meta-schema is strict about additionalProperties
|
||||
expect(error).toBeInstanceOf(SchemaValidationError);
|
||||
}
|
||||
});
|
||||
|
||||
test("7.3: Meta-schema validates type as string OR array", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
|
||||
// Single string type
|
||||
const hash1 = await putSchema(store, { type: "string" });
|
||||
expect(hash1).toBeTruthy();
|
||||
|
||||
// Array of types
|
||||
const hash2 = await putSchema(store, { type: ["string", "null"] } as any);
|
||||
expect(hash2).toBeTruthy();
|
||||
|
||||
// Invalid type (number)
|
||||
expect(
|
||||
async () => await putSchema(store, { type: 123 } as any)
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test Suite 8: Performance and Edge Cases", () => {
|
||||
test("8.1: Validation performance is acceptable", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
|
||||
const complexSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
level1: {
|
||||
type: "object",
|
||||
properties: {
|
||||
level2: {
|
||||
type: "object",
|
||||
properties: {
|
||||
level3: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const start = performance.now();
|
||||
for (let i = 0; i < 100; i++) {
|
||||
await putSchema(store, complexSchema);
|
||||
}
|
||||
const duration = performance.now() - start;
|
||||
|
||||
// Should complete in reasonable time (< 100ms for 100 validations)
|
||||
expect(duration).toBeLessThan(1000);
|
||||
});
|
||||
|
||||
test("8.2: Large schemas are handled correctly", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
|
||||
const largeSchema: Record<string, unknown> = {
|
||||
type: "object",
|
||||
properties: {},
|
||||
};
|
||||
|
||||
// Create a schema with 100 properties
|
||||
const props = largeSchema.properties as Record<string, unknown>;
|
||||
for (let i = 0; i < 100; i++) {
|
||||
props[`prop${i}`] = { type: "string" };
|
||||
}
|
||||
|
||||
const hash = await putSchema(store, largeSchema);
|
||||
expect(hash).toBeTruthy();
|
||||
});
|
||||
|
||||
test("8.3: Deeply nested schemas validate correctly", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
|
||||
// Build a 5-level deep schema
|
||||
let schema: Record<string, unknown> = { type: "string" };
|
||||
for (let i = 0; i < 5; i++) {
|
||||
schema = {
|
||||
type: "object",
|
||||
properties: { nested: schema },
|
||||
};
|
||||
}
|
||||
|
||||
const hash = await putSchema(store, schema);
|
||||
expect(hash).toBeTruthy();
|
||||
});
|
||||
|
||||
test("8.4: Circular-like schemas don't cause infinite loops", async () => {
|
||||
const store = new MemStore();
|
||||
await bootstrap(store);
|
||||
|
||||
// Schema where additionalProperties has same structure as parent
|
||||
const schema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
value: { type: "string" },
|
||||
},
|
||||
additionalProperties: {
|
||||
type: "object",
|
||||
properties: {
|
||||
value: { type: "string" },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const hash = await putSchema(store, schema);
|
||||
expect(hash).toBeTruthy();
|
||||
});
|
||||
});
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user