From eab470e763524c8f9a8afaaf49ae65a990220662 Mon Sep 17 00:00:00 2001 From: Code-Paragon Date: Sun, 30 Aug 2026 15:16:07 +0100 Subject: [PATCH] feat(validation): implement framework-independent SDK response schema guard system (#451) --- src/index.ts | 2 + src/validation/schemas.ts | 228 +++++ src/validation/types.ts | 42 + tests/validation/response-guard.test.ts | 1050 +++++++++++++++++++++++ 4 files changed, 1322 insertions(+) create mode 100644 src/validation/schemas.ts create mode 100644 src/validation/types.ts create mode 100644 tests/validation/response-guard.test.ts diff --git a/src/index.ts b/src/index.ts index ad1e9d7..155bb9a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,3 +11,5 @@ export * from "./time/index.js"; export * from "./stellar/index.js"; export * from "./transport/index.js"; export * from "./errors/index.js"; +export * from "./validation/schemas.js"; +export * from "./validation/types.js"; diff --git a/src/validation/schemas.ts b/src/validation/schemas.ts new file mode 100644 index 0000000..e0aad0e --- /dev/null +++ b/src/validation/schemas.ts @@ -0,0 +1,228 @@ +import type { + Schema, + ValidationError, + ValidationResult, +} from "./types.js"; +import { MAX_DEPTH } from "./types.js"; + +/** + * Helper function to create a validation error. + */ +function createError(message: string, path: string[]): ValidationError { + return { message, path }; +} + +/** + * Helper function to check depth limit. + */ +function checkDepth(depth?: number): void { + if (depth !== undefined && depth > MAX_DEPTH) { + throw new Error( + `Validation depth limit exceeded (max: ${MAX_DEPTH}). This may indicate circular data.` + ); + } +} + +/** + * String schema - validates that input is a string. + */ +export function string(): Schema { + return { + parse(input: unknown, path: string[] = [], depth: number = 0): ValidationResult { + checkDepth(depth); + if (typeof input === "string") { + return { success: true, data: input }; + } + return { + success: false, + error: createError("Expected string", path), + }; + }, + }; +} + +/** + * Number schema - validates that input is a finite number. + */ +export function number(): Schema { + return { + parse(input: unknown, path: string[] = [], depth: number = 0): ValidationResult { + checkDepth(depth); + if (typeof input === "number" && Number.isFinite(input)) { + return { success: true, data: input }; + } + return { + success: false, + error: createError("Expected finite number", path), + }; + }, + }; +} + +/** + * Boolean schema - validates that input is a boolean. + */ +export function boolean(): Schema { + return { + parse(input: unknown, path: string[] = [], depth: number = 0): ValidationResult { + checkDepth(depth); + if (typeof input === "boolean") { + return { success: true, data: input }; + } + return { + success: false, + error: createError("Expected boolean", path), + }; + }, + }; +} + +/** + * Null schema - validates that input is null. + */ +export function nullType(): Schema { + return { + parse(input: unknown, path: string[] = [], depth: number = 0): ValidationResult { + checkDepth(depth); + if (input === null) { + return { success: true, data: input }; + } + return { + success: false, + error: createError("Expected null", path), + }; + }, + }; +} + +/** + * Literal schema - validates that input exactly matches the given value. + */ +export function literal(value: T): Schema { + return { + parse(input: unknown, path: string[] = [], depth: number = 0): ValidationResult { + checkDepth(depth); + if (input === value) { + return { success: true, data: input as T }; + } + return { + success: false, + error: createError(`Expected literal value: ${JSON.stringify(value)}`, path), + }; + }, + }; +} + +/** + * Optional schema - allows undefined or delegates to the underlying schema if present. + */ +export function optional(schema: Schema): Schema { + return { + parse(input: unknown, path: string[] = [], depth: number = 0): ValidationResult { + checkDepth(depth); + if (input === undefined) { + return { success: true, data: undefined }; + } + return schema.parse(input, path, depth); + }, + }; +} + +/** + * Array schema - validates that input is an array and each item matches the schema. + */ +export function array(itemSchema: Schema): Schema { + return { + parse(input: unknown, path: string[] = [], depth: number = 0): ValidationResult { + checkDepth(depth); + if (!Array.isArray(input)) { + return { + success: false, + error: createError("Expected array", path), + }; + } + + const result: T[] = []; + for (let i = 0; i < input.length; i++) { + const itemPath = [...path, `[${i}]`]; + const itemResult = itemSchema.parse(input[i], itemPath, depth + 1); + if (!itemResult.success) { + return itemResult; + } + result.push(itemResult.data); + } + + return { success: true, data: result }; + }, + }; +} + +/** + * Object schema - validates object shapes with explicitly declared keys. + * Unknown keys are stripped from the result. + */ +export function object>>( + shape: T +): Schema<{ [K in keyof T]: T[K] extends Schema ? V : never }> { + return { + parse(input: unknown, path: string[] = [], depth: number = 0): ValidationResult { + checkDepth(depth); + if (typeof input !== "object" || input === null || Array.isArray(input)) { + return { + success: false, + error: createError("Expected object", path), + }; + } + + const result: Record = {}; + const obj = input as Record; + + for (const key in shape) { + if (Object.prototype.hasOwnProperty.call(shape, key)) { + const fieldPath = [...path, key]; + const fieldSchema = shape[key]; + const fieldResult = fieldSchema.parse(obj[key], fieldPath, depth + 1); + if (!fieldResult.success) { + return fieldResult; + } + result[key] = fieldResult.data; + } + } + + return { success: true, data: result }; + }, + }; +} + +/** + * Union schema - tries each schema sequentially until one succeeds. + * If all fail, returns a structured union validation error. + */ +export function union(...schemas: Schema[]): Schema; +export function union(...schemas: [Schema, Schema]): Schema; +export function union(...schemas: [Schema, Schema, Schema]): Schema; +export function union(...schemas: [Schema, Schema, Schema, Schema]): Schema; +export function union(...schemas: Schema[]): Schema { + return { + parse(input: unknown, path: string[] = [], depth: number = 0): ValidationResult { + checkDepth(depth); + const errors: string[] = []; + + for (const schema of schemas) { + const result = schema.parse(input, path, depth); + if (result.success) { + return result; + } + errors.push(result.error.message); + } + + return { + success: false, + error: createError( + `No union member matched. Errors: ${errors.join("; ")}`, + path + ), + }; + }, + }; +} diff --git a/src/validation/types.ts b/src/validation/types.ts new file mode 100644 index 0000000..5044fb2 --- /dev/null +++ b/src/validation/types.ts @@ -0,0 +1,42 @@ +/** + * Validation error with path information. + * The path array represents the location of the error in the data structure. + * For example, ['data', 'members', '2', 'id'] serializes to 'data.members[2].id'. + */ +export interface ValidationError { + /** Human-readable error message */ + message: string; + /** Path to the invalid field in the data structure */ + path: string[]; +} + +/** + * Discriminated union for validation results. + * Either a successful validation with parsed data, or a failure with an error. + */ +export type ValidationResult = + | { success: true; data: T } + | { success: false; error: ValidationError }; + +/** + * Core schema interface for validation. + * All schemas implement this interface to provide a consistent parse method. + */ +export interface Schema { + /** + * Parse and validate the input against this schema. + * + * @param input - The unknown input to validate + * @param path - Current path in the data structure (for error reporting) + * @param depth - Current recursion depth (for DoS protection) + * @returns ValidationResult with either the parsed data or a validation error + */ + parse( + input: unknown, + path?: string[], + depth?: number + ): ValidationResult; +} + +/** Maximum recursion depth to prevent DoS attacks via circular objects */ +export const MAX_DEPTH = 20; diff --git a/tests/validation/response-guard.test.ts b/tests/validation/response-guard.test.ts new file mode 100644 index 0000000..7bf378c --- /dev/null +++ b/tests/validation/response-guard.test.ts @@ -0,0 +1,1050 @@ +import { describe, it, expect } from "vitest"; +import { + string, + number, + boolean, + nullType, + literal, + optional, + array, + object, + union, +} from "../../src/validation/schemas"; +import type { Schema, ValidationResult, ValidationError } from "../../src/validation/types"; + +describe("Response Schema Guards", () => { + describe("Primitive validations and type mismatch rejections", () => { + describe("string()", () => { + it("should validate strings successfully", () => { + const schema = string(); + const result = schema.parse("hello"); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toBe("hello"); + } + }); + + it("should reject non-strings", () => { + const schema = string(); + const result = schema.parse(123); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.message).toBe("Expected string"); + expect(result.error.path).toEqual([]); + } + }); + + it("should reject numbers", () => { + const schema = string(); + const result = schema.parse(42); + + expect(result.success).toBe(false); + }); + + it("should reject booleans", () => { + const schema = string(); + const result = schema.parse(true); + + expect(result.success).toBe(false); + }); + + it("should reject null", () => { + const schema = string(); + const result = schema.parse(null); + + expect(result.success).toBe(false); + }); + + it("should reject objects", () => { + const schema = string(); + const result = schema.parse({}); + + expect(result.success).toBe(false); + }); + + it("should reject arrays", () => { + const schema = string(); + const result = schema.parse([]); + + expect(result.success).toBe(false); + }); + + it("should accept empty strings", () => { + const schema = string(); + const result = schema.parse(""); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toBe(""); + } + }); + }); + + describe("number()", () => { + it("should validate finite numbers successfully", () => { + const schema = number(); + const result = schema.parse(42); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toBe(42); + } + }); + + it("should validate negative numbers", () => { + const schema = number(); + const result = schema.parse(-10); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toBe(-10); + } + }); + + it("should validate decimal numbers", () => { + const schema = number(); + const result = schema.parse(3.14); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toBe(3.14); + } + }); + + it("should reject Infinity", () => { + const schema = number(); + const result = schema.parse(Infinity); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.message).toBe("Expected finite number"); + } + }); + + it("should reject -Infinity", () => { + const schema = number(); + const result = schema.parse(-Infinity); + + expect(result.success).toBe(false); + }); + + it("should reject NaN", () => { + const schema = number(); + const result = schema.parse(NaN); + + expect(result.success).toBe(false); + }); + + it("should reject strings", () => { + const schema = number(); + const result = schema.parse("42"); + + expect(result.success).toBe(false); + }); + + it("should reject booleans", () => { + const schema = number(); + const result = schema.parse(true); + + expect(result.success).toBe(false); + }); + + it("should reject null", () => { + const schema = number(); + const result = schema.parse(null); + + expect(result.success).toBe(false); + }); + + it("should reject objects", () => { + const schema = number(); + const result = schema.parse({}); + + expect(result.success).toBe(false); + }); + + it("should reject arrays", () => { + const schema = number(); + const result = schema.parse([]); + + expect(result.success).toBe(false); + }); + }); + + describe("boolean()", () => { + it("should validate true successfully", () => { + const schema = boolean(); + const result = schema.parse(true); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toBe(true); + } + }); + + it("should validate false successfully", () => { + const schema = boolean(); + const result = schema.parse(false); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toBe(false); + } + }); + + it("should reject strings", () => { + const schema = boolean(); + const result = schema.parse("true"); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.message).toBe("Expected boolean"); + } + }); + + it("should reject numbers", () => { + const schema = boolean(); + const result = schema.parse(1); + + expect(result.success).toBe(false); + }); + + it("should reject null", () => { + const schema = boolean(); + const result = schema.parse(null); + + expect(result.success).toBe(false); + }); + + it("should reject objects", () => { + const schema = boolean(); + const result = schema.parse({}); + + expect(result.success).toBe(false); + }); + + it("should reject arrays", () => { + const schema = boolean(); + const result = schema.parse([]); + + expect(result.success).toBe(false); + }); + }); + + describe("nullType()", () => { + it("should validate null successfully", () => { + const schema = nullType(); + const result = schema.parse(null); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toBe(null); + } + }); + + it("should reject strings", () => { + const schema = nullType(); + const result = schema.parse("null"); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.message).toBe("Expected null"); + } + }); + + it("should reject numbers", () => { + const schema = nullType(); + const result = schema.parse(0); + + expect(result.success).toBe(false); + }); + + it("should reject booleans", () => { + const schema = nullType(); + const result = schema.parse(false); + + expect(result.success).toBe(false); + }); + + it("should reject undefined", () => { + const schema = nullType(); + const result = schema.parse(undefined); + + expect(result.success).toBe(false); + }); + + it("should reject objects", () => { + const schema = nullType(); + const result = schema.parse({}); + + expect(result.success).toBe(false); + }); + + it("should reject arrays", () => { + const schema = nullType(); + const result = schema.parse([]); + + expect(result.success).toBe(false); + }); + }); + }); + + describe("Nested object validation and accurate path reporting", () => { + it("should validate simple objects", () => { + const schema = object({ + name: string(), + age: number(), + }); + + const result = schema.parse({ name: "John", age: 30 }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toEqual({ name: "John", age: 30 }); + } + }); + + it("should validate nested objects", () => { + const schema = object({ + user: object({ + name: string(), + age: number(), + }), + }); + + const result = schema.parse({ user: { name: "John", age: 30 } }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toEqual({ user: { name: "John", age: 30 } }); + } + }); + + it("should report correct path for nested field errors", () => { + const schema = object({ + user: object({ + name: string(), + age: number(), + }), + }); + + const result = schema.parse({ user: { name: "John", age: "thirty" } }); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.message).toBe("Expected finite number"); + expect(result.error.path).toEqual(["user", "age"]); + } + }); + + it("should reject non-objects", () => { + const schema = object({ name: string() }); + const result = schema.parse("string"); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.message).toBe("Expected object"); + expect(result.error.path).toEqual([]); + } + }); + + it("should reject arrays", () => { + const schema = object({ name: string() }); + const result = schema.parse([]); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.message).toBe("Expected object"); + } + }); + + it("should reject null", () => { + const schema = object({ name: string() }); + const result = schema.parse(null); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.message).toBe("Expected object"); + } + }); + + it("should report path for top-level field errors", () => { + const schema = object({ + name: string(), + age: number(), + }); + + const result = schema.parse({ name: "John", age: "thirty" }); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.message).toBe("Expected finite number"); + expect(result.error.path).toEqual(["age"]); + } + }); + + it("should validate deeply nested objects", () => { + const schema = object({ + level1: object({ + level2: object({ + level3: object({ + value: string(), + }), + }), + }), + }); + + const result = schema.parse({ + level1: { + level2: { + level3: { + value: "deep", + }, + }, + }, + }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.level1.level2.level3.value).toBe("deep"); + } + }); + + it("should report path for deeply nested errors", () => { + const schema = object({ + level1: object({ + level2: object({ + level3: object({ + value: string(), + }), + }), + }), + }); + + const result = schema.parse({ + level1: { + level2: { + level3: { + value: 123, + }, + }, + }, + }); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.path).toEqual(["level1", "level2", "level3", "value"]); + } + }); + }); + + describe("Array validation reporting exact failing indexes", () => { + it("should validate arrays of strings", () => { + const schema = array(string()); + const result = schema.parse(["a", "b", "c"]); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toEqual(["a", "b", "c"]); + } + }); + + it("should validate arrays of numbers", () => { + const schema = array(number()); + const result = schema.parse([1, 2, 3]); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toEqual([1, 2, 3]); + } + }); + + it("should validate empty arrays", () => { + const schema = array(string()); + const result = schema.parse([]); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toEqual([]); + } + }); + + it("should report exact failing index in path", () => { + const schema = array(string()); + const result = schema.parse(["a", "b", 123, "d"]); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.message).toBe("Expected string"); + expect(result.error.path).toEqual(["[2]"]); + } + }); + + it("should report failing index for first element", () => { + const schema = array(number()); + const result = schema.parse(["invalid", 2, 3]); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.path).toEqual(["[0]"]); + } + }); + + it("should report failing index for last element", () => { + const schema = array(string()); + const result = schema.parse(["a", "b", "c", 123]); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.path).toEqual(["[3]"]); + } + }); + + it("should reject non-arrays", () => { + const schema = array(string()); + const result = schema.parse("not an array"); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.message).toBe("Expected array"); + expect(result.error.path).toEqual([]); + } + }); + + it("should reject objects", () => { + const schema = array(string()); + const result = schema.parse({}); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.message).toBe("Expected array"); + } + }); + + it("should reject null", () => { + const schema = array(string()); + const result = schema.parse(null); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.message).toBe("Expected array"); + } + }); + + it("should validate nested arrays", () => { + const schema = array(array(string())); + const result = schema.parse([["a", "b"], ["c", "d"]]); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toEqual([["a", "b"], ["c", "d"]]); + } + }); + + it("should report path for nested array errors", () => { + const schema = array(array(string())); + const result = schema.parse([["a", "b"], ["c", 123]]); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.path).toEqual(["[1]", "[1]"]); + } + }); + + it("should validate arrays of objects", () => { + const schema = array( + object({ + name: string(), + age: number(), + }) + ); + + const result = schema.parse([ + { name: "John", age: 30 }, + { name: "Jane", age: 25 }, + ]); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toEqual([ + { name: "John", age: 30 }, + { name: "Jane", age: 25 }, + ]); + } + }); + + it("should report path for array of object errors", () => { + const schema = array( + object({ + name: string(), + age: number(), + }) + ); + + const result = schema.parse([ + { name: "John", age: 30 }, + { name: "Jane", age: "twenty-five" }, + ]); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.path).toEqual(["[1]", "age"]); + } + }); + }); + + describe("Optional values and undefined handling", () => { + it("should accept undefined", () => { + const schema = optional(string()); + const result = schema.parse(undefined); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toBe(undefined); + } + }); + + it("should validate matching schema when provided", () => { + const schema = optional(string()); + const result = schema.parse("hello"); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toBe("hello"); + } + }); + + it("should reject non-matching values", () => { + const schema = optional(string()); + const result = schema.parse(123); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.message).toBe("Expected string"); + } + }); + + it("should work with optional numbers", () => { + const schema = optional(number()); + const result = schema.parse(42); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toBe(42); + } + }); + + it("should work with optional objects", () => { + const schema = optional(object({ name: string() })); + const result = schema.parse({ name: "John" }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toEqual({ name: "John" }); + } + }); + + it("should work with optional arrays", () => { + const schema = optional(array(string())); + const result = schema.parse(["a", "b"]); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toEqual(["a", "b"]); + } + }); + + it("should handle undefined in object fields", () => { + const schema = object({ + name: string(), + age: optional(number()), + }); + + const result = schema.parse({ name: "John" }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toEqual({ name: "John", age: undefined }); + } + }); + }); + + describe("Literal and union validation", () => { + describe("literal()", () => { + it("should validate string literals", () => { + const schema = literal("hello"); + const result = schema.parse("hello"); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toBe("hello"); + } + }); + + it("should validate number literals", () => { + const schema = literal(42); + const result = schema.parse(42); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toBe(42); + } + }); + + it("should validate boolean literals", () => { + const schema = literal(true); + const result = schema.parse(true); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toBe(true); + } + }); + + it("should reject non-matching strings", () => { + const schema = literal("hello"); + const result = schema.parse("world"); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.message).toContain('Expected literal value: "hello"'); + } + }); + + it("should reject non-matching numbers", () => { + const schema = literal(42); + const result = schema.parse(43); + + expect(result.success).toBe(false); + }); + + it("should reject non-matching booleans", () => { + const schema = literal(true); + const result = schema.parse(false); + + expect(result.success).toBe(false); + }); + + it("should reject wrong types", () => { + const schema = literal("hello"); + const result = schema.parse(123); + + expect(result.success).toBe(false); + }); + }); + + describe("union()", () => { + it("should match first successful schema", () => { + const schema = union(string(), number()); + const result = schema.parse("hello"); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toBe("hello"); + } + }); + + it("should match second schema if first fails", () => { + const schema = union(string(), number()); + const result = schema.parse(42); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toBe(42); + } + }); + + it("should fail if all schemas fail", () => { + const schema = union(string(), number()); + const result = schema.parse(true); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.message).toContain("No union member matched"); + expect(result.error.message).toContain("Expected string"); + expect(result.error.message).toContain("Expected finite number"); + } + }); + + it("should work with multiple schemas", () => { + const schema = union(string(), number(), boolean()); + const result = schema.parse(true); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toBe(true); + } + }); + + it("should work with literal unions", () => { + const schema = union(literal("a"), literal("b"), literal("c")); + const result = schema.parse("b"); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toBe("b"); + } + }); + + it("should fail literal union with no match", () => { + const schema = union(literal("a"), literal("b"), literal("c")); + const result = schema.parse("d"); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.message).toContain("No union member matched"); + } + }); + + it("should preserve path in union errors", () => { + const schema = union(string(), number()); + const result = schema.parse(true, ["field"]); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.path).toEqual(["field"]); + } + }); + }); + }); + + describe("Unknown-key handling behavior", () => { + it("should strip unknown keys from objects", () => { + const schema = object({ name: string() }); + const result = schema.parse({ name: "John", extra: "data" }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toEqual({ name: "John" }); + expect("extra" in result.data).toBe(false); + } + }); + + it("should strip multiple unknown keys", () => { + const schema = object({ name: string() }); + const result = schema.parse({ + name: "John", + extra: "data", + more: "keys", + another: "field", + }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toEqual({ name: "John" }); + expect("extra" in result.data).toBe(false); + expect("more" in result.data).toBe(false); + expect("another" in result.data).toBe(false); + } + }); + + it("should strip unknown keys in nested objects", () => { + const schema = object({ + user: object({ + name: string(), + }), + }); + + const result = schema.parse({ + user: { name: "John", extra: "data", more: "keys" }, + extra: "top", + }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toEqual({ user: { name: "John" } }); + expect("extra" in result.data).toBe(false); + expect("extra" in result.data.user).toBe(false); + expect("more" in result.data.user).toBe(false); + } + }); + + it("should handle objects with only unknown keys", () => { + const schema = object({ name: string() }); + const result = schema.parse({ extra: "data", more: "keys" }); + + // Objects with only unknown keys should fail when required fields are missing + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.message).toBe("Expected string"); + expect(result.error.path).toEqual(["name"]); + } + }); + + it("should preserve known keys while stripping unknown ones", () => { + const schema = object({ + name: string(), + age: number(), + }); + + const result = schema.parse({ + name: "John", + age: 30, + extra: "data", + more: "keys", + }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toEqual({ name: "John", age: 30 }); + expect("extra" in result.data).toBe(false); + expect("more" in result.data).toBe(false); + } + }); + }); + + describe("Deeply nested hostile input / recursion depth limitation", () => { + it("should validate within depth limit", () => { + const schema = object({ + a: object({ + b: object({ + c: object({ + d: object({ + e: string(), + }), + }), + }), + }), + }); + + const result = schema.parse({ + a: { + b: { + c: { + d: { + e: "deep", + }, + }, + }, + }, + }); + + expect(result.success).toBe(true); + }); + + it("should throw error when depth exceeds limit", () => { + // Create a deeply nested structure that exceeds MAX_DEPTH (20) + let schema: Schema = string(); + for (let i = 0; i < 25; i++) { + schema = object({ value: schema }); + } + + let value: any = "deep"; + for (let i = 0; i < 25; i++) { + value = { value }; + } + + expect(() => schema.parse(value)).toThrow( + "Validation depth limit exceeded" + ); + }); + + it("should handle circular references safely via depth limit", () => { + const schema = object({ + nested: object({ + value: string(), + }), + }); + + // This should not cause infinite recursion due to depth limit + // Test the depth limit directly + let deepSchema: Schema = string(); + for (let i = 0; i < 21; i++) { + deepSchema = object({ level: deepSchema }); + } + + let deepValue: any = "end"; + for (let i = 0; i < 21; i++) { + deepValue = { level: deepValue }; + } + + expect(() => deepSchema.parse(deepValue)).toThrow( + "Validation depth limit exceeded" + ); + }); + + it("should track depth correctly in nested arrays", () => { + const schema = array(array(array(string()))); + const result = schema.parse([[["a"]]]); + + expect(result.success).toBe(true); + }); + + it("should throw on deeply nested arrays", () => { + let schema: Schema = string(); + for (let i = 0; i < 21; i++) { + schema = array(schema); + } + + let value: any = "end"; + for (let i = 0; i < 21; i++) { + value = [value]; + } + + expect(() => schema.parse(value)).toThrow( + "Validation depth limit exceeded" + ); + }); + + it("should handle mixed nested structures within depth limit", () => { + const schema = object({ + data: array( + object({ + items: array( + object({ + value: string(), + }) + ), + }) + ), + }); + + const result = schema.parse({ + data: [ + { + items: [ + { value: "a" }, + { value: "b" }, + ], + }, + ], + }); + + expect(result.success).toBe(true); + }); + + it("should throw on hostile mixed nested structures exceeding depth", () => { + let schema: Schema = string(); + for (let i = 0; i < 21; i++) { + schema = object({ nested: array(schema) }); + } + + let value: any = "end"; + for (let i = 0; i < 21; i++) { + value = { nested: [value] }; + } + + expect(() => schema.parse(value)).toThrow( + "Validation depth limit exceeded" + ); + }); + + it("should validate exactly at depth limit boundary", () => { + // Create structure exactly at depth limit (20) + let schema: Schema = string(); + for (let i = 0; i < 20; i++) { + schema = object({ level: schema }); + } + + let value: any = "end"; + for (let i = 0; i < 20; i++) { + value = { level: value }; + } + + const result = schema.parse(value); + expect(result.success).toBe(true); + }); + }); +});