Files
2026-07-13 12:38:34 +08:00

487 lines
17 KiB
TypeScript

import { describe, it, expect, vi } from 'vitest';
import { dereferenceJsonSchema } from '../../src/utils/jsonSchema';
import { JsonSchemaRefResolutionError } from '../../src/errors/ValidationErrors';
import logger from '../../src/utils/logger';
const containsRef = (value: unknown): boolean => {
if (value === null || typeof value !== 'object') return false;
if (Array.isArray(value)) return value.some(containsRef);
if ('$ref' in (value as Record<string, unknown>)) return true;
return Object.values(value as Record<string, unknown>).some(containsRef);
};
describe('dereferenceJsonSchema', () => {
it('inlines a single internal $ref under $defs', () => {
const out = dereferenceJsonSchema({
type: 'object',
properties: { user: { $ref: '#/$defs/User' } },
required: ['user'],
$defs: { User: { type: 'string' } },
});
expect(out).toEqual({
type: 'object',
properties: { user: { type: 'string' } },
required: ['user'],
});
expect(containsRef(out)).toBe(false);
});
it('resolves a chain of refs A -> B -> C', () => {
const out = dereferenceJsonSchema({
type: 'object',
properties: { v: { $ref: '#/$defs/A' } },
$defs: {
A: { $ref: '#/$defs/B' },
B: { $ref: '#/$defs/C' },
C: { type: 'integer' },
},
});
expect(containsRef(out)).toBe(false);
expect((out as { properties: { v: unknown } }).properties.v).toEqual({ type: 'integer' });
});
it('walks containers reflectively (items, oneOf, anyOf, allOf, not, additionalProperties, patternProperties, prefixItems)', () => {
const out = dereferenceJsonSchema({
type: 'object',
properties: {
a: { type: 'array', items: { $ref: '#/$defs/Leaf' } },
b: { oneOf: [{ $ref: '#/$defs/Leaf' }, { type: 'null' }] },
c: { anyOf: [{ $ref: '#/$defs/Leaf' }] },
d: { allOf: [{ $ref: '#/$defs/Leaf' }] },
e: { not: { $ref: '#/$defs/Leaf' } },
f: { type: 'object', additionalProperties: { $ref: '#/$defs/Leaf' } },
g: {
type: 'object',
patternProperties: { '^x_': { $ref: '#/$defs/Leaf' } },
},
h: { type: 'array', prefixItems: [{ $ref: '#/$defs/Leaf' }] },
},
$defs: { Leaf: { type: 'string' } },
});
expect(containsRef(out)).toBe(false);
});
it('resolves Draft 7 legacy `definitions`', () => {
const out = dereferenceJsonSchema({
type: 'object',
properties: { name: { $ref: '#/definitions/Name' } },
definitions: { Name: { type: 'string', minLength: 1 } },
});
expect(containsRef(out)).toBe(false);
expect((out as { properties: { name: unknown } }).properties.name).toEqual({
type: 'string',
minLength: 1,
});
});
it('mixes $defs and definitions transitively', () => {
const out = dereferenceJsonSchema({
type: 'object',
properties: { v: { $ref: '#/definitions/A' } },
definitions: { A: { $ref: '#/$defs/B' } },
$defs: { B: { type: 'boolean' } },
});
expect(containsRef(out)).toBe(false);
expect((out as { properties: { v: unknown } }).properties.v).toEqual({ type: 'boolean' });
});
it('shallow-merges sibling keywords next to $ref (Draft 2020-12 semantics; siblings win on collision)', () => {
const out = dereferenceJsonSchema({
type: 'object',
properties: {
v: {
$ref: '#/$defs/Foo',
description: 'caller override',
default: null,
},
},
$defs: {
Foo: { type: 'string', description: 'target description' },
},
});
const v = (out as { properties: { v: Record<string, unknown> } }).properties.v;
expect(v.type).toBe('string');
expect(v.description).toBe('caller override');
expect(v.default).toBeNull();
expect(containsRef(out)).toBe(false);
});
it('breaks recursive $ref cycles with a permissive object sentinel', () => {
const out = dereferenceJsonSchema({
$ref: '#/$defs/Tree',
$defs: {
Tree: {
type: 'object',
properties: {
children: { type: 'array', items: { $ref: '#/$defs/Tree' } },
},
},
},
});
expect((out as { type: string }).type).toBe('object');
const children = (
out as {
properties: { children: { items: { type: string; additionalProperties: boolean } } };
}
).properties.children;
expect(children.items).toEqual({ type: 'object', additionalProperties: true });
expect(containsRef(out)).toBe(false);
});
it('strips $defs and definitions from the returned root schema', () => {
const out = dereferenceJsonSchema({
type: 'object',
properties: { x: { $ref: '#/$defs/Foo' } },
$defs: { Foo: { type: 'integer' } },
definitions: { Bar: { type: 'string' } },
}) as Record<string, unknown>;
expect(out.$defs).toBeUndefined();
expect(out.definitions).toBeUndefined();
});
it('throws JsonSchemaRefResolutionError when target is missing', () => {
expect(() =>
dereferenceJsonSchema({
type: 'object',
properties: { v: { $ref: '#/$defs/Missing' } },
$defs: {},
})
).toThrow(JsonSchemaRefResolutionError);
});
it('throws JsonSchemaRefResolutionError when chain depth exceeds the cap', () => {
const $defs: Record<string, unknown> = {};
for (let i = 0; i < 200; i++) {
$defs[`A${i}`] = { $ref: i + 1 < 200 ? `#/$defs/A${i + 1}` : '#/$defs/A0' };
}
expect(() =>
dereferenceJsonSchema({
type: 'object',
properties: { v: { $ref: '#/$defs/A0' } },
$defs,
})
).toThrow(JsonSchemaRefResolutionError);
});
it('throws JsonSchemaRefResolutionError on pathologically deep object nesting', () => {
// Build { properties: { x: { properties: { x: { ... } } } } } 1000 levels deep.
// Without a node-depth cap this would blow the V8 stack; with the cap, it throws
// a typed error before recursion grows unbounded.
type Nested = { type: 'object'; properties: { x: Nested | { type: 'string' } } };
let leaf: Nested | { type: 'string' } = { type: 'string' };
for (let i = 0; i < 1000; i++) {
leaf = { type: 'object', properties: { x: leaf } } as Nested;
}
expect(() => dereferenceJsonSchema(leaf)).toThrow(JsonSchemaRefResolutionError);
});
it('breaks JS object cycles that do not flow through a $ref', () => {
// Composio ingests schemas as live JS objects (custom tools, telemetry, …),
// so a non-$ref cycle is plausible. Without object-identity cycle detection
// this would infinite-loop; with it, the cycle returns the permissive sentinel.
const root: Record<string, unknown> = {
type: 'object',
properties: {},
};
(root.properties as Record<string, unknown>).self = root;
const out = dereferenceJsonSchema(root) as {
properties: { self: { type: string; additionalProperties: boolean } };
};
expect(out.properties.self).toEqual({ type: 'object', additionalProperties: true });
});
it('leaves external $ref pointers untouched and warns once for audit', () => {
const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => {});
try {
const out = dereferenceJsonSchema({
type: 'object',
properties: { v: { $ref: 'https://example.com/Foo' } },
});
expect((out as { properties: { v: { $ref: string } } }).properties.v.$ref).toBe(
'https://example.com/Foo'
);
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('https://example.com/Foo'));
} finally {
warnSpy.mockRestore();
}
});
it('filters prototype-pollution keys when cloning', () => {
const out = dereferenceJsonSchema({
type: 'object',
properties: {
v: { $ref: '#/$defs/User' },
},
$defs: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
User: { type: 'object', __proto__: { polluted: true } } as any,
},
} as Record<string, unknown>);
const v = (out as { properties: { v: Record<string, unknown> } }).properties.v;
expect('__proto__' in v && (v as Record<string, unknown>).__proto__).not.toEqual({
polluted: true,
});
// The cloned node's prototype should remain Object.prototype.
expect(Object.getPrototypeOf(v)).toBe(Object.prototype);
});
it('does not mutate the input schema', () => {
const input = {
type: 'object',
properties: { v: { $ref: '#/$defs/Foo' } },
$defs: { Foo: { type: 'string' } },
};
const snapshot = structuredClone(input);
dereferenceJsonSchema(input);
expect(input).toEqual(snapshot);
});
it('decodes JSON Pointer escape sequences (~1 -> /, ~0 -> ~) in the correct order', () => {
const out = dereferenceJsonSchema({
type: 'object',
properties: {
a: { $ref: '#/$defs/with~1slash' },
b: { $ref: '#/$defs/with~0tilde' },
c: { $ref: '#/$defs/tilde-then-slash~01' },
},
$defs: {
'with/slash': { type: 'string' },
'with~tilde': { type: 'integer' },
'tilde-then-slash~1': { type: 'boolean' },
},
} as unknown as Record<string, unknown>);
const props = (out as { properties: Record<string, { type: string }> }).properties;
expect(props.a.type).toBe('string');
expect(props.b.type).toBe('integer');
expect(props.c.type).toBe('boolean');
});
it('resolves array-index pointers (#/$defs/foo/oneOf/0)', () => {
const out = dereferenceJsonSchema({
type: 'object',
properties: { v: { $ref: '#/$defs/foo/oneOf/0' } },
$defs: {
foo: { oneOf: [{ type: 'string' }, { type: 'number' }] },
},
} as unknown as Record<string, unknown>);
expect((out as { properties: { v: { type: string } } }).properties.v.type).toBe('string');
});
describe("with onUnresolved: 'sentinel' lenient mode", () => {
// Sentinel injected for an unresolvable $ref carries an LLM-visible hint
// describing the degradation. The hint is overwritten by a caller-provided
// `description` sibling when present (Draft 2020-12 sibling-keyword merge).
const PERMISSIVE = {
type: 'object',
additionalProperties: true,
description: expect.stringContaining('Schema shape unresolved at the source'),
};
it('replaces a dangling internal $ref (no $defs block at all) with the cycle-break sentinel', () => {
// Mirrors GMAIL_FETCH_EMAILS.outputParameters: a $ref into #/$defs/...
// without any $defs declared on the root.
const out = dereferenceJsonSchema(
{
type: 'object',
properties: {
data: { $ref: '#/$defs/FetchEmailsResponse' },
error: { type: 'string' },
successful: { type: 'boolean' },
},
required: ['data', 'successful'],
title: 'FetchEmailsResponseWrapper',
},
{ onUnresolved: 'sentinel' }
) as {
type: string;
properties: { data: unknown; error: unknown; successful: unknown };
required: string[];
};
expect(out.properties.data).toEqual(PERMISSIVE);
// Siblings of the replaced node are untouched.
expect(out.properties.error).toEqual({ type: 'string' });
expect(out.properties.successful).toEqual({ type: 'boolean' });
expect(out.required).toEqual(['data', 'successful']);
expect(containsRef(out)).toBe(false);
});
it('replaces a $ref that points into an empty $defs (missing key) with the sentinel', () => {
const out = dereferenceJsonSchema(
{
type: 'object',
properties: { v: { $ref: '#/$defs/Missing' } },
$defs: {},
},
{ onUnresolved: 'sentinel' }
) as { properties: { v: unknown } };
expect(out.properties.v).toEqual(PERMISSIVE);
expect(containsRef(out)).toBe(false);
});
it("invokes onReplace once per replacement with the original ref and reason 'missing-target'", () => {
const calls: Array<[string, string]> = [];
dereferenceJsonSchema(
{
type: 'object',
properties: {
a: { $ref: '#/$defs/Missing' },
b: { $ref: '#/$defs/Missing' },
c: { $ref: '#/$defs/AlsoMissing' },
},
},
{
onUnresolved: 'sentinel',
onReplace: (ref, reason) => calls.push([ref, reason]),
}
);
expect(calls).toEqual([
['#/$defs/Missing', 'missing-target'],
['#/$defs/Missing', 'missing-target'],
['#/$defs/AlsoMissing', 'missing-target'],
]);
});
it("invokes onReplace with reason 'malformed-pointer' for a non '/' internal pointer", () => {
const calls: Array<[string, string]> = [];
const out = dereferenceJsonSchema(
{
type: 'object',
properties: { v: { $ref: '#bar' } },
},
{
onUnresolved: 'sentinel',
onReplace: (ref, reason) => calls.push([ref, reason]),
}
) as { properties: { v: unknown } };
expect(calls).toEqual([['#bar', 'malformed-pointer']]);
expect(out.properties.v).toEqual(PERMISSIVE);
});
it('still throws JsonSchemaRefResolutionError when chain depth exceeds the cap (safety cap is not lenient)', () => {
const $defs: Record<string, unknown> = {};
for (let i = 0; i < 200; i++) {
$defs[`A${i}`] = { $ref: i + 1 < 200 ? `#/$defs/A${i + 1}` : '#/$defs/A0' };
}
expect(() =>
dereferenceJsonSchema(
{
type: 'object',
properties: { v: { $ref: '#/$defs/A0' } },
$defs,
},
{ onUnresolved: 'sentinel' }
)
).toThrow(JsonSchemaRefResolutionError);
});
it('still throws JsonSchemaRefResolutionError when node depth exceeds the cap (safety cap is not lenient)', () => {
type Nested = { type: 'object'; properties: { x: Nested | { type: 'string' } } };
let leaf: Nested | { type: 'string' } = { type: 'string' };
for (let i = 0; i < 1000; i++) {
leaf = { type: 'object', properties: { x: leaf } } as Nested;
}
expect(() => dereferenceJsonSchema(leaf, { onUnresolved: 'sentinel' })).toThrow(
JsonSchemaRefResolutionError
);
});
it("explicit onUnresolved: 'throw' matches default behavior", () => {
expect(() =>
dereferenceJsonSchema(
{
type: 'object',
properties: { v: { $ref: '#/$defs/Missing' } },
$defs: {},
},
{ onUnresolved: 'throw' }
)
).toThrow(JsonSchemaRefResolutionError);
});
it('does not call onReplace in strict mode (regression guard)', () => {
const onReplace = vi.fn();
expect(() =>
dereferenceJsonSchema(
{
type: 'object',
properties: { v: { $ref: '#/$defs/Missing' } },
$defs: {},
},
{ onUnresolved: 'throw', onReplace }
)
).toThrow(JsonSchemaRefResolutionError);
expect(onReplace).not.toHaveBeenCalled();
});
it("injects a default LLM-visible 'description' on the sentinel when the source $ref node has none", () => {
const out = dereferenceJsonSchema(
{
type: 'object',
properties: { v: { $ref: '#/$defs/Missing' } },
},
{ onUnresolved: 'sentinel' }
) as { properties: { v: { description: string } } };
expect(out.properties.v.description).toMatch(/Schema shape unresolved at the source/);
expect(out.properties.v.description).toContain(
'https://github.com/ComposioHQ/composio/issues/3307'
);
});
it("preserves a caller-provided 'description' sibling instead of overwriting with the default", () => {
const out = dereferenceJsonSchema(
{
type: 'object',
properties: {
v: {
$ref: '#/$defs/Missing',
description: 'caller-supplied prose context',
},
},
},
{ onUnresolved: 'sentinel' }
) as { properties: { v: { description: string; type: string } } };
// Sibling-merge wins over the default — caller context survives.
expect(out.properties.v.description).toBe('caller-supplied prose context');
expect(out.properties.v.type).toBe('object');
});
it('preserves resolvable $defs while replacing only the dangling branch (mixed schema)', () => {
const out = dereferenceJsonSchema(
{
type: 'object',
properties: {
resolved: { $ref: '#/$defs/Real' },
dangling: { $ref: '#/$defs/Ghost' },
},
$defs: {
Real: { type: 'integer' },
},
},
{ onUnresolved: 'sentinel' }
) as { properties: { resolved: { type: string }; dangling: unknown } };
expect(out.properties.resolved).toEqual({ type: 'integer' });
expect(out.properties.dangling).toEqual(PERMISSIVE);
});
});
});