import { describe, expect, it } from "@suaveplan/testing/runner"; import fc from "fast-check"; import { getStringifySize, isValidJSON, JsonError, safeParse, safeStringify, tryParse, } from "./safe-json.js"; describe("safeParse", () => { it("parses valid JSON", { timeout: 5000 }, () => { expect(safeParse('{"a":1}')).toEqual({ a: 1 }); }); it("parses a JSON array", { timeout: 5000 }, () => { expect(safeParse("[1,2,3]")).toEqual([1, 2, 3]); }); it("parses null literal", { timeout: 5000 }, () => { expect(safeParse("null")).toBeNull(); }); it("parses string literal", { timeout: 5000 }, () => { expect(safeParse('"hello"')).toBe("hello"); }); it("parses number literal", { timeout: 5000 }, () => { expect(safeParse("42")).toBe(42); }); it("parses boolean literals", { timeout: 5000 }, () => { expect(safeParse("true")).toBe(true); expect(safeParse("false")).toBe(false); }); it("rejects __proto__ key", { timeout: 5000 }, () => { expect(() => safeParse('{"__proto__":{"x":1}}')).toThrow(JsonError); }); it("rejects constructor key", { timeout: 5000 }, () => { expect(() => safeParse('{"constructor":{"x":1}}')).toThrow(JsonError); }); it("rejects prototype key", { timeout: 5000 }, () => { expect(() => safeParse('{"prototype":{"x":1}}')).toThrow(JsonError); }); it("throws JsonError with PARSE.PROTOTYPE_POLLUTION code on forbidden keys", { timeout: 5000, }, () => { try { safeParse('{"__proto__":{}}'); expect.fail("should have thrown"); } catch (err) { expect(err).toBeInstanceOf(JsonError); expect((err as JsonError).code).toBe( "JSON.PARSE.PROTOTYPE_POLLUTION" ); } }); it("throws on invalid JSON", { timeout: 5000 }, () => { expect(() => safeParse("not json")).toThrow(JsonError); }); it("throws JsonError with PARSE.FAILED code on invalid JSON", { timeout: 5000, }, () => { try { safeParse("{bad}"); expect.fail("should have thrown"); } catch (err) { expect(err).toBeInstanceOf(JsonError); expect((err as JsonError).code).toBe("JSON.PARSE.FAILED"); } }); it("allows custom forbidden keys — empty list allows __proto__", { timeout: 5000, }, () => { expect(() => safeParse('{"__proto__":{}}', { forbiddenKeys: [] }) ).not.toThrow(); }); it("allows custom forbidden keys — custom key blocks the key", { timeout: 5000, }, () => { expect(() => safeParse('{"evil":1}', { forbiddenKeys: ["evil"] }) ).toThrow(JsonError); }); it("throws when AbortSignal is already aborted", { timeout: 5000 }, () => { const controller = new AbortController(); controller.abort(); expect(() => safeParse('{"a":1}', { signal: controller.signal }) ).toThrow(JsonError); }); it("parses nested objects successfully", { timeout: 5000 }, () => { const result = safeParse('{"a":{"b":{"c":1}}}'); expect(result).toEqual({ a: { b: { c: 1 } } }); }); }); describe("safeStringify", () => { it("serialises a plain object", { timeout: 5000 }, () => { expect(safeStringify({ a: 1 })).toBe('{"a":1}'); }); it("serialises with space option", { timeout: 5000 }, () => { expect(safeStringify({ a: 1 }, { space: 2 })).toBe('{\n "a": 1\n}'); }); it("serialises arrays", { timeout: 5000 }, () => { expect(safeStringify([1, 2, 3])).toBe("[1,2,3]"); }); it("serialises null", { timeout: 5000 }, () => { expect(safeStringify(null)).toBe("null"); }); it("serialises primitives", { timeout: 5000 }, () => { expect(safeStringify(42)).toBe("42"); expect(safeStringify(true)).toBe("true"); expect(safeStringify("hello")).toBe('"hello"'); }); it("throws on circular reference", { timeout: 5000 }, () => { const obj: Record = {}; obj.self = obj; expect(() => safeStringify(obj)).toThrow(JsonError); }); it("throws JsonError with STRINGIFY.CIRCULAR code on circular reference", { timeout: 5000, }, () => { const obj: Record = {}; obj.self = obj; try { safeStringify(obj); expect.fail("should have thrown"); } catch (err) { expect(err).toBeInstanceOf(JsonError); expect((err as JsonError).code).toBe("JSON.STRINGIFY.CIRCULAR"); } }); it("throws when AbortSignal is already aborted", { timeout: 5000 }, () => { const controller = new AbortController(); controller.abort(); expect(() => safeStringify({ a: 1 }, { signal: controller.signal }) ).toThrow(JsonError); }); it("handles nested objects without circular refs", { timeout: 5000, }, () => { const obj = { a: { b: { c: 1 } } }; expect(safeStringify(obj)).toBe('{"a":{"b":{"c":1}}}'); }); it("throws JsonError with STRINGIFY.FAILED code for non-serialisable values (BigInt)", { timeout: 5000, }, () => { try { safeStringify({ big: BigInt(1) }); expect.fail("should have thrown"); } catch (err) { expect(err).toBeInstanceOf(JsonError); expect((err as JsonError).code).toBe("JSON.STRINGIFY.FAILED"); } }); it("allows same object reference at different paths (not a cycle)", { timeout: 5000, }, () => { const shared = { x: 1 }; const obj = { a: shared, b: shared }; // DAG sharing is fine — only actual cycles throw expect(() => safeStringify(obj)).not.toThrow(); }); }); describe("tryParse", () => { it("returns undefined on invalid JSON", { timeout: 5000 }, () => { expect(tryParse("bad")).toBeUndefined(); }); it("returns parsed value on valid JSON", { timeout: 5000 }, () => { expect(tryParse("42")).toBe(42); }); it("returns null for JSON null", { timeout: 5000 }, () => { expect(tryParse("null")).toBeNull(); }); it("returns undefined when safeParse throws for prototype pollution", { timeout: 5000, }, () => { expect(tryParse('{"__proto__":{}}')).toBeUndefined(); }); it("returns parsed object", { timeout: 5000 }, () => { expect(tryParse('{"a":1}')).toEqual({ a: 1 }); }); it("passes options through to safeParse", { timeout: 5000 }, () => { // With empty forbidden keys, __proto__ should NOT be blocked expect( tryParse('{"__proto__":{}}', { forbiddenKeys: [] }) ).toBeDefined(); }); }); describe("isValidJSON", () => { it("returns true for valid JSON object", { timeout: 5000 }, () => { expect(isValidJSON('{"x":1}')).toBe(true); }); it("returns false for invalid JSON", { timeout: 5000 }, () => { expect(isValidJSON("not json")).toBe(false); }); it("returns true for JSON null literal", { timeout: 5000 }, () => { expect(isValidJSON("null")).toBe(true); }); it("returns true for JSON string", { timeout: 5000 }, () => { expect(isValidJSON('"hello"')).toBe(true); }); it("returns true for JSON number", { timeout: 5000 }, () => { expect(isValidJSON("42")).toBe(true); }); it("returns true for JSON boolean", { timeout: 5000 }, () => { expect(isValidJSON("true")).toBe(true); expect(isValidJSON("false")).toBe(true); }); it("returns true for JSON array", { timeout: 5000 }, () => { expect(isValidJSON("[1,2,3]")).toBe(true); }); it("returns false for empty string", { timeout: 5000 }, () => { expect(isValidJSON("")).toBe(false); }); it("returns false for prototype-polluting JSON (forbidden key)", { timeout: 5000, }, () => { // isValidJSON uses safeParse internally which blocks __proto__ by default expect(isValidJSON('{"__proto__":{}}')).toBe(false); }); }); describe("getStringifySize", () => { it("returns byte size of serialised value", { timeout: 5000 }, () => { const value = { hello: "world" }; const size = getStringifySize(value); expect(size).toBe( new TextEncoder().encode(JSON.stringify(value)).byteLength ); }); it("returns correct size for string with multi-byte chars", { timeout: 5000, }, () => { const value = { emoji: "🎉" }; const size = getStringifySize(value); expect(size).toBe( new TextEncoder().encode(JSON.stringify(value)).byteLength ); }); it("returns correct size for simple number", { timeout: 5000 }, () => { expect(getStringifySize(42)).toBe(2); }); }); describe("property tests", () => { it("safeParse round-trips any JSON-serialisable value not containing forbidden keys", { timeout: 30000, }, () => { const FORBIDDEN = new Set(["__proto__", "constructor", "prototype"]); // JSON has no negative-zero literal: `JSON.stringify(-0)` → `"0"`, so a // value containing `-0` cannot round-trip losslessly through JSON. // Filter such values out of the property to keep the invariant honest. function isUnRoundTrippable(v: unknown): boolean { if (Object.is(v, -0)) return true; if (typeof v !== "object" || v === null) return false; if (Array.isArray(v)) return v.some(isUnRoundTrippable); return Object.entries(v as object).some( ([k, val]) => FORBIDDEN.has(k) || isUnRoundTrippable(val) ); } fc.assert( fc.property( fc.jsonValue().filter((v) => !isUnRoundTrippable(v)), (value) => { expect(safeParse(JSON.stringify(value))).toEqual(value); } ) ); }); it("safeStringify + safeParse is identity for clean objects", { timeout: 30000, }, () => { fc.assert( fc.property( fc.record({ a: fc.integer(), b: fc.string() }), (obj) => { expect(safeParse(safeStringify(obj))).toEqual(obj); } ) ); }); });