From de29b9939dcd6b4ebba5649bcdae22566d8018da Mon Sep 17 00:00:00 2001 From: Okorie2000-code Date: Sun, 30 Aug 2026 15:17:21 +0100 Subject: [PATCH] feat(validation): add deterministic JSON-safe metadata sanitiser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a standalone metadata sanitiser that recursively validates and sanitises arbitrary runtime values into JSON-safe representations. Implements circular-reference detection, prototype-pollution safety, configurable depth/collection limits, and structured error paths. Closes #462 🤖 Generated with Codebuff Co-Authored-By: Codebuff --- src/index.ts | 1 + src/validation/metadataSanitiser.ts | 297 +++++++ tests/validation/metadataSanitiser.test.ts | 859 +++++++++++++++++++++ 3 files changed, 1157 insertions(+) create mode 100644 src/validation/metadataSanitiser.ts create mode 100644 tests/validation/metadataSanitiser.test.ts diff --git a/src/index.ts b/src/index.ts index 563e732..6c1d241 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,7 @@ export * from "./client/index.js"; export * from "./context/RequestContext.js"; export * from "./validation/schemas.js"; export * from "./validation/types.js"; +export * from "./validation/metadataSanitiser.js"; export * from "./diagnostics/index.js"; export * from "./headers/index.js"; export * from "./middleware/index.js"; diff --git a/src/validation/metadataSanitiser.ts b/src/validation/metadataSanitiser.ts new file mode 100644 index 0000000..d5c1b96 --- /dev/null +++ b/src/validation/metadataSanitiser.ts @@ -0,0 +1,297 @@ +import type { ValidationError, ValidationResult } from "./types.js"; + +/** + * A JSON-safe value type produced by the sanitiser. + * Only contains primitives, arrays, and plain objects with string keys. + */ +export type JsonSafeValue = + | null + | boolean + | number + | string + | JsonSafeValue[] + | { [key: string]: JsonSafeValue }; + +/** + * Configuration options for the metadata sanitiser. + */ +export interface MetadataSanitiserOptions { + /** Maximum recursion depth. @default 20 */ + maxDepth?: number; + /** Maximum allowed array length. @default 1000 */ + maxArrayLength?: number; + /** Maximum allowed object key count. @default 100 */ + maxObjectKeyCount?: number; +} + +/** Default maximum recursion depth. */ +const DEFAULT_MAX_DEPTH = 20; + +/** Default maximum array length. */ +const DEFAULT_MAX_ARRAY_LENGTH = 1000; + +/** Default maximum object key count. */ +const DEFAULT_MAX_OBJECT_KEY_COUNT = 100; + +/** Keys that must never be copied to prevent prototype pollution. */ +const DANGEROUS_KEYS = new Set(["__proto__", "constructor", "prototype"]); + +/** + * Recursively sanitises an arbitrary runtime value into a deterministic, + * JSON-safe representation. Returns a structured validation failure when the + * input contains values that cannot be safely serialised. + * + * - Accepts: `null`, `boolean`, `string`, finite `number`, arrays, plain + * objects with own enumerable string keys. + * - Rejects: `undefined`, `NaN`, `Infinity`, `-Infinity`, `BigInt`, `Symbol`, + * `Function`, class instances with non-plain prototypes, circular structures, + * and values exceeding configured depth/collection limits. + * + * The returned output is always freshly allocated — the input is never mutated. + * + * @param input - Arbitrary runtime value to sanitise. + * @param options - Optional configuration for depth, array, and key limits. + * @returns A `ValidationResult` containing the sanitised value or an error + * with the exact failing path. + */ +export function sanitiseMetadata( + input: unknown, + options?: MetadataSanitiserOptions, +): ValidationResult { + const maxDepth = options?.maxDepth ?? DEFAULT_MAX_DEPTH; + const maxArrayLength = options?.maxArrayLength ?? DEFAULT_MAX_ARRAY_LENGTH; + const maxObjectKeyCount = + options?.maxObjectKeyCount ?? DEFAULT_MAX_OBJECT_KEY_COUNT; + + const seen = new Set(); + + function sanitise( + value: unknown, + path: string[], + depth: number, + ): ValidationResult { + // ── depth guard ────────────────────────────────────────────────────── + if (depth > maxDepth) { + return { + success: false, + error: createError( + `Maximum depth of ${maxDepth} exceeded`, + path, + ), + }; + } + + // ── null ───────────────────────────────────────────────────────────── + if (value === null) { + return { success: true, data: null }; + } + + // ── primitives ─────────────────────────────────────────────────────── + if (typeof value === "boolean") { + return { success: true, data: value }; + } + + if (typeof value === "string") { + return { success: true, data: value }; + } + + if (typeof value === "number") { + if (!Number.isFinite(value)) { + return { + success: false, + error: createError( + `Invalid number value: ${String(value)}`, + path, + ), + }; + } + return { success: true, data: value }; + } + + // ── unsupported primitives ─────────────────────────────────────────── + if (typeof value === "bigint") { + return { + success: false, + error: createError( + "BigInt values are not JSON-safe and must be converted before sanitisation", + path, + ), + }; + } + + if (typeof value === "symbol") { + return { + success: false, + error: createError("Symbol values are not JSON-safe", path), + }; + } + + if (typeof value === "function") { + return { + success: false, + error: createError("Function values are not JSON-safe", path), + }; + } + + if (typeof value === "undefined") { + return { + success: false, + error: createError("undefined is not JSON-safe", path), + }; + } + + // ── arrays ─────────────────────────────────────────────────────────── + if (Array.isArray(value)) { + if (value.length > maxArrayLength) { + return { + success: false, + error: createError( + `Array length ${value.length} exceeds maximum of ${maxArrayLength}`, + path, + ), + }; + } + + // Cycle detection for arrays. + if (seen.has(value as object)) { + return { + success: false, + error: createError("Circular reference detected", path), + }; + } + seen.add(value as object); + + try { + const result: JsonSafeValue[] = []; + for (let i = 0; i < value.length; i++) { + const itemPath = [...path, `[${i}]`]; + const itemResult = sanitise(value[i], itemPath, depth + 1); + if (!itemResult.success) { + return itemResult; + } + result.push(itemResult.data); + } + + return { success: true, data: result }; + } finally { + seen.delete(value as object); + } + } + + // ── objects (plain objects only) ───────────────────────────────────── + if (typeof value === "object") { + // Reject class instances — only plain objects (created via {} / Object.create(null)) + // or plain prototypes are accepted. + const proto = Object.getPrototypeOf(value); + if (proto !== null && proto !== Object.prototype) { + return { + success: false, + error: createError( + "Class instances and non-plain objects are not JSON-safe", + path, + ), + }; + } + + // Cycle detection — must track before recursing into children. + if (seen.has(value as object)) { + return { + success: false, + error: createError("Circular reference detected", path), + }; + } + seen.add(value as object); + + try { + const obj = value as Record; + const keys = Object.keys(obj); + + if (keys.length > maxObjectKeyCount) { + return { + success: false, + error: createError( + `Object key count ${keys.length} exceeds maximum of ${maxObjectKeyCount}`, + path, + ), + }; + } + + const result: Record = {}; + for (const key of keys) { + if (DANGEROUS_KEYS.has(key)) { + return { + success: false, + error: createError( + `Unsafe object key "${key}" is not allowed`, + [...path, key], + ), + }; + } + + const fieldPath = [...path, key]; + const fieldResult = sanitise(obj[key], fieldPath, depth + 1); + if (!fieldResult.success) { + return fieldResult; + } + result[key] = fieldResult.data; + } + + return { success: true, data: result }; + } finally { + // Remove from tracking set so the same non-cyclic object can appear + // in multiple places without being falsely flagged. + seen.delete(value as object); + } + } + + // ── fallthrough — anything else (e.g. boxed primitives, RegExp, Map…) ─ + return { + success: false, + error: createError( + `Unsupported value of type ${typeof value}`, + path, + ), + }; + } + + function createError(message: string, errorPath: string[]): ValidationError { + return { message, path: errorPath }; + } + + return sanitise(input, [], 0); +} + +/** + * Convenience wrapper that returns only the sanitised value, throwing a + * {@link SanitisationError} when the input is unsafe. + * + * @throws {SanitisationError} If the input cannot be safely sanitised. + */ +export function sanitiseMetadataOrThrow( + input: unknown, + options?: MetadataSanitiserOptions, +): JsonSafeValue { + const result = sanitiseMetadata(input, options); + if (!result.success) { + throw new SanitisationError(result.error); + } + return result.data; +} + +/** + * Error thrown by {@link sanitiseMetadataOrThrow} when sanitisation fails. + */ +export class SanitisationError extends Error { + /** The structured validation error describing the failure. */ + public readonly validationError: ValidationError; + + constructor(validationError: ValidationError) { + const pathStr = + validationError.path.length > 0 + ? validationError.path.join(".") + : "(root)"; + super(`Sanitisation failed at ${pathStr}: ${validationError.message}`); + this.name = "SanitisationError"; + this.validationError = validationError; + } +} diff --git a/tests/validation/metadataSanitiser.test.ts b/tests/validation/metadataSanitiser.test.ts new file mode 100644 index 0000000..9d9aca7 --- /dev/null +++ b/tests/validation/metadataSanitiser.test.ts @@ -0,0 +1,859 @@ +import { describe, it, expect } from "vitest"; +import { + sanitiseMetadata, + sanitiseMetadataOrThrow, + SanitisationError, +} from "../../src/validation/metadataSanitiser"; +import type { JsonSafeValue } from "../../src/validation/metadataSanitiser"; + +// ─── Valid primitives ───────────────────────────────────────────────────── + +describe("Valid primitives", () => { + it("should accept null", () => { + const result = sanitiseMetadata(null); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toBeNull(); + } + }); + + it("should accept true", () => { + const result = sanitiseMetadata(true); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toBe(true); + } + }); + + it("should accept false", () => { + const result = sanitiseMetadata(false); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toBe(false); + } + }); + + it("should accept empty string", () => { + const result = sanitiseMetadata(""); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toBe(""); + } + }); + + it("should accept a normal string", () => { + const result = sanitiseMetadata("hello world"); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toBe("hello world"); + } + }); + + it("should accept finite positive numbers", () => { + const result = sanitiseMetadata(42); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toBe(42); + } + }); + + it("should accept finite negative numbers", () => { + const result = sanitiseMetadata(-100); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toBe(-100); + } + }); + + it("should accept zero", () => { + const result = sanitiseMetadata(0); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toBe(0); + } + }); + + it("should accept negative zero", () => { + const result = sanitiseMetadata(-0); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toBe(-0); + } + }); + + it("should accept decimal numbers", () => { + const result = sanitiseMetadata(3.14); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toBe(3.14); + } + }); + + it("should accept very small numbers", () => { + const result = sanitiseMetadata(Number.MIN_VALUE); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toBe(Number.MIN_VALUE); + } + }); + + it("should accept Number.MAX_SAFE_INTEGER", () => { + const result = sanitiseMetadata(Number.MAX_SAFE_INTEGER); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toBe(Number.MAX_SAFE_INTEGER); + } + }); +}); + +// ─── Invalid numbers ───────────────────────────────────────────────────── + +describe("Invalid numbers", () => { + it("should reject NaN", () => { + const result = sanitiseMetadata(NaN); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.message).toContain("Invalid number value"); + expect(result.error.path).toEqual([]); + } + }); + + it("should reject Infinity", () => { + const result = sanitiseMetadata(Infinity); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.message).toContain("Invalid number value"); + } + }); + + it("should reject -Infinity", () => { + const result = sanitiseMetadata(-Infinity); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.message).toContain("Invalid number value"); + } + }); +}); + +// ─── Unsupported values ────────────────────────────────────────────────── + +describe("Unsupported values", () => { + it("should reject undefined", () => { + const result = sanitiseMetadata(undefined); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.message).toBe("undefined is not JSON-safe"); + expect(result.error.path).toEqual([]); + } + }); + + it("should reject functions", () => { + const fn = () => {}; + const result = sanitiseMetadata(fn); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.message).toBe("Function values are not JSON-safe"); + } + }); + + it("should reject arrow functions", () => { + const result = sanitiseMetadata(() => 42); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.message).toBe("Function values are not JSON-safe"); + } + }); + + it("should reject symbols", () => { + const result = sanitiseMetadata(Symbol("test")); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.message).toBe("Symbol values are not JSON-safe"); + } + }); + + it("should reject BigInt", () => { + const result = sanitiseMetadata(BigInt(42)); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.message).toContain("BigInt"); + } + }); + + it("should reject RegExp", () => { + const result = sanitiseMetadata(/test/); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.message).toContain("Class instances"); + } + }); + + it("should reject Date objects", () => { + const result = sanitiseMetadata(new Date()); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.message).toContain("Class instances"); + } + }); + + it("should reject Map", () => { + const result = sanitiseMetadata(new Map([["a", 1]])); + expect(result.success).toBe(false); + }); + + it("should reject Set", () => { + const result = sanitiseMetadata(new Set([1, 2, 3])); + expect(result.success).toBe(false); + }); + + it("should reject Error instances", () => { + const result = sanitiseMetadata(new Error("test")); + expect(result.success).toBe(false); + }); + + it("should reject boxed primitives", () => { + // eslint-disable-next-line no-new-wrappers + const result = sanitiseMetadata(new String("hello")); + expect(result.success).toBe(false); + }); +}); + +// ─── Arrays ────────────────────────────────────────────────────────────── + +describe("Arrays", () => { + it("should accept empty arrays", () => { + const result = sanitiseMetadata([]); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toEqual([]); + } + }); + + it("should accept arrays of primitives", () => { + const result = sanitiseMetadata([1, "two", true, null]); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toEqual([1, "two", true, null]); + } + }); + + it("should accept nested arrays", () => { + const result = sanitiseMetadata([[1, 2], [3, [4, 5]]]); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toEqual([[1, 2], [3, [4, 5]]]); + } + }); + + it("should accept arrays of objects", () => { + const input = [{ name: "Alice" }, { name: "Bob" }]; + const result = sanitiseMetadata(input); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toEqual([{ name: "Alice" }, { name: "Bob" }]); + } + }); + + it("should report failing index in path", () => { + const result = sanitiseMetadata([1, undefined, 3]); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.path).toEqual(["[1]"]); + expect(result.error.message).toContain("undefined"); + } + }); + + it("should report nested index for invalid values in arrays of arrays", () => { + const result = sanitiseMetadata([[1], [Symbol("bad")]]); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.path).toEqual(["[1]", "[0]"]); + } + }); + + it("should reject arrays exceeding max length", () => { + const bigArray = new Array(1001).fill(1); + const result = sanitiseMetadata(bigArray, { maxArrayLength: 1000 }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.message).toContain("exceeds maximum"); + expect(result.error.path).toEqual([]); + } + }); + + it("should accept arrays within max length", () => { + const array = new Array(1000).fill(1); + const result = sanitiseMetadata(array, { maxArrayLength: 1000 }); + expect(result.success).toBe(true); + }); +}); + +// ─── Objects ───────────────────────────────────────────────────────────── + +describe("Objects", () => { + it("should accept empty objects", () => { + const result = sanitiseMetadata({}); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toEqual({}); + } + }); + + it("should accept plain objects with primitive values", () => { + const input = { name: "Alice", age: 30, active: true }; + const result = sanitiseMetadata(input); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toEqual({ name: "Alice", age: 30, active: true }); + } + }); + + it("should accept nested objects", () => { + const input = { user: { profile: { name: "Alice" } } }; + const result = sanitiseMetadata(input); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toEqual({ user: { profile: { name: "Alice" } } }); + } + }); + + it("should accept objects with array values", () => { + const input = { tags: ["admin", "user"], scores: [100, 200] }; + const result = sanitiseMetadata(input); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toEqual({ + tags: ["admin", "user"], + scores: [100, 200], + }); + } + }); + + it("should reject objects with non-plain prototypes", () => { + const proto = { inherited: true }; + const obj = Object.create(proto); + obj.own = "yes"; + const result = sanitiseMetadata(obj); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.message).toContain("Class instances"); + } + }); + + it("should accept Object.create(null) objects (no prototype)", () => { + const obj = Object.create(null); + obj.own = "yes"; + const result = sanitiseMetadata(obj); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toEqual({ own: "yes" }); + } + }); + + it("should reject objects exceeding max key count", () => { + const keys: Record = {}; + for (let i = 0; i < 101; i++) { + keys[`key${i}`] = i; + } + const result = sanitiseMetadata(keys, { maxObjectKeyCount: 100 }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.message).toContain("exceeds maximum"); + } + }); + + it("should accept objects within max key count", () => { + const keys: Record = {}; + for (let i = 0; i < 100; i++) { + keys[`key${i}`] = i; + } + const result = sanitiseMetadata(keys, { maxObjectKeyCount: 100 }); + expect(result.success).toBe(true); + }); +}); + +// ─── Prototype pollution safety ────────────────────────────────────────── + +describe("Prototype pollution safety", () => { + it("should reject __proto__ key", () => { + const input = JSON.parse('{"__proto__": {"polluted": true}}'); + const result = sanitiseMetadata(input); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.path).toContain("__proto__"); + expect(result.error.message).toContain("Unsafe object key"); + } + // Verify no pollution occurred + expect(({} as Record).polluted).toBeUndefined(); + }); + + it("should reject constructor key", () => { + const input = { constructor: { prototype: { polluted: true } } }; + const result = sanitiseMetadata(input); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.path).toContain("constructor"); + expect(result.error.message).toContain("Unsafe object key"); + } + }); + + it("should reject prototype key", () => { + const input = JSON.parse('{"prototype": {"polluted": true}}'); + const result = sanitiseMetadata(input); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.path).toContain("prototype"); + expect(result.error.message).toContain("Unsafe object key"); + } + }); + + it("should reject nested dangerous keys", () => { + const input = JSON.parse( + '{"nested": {"__proto__": {"polluted": true}}}', + ); + const result = sanitiseMetadata(input); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.path).toEqual(["nested", "__proto__"]); + } + }); + + it("should not pollute Object prototype", () => { + const originalKeys = Object.getOwnPropertyNames(Object.prototype); + const input = JSON.parse('{"__proto__": {"polluted": true}}'); + sanitiseMetadata(input); + const afterKeys = Object.getOwnPropertyNames(Object.prototype); + expect(afterKeys).toEqual(originalKeys); + expect(({} as Record).polluted).toBeUndefined(); + }); +}); + +// ─── Circular references ───────────────────────────────────────────────── + +describe("Circular references", () => { + it("should detect direct self-reference", () => { + const value: Record = {}; + value.self = value; + const result = sanitiseMetadata(value); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.message).toContain("Circular reference"); + expect(result.error.path).toEqual(["self"]); + } + }); + + it("should detect nested cycle", () => { + const value: Record = { a: { b: {} } }; + (value.a as Record).c = value; + const result = sanitiseMetadata(value); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.message).toContain("Circular reference"); + } + }); + + it("should detect array self-reference", () => { + const value: unknown[] = [1, 2, 3]; + value.push(value); + const result = sanitiseMetadata(value); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.message).toContain("Circular reference"); + expect(result.error.path).toEqual(["[3]"]); + } + }); + + it("should detect cycle from object to parent array", () => { + const child: Record = {}; + const parent: unknown[] = [child]; + child.parent = parent; + const result = sanitiseMetadata(parent); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.message).toContain("Circular reference"); + } + }); + + it("should allow the same non-cyclic object in multiple places", () => { + const shared = { value: "shared" }; + const input = { a: shared, b: shared }; + const result = sanitiseMetadata(input); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toEqual({ + a: { value: "shared" }, + b: { value: "shared" }, + }); + // Verify independent copies + const data = result.data as Record; + expect(data.a).not.toBe(data.b); + } + }); +}); + +// ─── Maximum depth ─────────────────────────────────────────────────────── + +describe("Maximum depth", () => { + it("should accept values within default depth", () => { + // Create a structure exactly at depth 20 + let value: unknown = "deep"; + for (let i = 0; i < 20; i++) { + value = { value }; + } + const result = sanitiseMetadata(value); + expect(result.success).toBe(true); + }); + + it("should reject values exceeding default depth", () => { + let value: unknown = "deep"; + for (let i = 0; i < 22; i++) { + value = { value }; + } + const result = sanitiseMetadata(value); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.message).toContain("Maximum depth"); + } + }); + + it("should respect custom depth option", () => { + let value: unknown = "deep"; + for (let i = 0; i < 3; i++) { + value = { value }; + } + // Depth 2 should reject this + const result = sanitiseMetadata(value, { maxDepth: 2 }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.message).toContain("Maximum depth of 2"); + } + }); + + it("should accept deep arrays within custom depth", () => { + let value: unknown = "deep"; + for (let i = 0; i < 5; i++) { + value = [value]; + } + const result = sanitiseMetadata(value, { maxDepth: 6 }); + expect(result.success).toBe(true); + }); + + it("should reject deep arrays exceeding custom depth", () => { + let value: unknown = "deep"; + for (let i = 0; i < 6; i++) { + value = [value]; + } + const result = sanitiseMetadata(value, { maxDepth: 5 }); + expect(result.success).toBe(false); + }); +}); + +// ─── Collection limits ─────────────────────────────────────────────────── + +describe("Collection limits", () => { + it("should reject arrays exceeding maxArrayLength", () => { + const arr = new Array(501).fill("x"); + const result = sanitiseMetadata(arr, { maxArrayLength: 500 }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.message).toContain("Array length 501"); + expect(result.error.message).toContain("maximum of 500"); + } + }); + + it("should reject objects exceeding maxObjectKeyCount", () => { + const obj: Record = {}; + for (let i = 0; i < 51; i++) { + obj[`k${i}`] = i; + } + const result = sanitiseMetadata(obj, { maxObjectKeyCount: 50 }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.message).toContain("Object key count 51"); + expect(result.error.message).toContain("maximum of 50"); + } + }); + + it("should report path for nested collection limit violations", () => { + const input = { + items: new Array(101).fill(1), + }; + const result = sanitiseMetadata(input, { maxArrayLength: 100 }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.path).toEqual(["items"]); + } + }); + + it("should accept exactly at limit", () => { + const arr = new Array(10).fill(1); + const result = sanitiseMetadata(arr, { maxArrayLength: 10 }); + expect(result.success).toBe(true); + }); +}); + +// ─── Structured error paths ────────────────────────────────────────────── + +describe("Structured error paths", () => { + it("should report path for top-level undefined", () => { + const result = sanitiseMetadata(undefined); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.path).toEqual([]); + } + }); + + it("should report path for nested invalid values in objects", () => { + const input = { user: { profile: { name: undefined } } }; + const result = sanitiseMetadata(input); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.path).toEqual(["user", "profile", "name"]); + } + }); + + it("should report path for invalid values in arrays", () => { + const input = ["ok", 42, Symbol("bad"), "ok"]; + const result = sanitiseMetadata(input); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.path).toEqual(["[2]"]); + } + }); + + it("should report path for invalid values in nested arrays", () => { + const input = [[1, 2], [3, undefined]]; + const result = sanitiseMetadata(input); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.path).toEqual(["[1]", "[1]"]); + } + }); + + it("should report path for invalid values in mixed structures", () => { + const input = { + users: [ + { name: "Alice", tags: [1, Symbol("bad")] }, + ], + }; + const result = sanitiseMetadata(input); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.path).toEqual(["users", "[0]", "tags", "[1]"]); + } + }); + + it("should report path for NaN deep inside structure", () => { + const input = { level1: { level2: [1, NaN, 3] } }; + const result = sanitiseMetadata(input); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.path).toEqual(["level1", "level2", "[1]"]); + } + }); +}); + +// ─── Determinism ───────────────────────────────────────────────────────── + +describe("Determinism", () => { + it("should produce identical results for the same input", () => { + const input = { a: 1, b: [2, 3], c: { d: "hello" } }; + const result1 = sanitiseMetadata(input); + const result2 = sanitiseMetadata(input); + expect(result1).toEqual(result2); + }); + + it("should produce identical error results for the same invalid input", () => { + const input = { a: undefined }; + const result1 = sanitiseMetadata(input); + const result2 = sanitiseMetadata(input); + expect(result1).toEqual(result2); + }); + + it("should produce identical results for the same options", () => { + const input = new Array(501).fill(1); + const result1 = sanitiseMetadata(input, { maxArrayLength: 500 }); + const result2 = sanitiseMetadata(input, { maxArrayLength: 500 }); + expect(result1).toEqual(result2); + }); +}); + +// ─── No mutation ───────────────────────────────────────────────────────── + +describe("No mutation", () => { + it("should not mutate the input object", () => { + const input = { a: 1, b: [2, 3], c: { d: "hello" } }; + const frozen = JSON.parse(JSON.stringify(input)); + sanitiseMetadata(input); + expect(input).toEqual(frozen); + }); + + it("should not mutate nested objects", () => { + const nested = { inner: { value: "test" } }; + const input = { data: nested }; + const originalInner = JSON.parse(JSON.stringify(nested)); + sanitiseMetadata(input); + expect(nested).toEqual(originalInner); + }); + + it("should return fresh output objects (not references to input)", () => { + const input = { a: { b: 1 } }; + const result = sanitiseMetadata(input); + expect(result.success).toBe(true); + if (result.success) { + const data = result.data as Record; + expect(data.a).not.toBe(input.a); + } + }); + + it("should return fresh arrays (not references to input)", () => { + const input = [1, [2, 3]]; + const result = sanitiseMetadata(input); + expect(result.success).toBe(true); + if (result.success) { + const data = result.data as unknown[]; + expect(data).not.toBe(input); + expect(data[1]).not.toBe(input[1]); + } + }); + + it("should not be affected by post-sanitisation mutations to input", () => { + const input: Record = { a: 1 }; + const result = sanitiseMetadata(input); + input.a = 999; + input.b = "new"; + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toEqual({ a: 1 }); + } + }); +}); + +// ─── sanitiseMetadataOrThrow ───────────────────────────────────────────── + +describe("sanitiseMetadataOrThrow", () => { + it("should return value on success", () => { + const result = sanitiseMetadataOrThrow({ a: 1, b: "hello" }); + expect(result).toEqual({ a: 1, b: "hello" }); + }); + + it("should throw SanitisationError on failure", () => { + expect(() => sanitiseMetadataOrThrow({ a: undefined })).toThrow( + SanitisationError, + ); + }); + + it("should throw with correct message", () => { + try { + sanitiseMetadataOrThrow({ user: { name: NaN } }); + expect.fail("Should have thrown"); + } catch (e) { + expect(e).toBeInstanceOf(SanitisationError); + expect((e as SanitisationError).message).toContain("user.name"); + expect((e as SanitisationError).message).toContain("Invalid number"); + } + }); + + it("should include validation error on the thrown error", () => { + try { + sanitiseMetadataOrThrow(Symbol("bad")); + expect.fail("Should have thrown"); + } catch (e) { + expect(e).toBeInstanceOf(SanitisationError); + const err = e as SanitisationError; + expect(err.validationError.message).toBe("Symbol values are not JSON-safe"); + expect(err.validationError.path).toEqual([]); + } + }); +}); + +// ─── Edge cases ────────────────────────────────────────────────────────── + +describe("Edge cases", () => { + it("should accept Object.create(null)", () => { + const input = Object.create(null); + input.key = "value"; + const result = sanitiseMetadata(input); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toEqual({ key: "value" }); + } + }); + + it("should accept empty string keys", () => { + const input = { "": "empty key" }; + const result = sanitiseMetadata(input); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toEqual({ "": "empty key" }); + } + }); + + it("should accept unicode string keys", () => { + const input = { "日本語": "value", "🔑": "emoji key" }; + const result = sanitiseMetadata(input); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toEqual({ "日本語": "value", "🔑": "emoji key" }); + } + }); + + it("should accept deeply nested valid structures", () => { + let value: unknown = "leaf"; + for (let i = 0; i < 15; i++) { + value = { [`level${i}`]: value }; + } + const result = sanitiseMetadata(value); + expect(result.success).toBe(true); + }); + + it("should handle mixed array with various invalid types", () => { + const input = [1, "ok", null, true, undefined, Symbol("x")]; + const result = sanitiseMetadata(input); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.path).toEqual(["[4]"]); + } + }); + + it("should handle objects with only dangerous keys", () => { + const input = JSON.parse('{"__proto__": 1}'); + const result = sanitiseMetadata(input); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.path).toContain("__proto__"); + } + }); + + it("should accept objects with numeric string keys", () => { + const input = { "0": "a", "1": "b", "2": "c" }; + const result = sanitiseMetadata(input); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toEqual({ "0": "a", "1": "b", "2": "c" }); + } + }); + + it("should handle default options correctly", () => { + const result = sanitiseMetadata({ a: 1 }); + expect(result.success).toBe(true); + }); + + it("should handle all empty options", () => { + const result = sanitiseMetadata({ a: 1 }, {}); + expect(result.success).toBe(true); + }); + + it("should handle class with custom toString", () => { + class Custom { + toString() { + return "custom"; + } + } + const result = sanitiseMetadata(new Custom()); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.message).toContain("Class instances"); + } + }); +});