openclaw/src/plugins/schema-validator.test.ts

293 lines
7.8 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { validateJsonSchemaValue } from "./schema-validator.js";
function expectValidationFailure(
params: Parameters<typeof validateJsonSchemaValue>[0],
): Extract<ReturnType<typeof validateJsonSchemaValue>, { ok: false }> {
const result = validateJsonSchemaValue(params);
expect(result.ok).toBe(false);
if (result.ok) {
throw new Error("expected validation failure");
}
return result;
}
function expectValidationIssue(
result: Extract<ReturnType<typeof validateJsonSchemaValue>, { ok: false }>,
path: string,
) {
const issue = result.errors.find((entry) => entry.path === path);
expect(issue).toBeDefined();
return issue;
}
function expectIssueMessageIncludes(
issue: ReturnType<typeof expectValidationIssue>,
fragments: readonly string[],
) {
expect(issue?.message).toEqual(expect.stringContaining(fragments[0] ?? ""));
fragments.slice(1).forEach((fragment) => {
expect(issue?.message).toContain(fragment);
});
}
describe("schema validator", () => {
it("can apply JSON Schema defaults while validating", () => {
const res = validateJsonSchemaValue({
cacheKey: "schema-validator.test.defaults",
schema: {
type: "object",
properties: {
mode: {
type: "string",
default: "auto",
},
},
additionalProperties: false,
},
value: {},
applyDefaults: true,
});
expect(res.ok).toBe(true);
if (res.ok) {
expect(res.value).toEqual({ mode: "auto" });
}
});
it.each([
{
title: "includes allowed values in enum validation errors",
params: {
cacheKey: "schema-validator.test.enum",
schema: {
type: "object",
properties: {
fileFormat: {
type: "string",
enum: ["markdown", "html", "json"],
},
},
required: ["fileFormat"],
},
value: { fileFormat: "txt" },
},
path: "fileFormat",
messageIncludes: ["(allowed:"],
allowedValues: ["markdown", "html", "json"],
hiddenCount: 0,
},
{
title: "includes allowed value in const validation errors",
params: {
cacheKey: "schema-validator.test.const",
schema: {
type: "object",
properties: {
mode: {
const: "strict",
},
},
required: ["mode"],
},
value: { mode: "relaxed" },
},
path: "mode",
messageIncludes: ["(allowed:"],
allowedValues: ["strict"],
hiddenCount: 0,
},
{
title: "truncates long allowed-value hints",
params: {
cacheKey: "schema-validator.test.enum.truncate",
schema: {
type: "object",
properties: {
mode: {
type: "string",
enum: [
"v1",
"v2",
"v3",
"v4",
"v5",
"v6",
"v7",
"v8",
"v9",
"v10",
"v11",
"v12",
"v13",
],
},
},
required: ["mode"],
},
value: { mode: "not-listed" },
},
path: "mode",
messageIncludes: ["(allowed:", "... (+1 more)"],
allowedValues: ["v1", "v2", "v3", "v4", "v5", "v6", "v7", "v8", "v9", "v10", "v11", "v12"],
hiddenCount: 1,
},
{
title: "truncates oversized allowed value entries",
params: {
cacheKey: "schema-validator.test.enum.long-value",
schema: {
type: "object",
properties: {
mode: {
type: "string",
enum: ["a".repeat(300)],
},
},
required: ["mode"],
},
value: { mode: "not-listed" },
},
path: "mode",
messageIncludes: ["(allowed:", "... (+"],
},
])("$title", ({ params, path, messageIncludes, allowedValues, hiddenCount }) => {
const result = expectValidationFailure(params);
const issue = expectValidationIssue(result, path);
expectIssueMessageIncludes(issue, messageIncludes);
if (allowedValues) {
expect(issue?.allowedValues).toEqual(allowedValues);
expect(issue?.allowedValuesHiddenCount).toBe(hiddenCount);
}
});
it.each([
{
title: "appends missing required property to the structured path",
params: {
cacheKey: "schema-validator.test.required.path",
schema: {
type: "object",
properties: {
settings: {
type: "object",
properties: {
mode: { type: "string" },
},
required: ["mode"],
},
},
required: ["settings"],
},
value: { settings: {} },
},
expectedPath: "settings.mode",
},
{
title: "appends missing dependency property to the structured path",
params: {
cacheKey: "schema-validator.test.dependencies.path",
schema: {
type: "object",
properties: {
settings: {
type: "object",
dependencies: {
mode: ["format"],
},
},
},
},
value: { settings: { mode: "strict" } },
},
expectedPath: "settings.format",
},
])("$title", ({ params, expectedPath }) => {
const result = expectValidationFailure(params);
const issue = expectValidationIssue(result, expectedPath);
expect(issue?.allowedValues).toBeUndefined();
});
it("sanitizes terminal text while preserving structured fields", () => {
const maliciousProperty = "evil\nkey\t\x1b[31mred\x1b[0m";
const result = expectValidationFailure({
cacheKey: "schema-validator.test.terminal-sanitize",
schema: {
type: "object",
properties: {},
required: [maliciousProperty],
},
value: {},
});
const issue = result.errors[0];
expect(issue).toBeDefined();
expect(issue?.path).toContain("\n");
expect(issue?.message).toContain("\n");
expect(issue?.text).toContain("\\n");
expect(issue?.text).toContain("\\t");
expect(issue?.text).not.toContain("\n");
expect(issue?.text).not.toContain("\t");
expect(issue?.text).not.toContain("\x1b");
});
it.each([
{
title: "accepts uri-formatted string schemas for valid urls",
params: {
cacheKey: "schema-validator.test.uri.valid",
schema: {
type: "object",
properties: {
apiRoot: {
type: "string",
format: "uri",
},
},
required: ["apiRoot"],
},
value: { apiRoot: "https://api.telegram.org" },
},
ok: true,
},
{
title: "rejects uri-formatted string schemas for invalid urls",
params: {
cacheKey: "schema-validator.test.uri.invalid",
schema: {
type: "object",
properties: {
apiRoot: {
type: "string",
format: "uri",
},
},
required: ["apiRoot"],
},
value: { apiRoot: "not a uri" },
},
ok: false,
expectedPath: "apiRoot",
expectedMessage: "must match format",
},
])(
"supports uri-formatted string schemas: $title",
({ params, ok, expectedPath, expectedMessage }) => {
const result = validateJsonSchemaValue(params);
if (ok) {
expect(result.ok).toBe(true);
return;
}
expect(result.ok).toBe(false);
if (!result.ok) {
const issue = expectValidationIssue(result, expectedPath as string);
expect(issue?.message).toContain(expectedMessage);
}
},
);
});