Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,325 @@
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<string, unknown> = {};
obj.self = obj;
expect(() => safeStringify(obj)).toThrow(JsonError);
});
it("throws JsonError with STRINGIFY.CIRCULAR code on circular reference", {
timeout: 5000,
}, () => {
const obj: Record<string, unknown> = {};
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);
}
)
);
});
});