284 lines
9.7 KiB
TypeScript
284 lines
9.7 KiB
TypeScript
/**
|
|
* Spec-conformance tests for SZ-10 — Zod schema validation at the public
|
|
* boundary. Pins:
|
|
* - WireNodeSchema accepts every valid wire shape produced by serialize.
|
|
* - WireNodeSchema rejects malformed envelopes (non-string __t, extra keys).
|
|
* - deserialize rejects malformed JSON wire input with MalformedWireError.
|
|
* - decodeBinary rejects malformed msgpack wire input with MalformedWireError.
|
|
* - serialize / deserialize / decodeBinary reject invalid options with
|
|
* InvalidOptionsError (typed by stable error code).
|
|
*/
|
|
import { describe, expect, expectTypeOf, it } from "@suaveplan/testing/runner";
|
|
import {
|
|
InvalidOptionsError,
|
|
MalformedWireError,
|
|
} from "../../errors/serialize-error.js";
|
|
import { registerType, unregisterType } from "../../registry/registry.js";
|
|
import { deserialize } from "../json/json-decode.js";
|
|
import { serialize } from "../json/json-encode.js";
|
|
import { decodeBinary } from "../msgpack/msgpack-decode.js";
|
|
import { encodeBinary } from "../msgpack/msgpack-encode.js";
|
|
import {
|
|
TaggedEnvelopeSchema,
|
|
validateWireNode,
|
|
WireNodeSchema,
|
|
} from "./wire.schemas.js";
|
|
|
|
describe("SZ-10 — WireNodeSchema acceptance", () => {
|
|
it("accepts JSON primitives", { timeout: 5000 }, () => {
|
|
for (const v of [null, true, false, 1, "x"]) {
|
|
expect(WireNodeSchema.safeParse(v).success).toBe(true);
|
|
}
|
|
});
|
|
|
|
it("accepts arrays of nodes", { timeout: 5000 }, () => {
|
|
expect(WireNodeSchema.safeParse([1, "x", null]).success).toBe(true);
|
|
});
|
|
|
|
it("accepts plain records", { timeout: 5000 }, () => {
|
|
expect(WireNodeSchema.safeParse({ a: 1, b: "x" }).success).toBe(true);
|
|
});
|
|
|
|
it("accepts a tagged envelope", { timeout: 5000 }, () => {
|
|
expect(
|
|
WireNodeSchema.safeParse({ __t: "__Date", v: "2024-01-01" }).success
|
|
).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("SZ-10 — WireNodeSchema rejection", () => {
|
|
it("rejects undefined", { timeout: 5000 }, () => {
|
|
expect(WireNodeSchema.safeParse(undefined).success).toBe(false);
|
|
});
|
|
|
|
it("rejects symbols and functions", { timeout: 5000 }, () => {
|
|
expect(WireNodeSchema.safeParse(Symbol("x")).success).toBe(false);
|
|
expect(WireNodeSchema.safeParse(() => 0).success).toBe(false);
|
|
});
|
|
|
|
it("strict envelope rejects extra keys", { timeout: 5000 }, () => {
|
|
const r = TaggedEnvelopeSchema.safeParse({
|
|
__t: "__Date",
|
|
v: "x",
|
|
extra: 1,
|
|
});
|
|
expect(r.success).toBe(false);
|
|
});
|
|
|
|
it("validateWireNode returns ZodError on rejection", {
|
|
timeout: 5000,
|
|
}, () => {
|
|
const r = validateWireNode(undefined);
|
|
expect(r.ok).toBe(false);
|
|
if (!r.ok) {
|
|
expect(r.error.issues.length).toBeGreaterThan(0);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("SZ-10 — deserialize boundary validation", () => {
|
|
it("invalid JSON throws MalformedWireError", { timeout: 5000 }, () => {
|
|
expect(() => deserialize("{not json")).toThrowError(MalformedWireError);
|
|
});
|
|
|
|
it("malformed envelope (non-string __t) throws MalformedWireError", {
|
|
timeout: 5000,
|
|
}, () => {
|
|
const bad = JSON.stringify({ __t: 42, v: null });
|
|
expect(() => deserialize(bad)).toThrowError(MalformedWireError);
|
|
});
|
|
|
|
it("MalformedWireError carries CODEC.MALFORMED_WIRE code", {
|
|
timeout: 5000,
|
|
}, () => {
|
|
try {
|
|
deserialize("{not json");
|
|
} catch (e) {
|
|
expect((e as MalformedWireError).code).toBe(
|
|
"SERIALIZE.CODEC.MALFORMED_WIRE"
|
|
);
|
|
return;
|
|
}
|
|
throw new Error("expected throw");
|
|
});
|
|
});
|
|
|
|
describe("SZ-10 — decodeBinary boundary validation", () => {
|
|
it("invalid msgpack bytes throw MalformedWireError", {
|
|
timeout: 5000,
|
|
}, () => {
|
|
// 0xc1 is reserved/never-used in msgpack and triggers a decode error.
|
|
const bad = new Uint8Array([0xc1, 0xc1, 0xc1]);
|
|
expect(() => decodeBinary(bad)).toThrowError(MalformedWireError);
|
|
});
|
|
|
|
it("round-trips valid msgpack input", { timeout: 5000 }, () => {
|
|
const bytes = encodeBinary({ a: 1, b: "two" });
|
|
const back = decodeBinary(bytes);
|
|
expect(back).toEqual({ a: 1, b: "two" });
|
|
});
|
|
});
|
|
|
|
describe("SZ-10 — invalid options rejection", () => {
|
|
it("serialize throws InvalidOptionsError for non-positive maxDepth", {
|
|
timeout: 5000,
|
|
}, () => {
|
|
expect(() => serialize({}, { maxDepth: -1 } as never)).toThrowError(
|
|
InvalidOptionsError
|
|
);
|
|
});
|
|
|
|
it("serialize throws InvalidOptionsError for non-positive maxSize", {
|
|
timeout: 5000,
|
|
}, () => {
|
|
expect(() => serialize({}, { maxSize: 0 } as never)).toThrowError(
|
|
InvalidOptionsError
|
|
);
|
|
});
|
|
|
|
it("serialize throws InvalidOptionsError for unknown option keys", {
|
|
timeout: 5000,
|
|
}, () => {
|
|
expect(() => serialize({}, { unknown: 1 } as never)).toThrowError(
|
|
InvalidOptionsError
|
|
);
|
|
});
|
|
|
|
it("deserialize throws InvalidOptionsError for non-AbortSignal signal", {
|
|
timeout: 5000,
|
|
}, () => {
|
|
expect(() =>
|
|
deserialize("null", { signal: "nope" as unknown as AbortSignal })
|
|
).toThrowError(InvalidOptionsError);
|
|
});
|
|
|
|
it("encodeBinary throws InvalidOptionsError for non-positive maxSize", {
|
|
timeout: 5000,
|
|
}, () => {
|
|
expect(() => encodeBinary({}, { maxSize: -10 } as never)).toThrowError(
|
|
InvalidOptionsError
|
|
);
|
|
});
|
|
|
|
it("decodeBinary throws InvalidOptionsError for unknown option keys", {
|
|
timeout: 5000,
|
|
}, () => {
|
|
const bytes = encodeBinary({ a: 1 });
|
|
expect(() => decodeBinary(bytes, { unknown: 1 } as never)).toThrowError(
|
|
InvalidOptionsError
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("SZ-10 — error class branch coverage", () => {
|
|
it("MalformedWireError works without a path argument", {
|
|
timeout: 5000,
|
|
}, () => {
|
|
const err = new MalformedWireError("no-path");
|
|
expect(err.code).toBe("SERIALIZE.CODEC.MALFORMED_WIRE");
|
|
expect(err.context?.["tag"]).toBe("no-path");
|
|
expect(err.context?.["path"]).toBeUndefined();
|
|
});
|
|
|
|
it("InvalidOptionsError works with cause", { timeout: 5000 }, () => {
|
|
const cause = new Error("inner");
|
|
const err = new InvalidOptionsError("invalid", cause);
|
|
expect(err.code).toBe("SERIALIZE.OPTIONS.INVALID");
|
|
expect(err.cause).toBe(cause);
|
|
});
|
|
});
|
|
|
|
describe("SZ-10 — msgpack wire-schema rejection", () => {
|
|
it("decodeBinary rejects msgpack-encoded malformed envelopes", {
|
|
timeout: 5000,
|
|
}, () => {
|
|
// Build a real msgpack buffer for { __t: 1, v: 1 } using @msgpack/msgpack
|
|
// directly — bypassing our encoder so the wire-schema rejection path
|
|
// can be exercised independently of JSON.parse.
|
|
// 0x82 = fixmap of 2; 0xa3 "__t" = fixstr; 0x01 = 1; 0xa1 "v" = fixstr; 0x01 = 1.
|
|
const bytes = new Uint8Array([
|
|
0x82, 0xa3, 0x5f, 0x5f, 0x74, 0x01, 0xa1, 0x76, 0x01,
|
|
]);
|
|
expect(() => decodeBinary(bytes)).toThrowError(MalformedWireError);
|
|
});
|
|
});
|
|
|
|
describe("SZ-10 — encode/decode error label fallback", () => {
|
|
it("encode error path tags non-Error throws as SERIALIZE.CODEC.UNKNOWN", {
|
|
timeout: 5000,
|
|
}, () => {
|
|
// Register a custom encode function that throws a non-Error value to
|
|
// exercise the `: "SERIALIZE.CODEC.UNKNOWN"` branch in both codecs.
|
|
const tag = "__SerializeEncodeBranchProbe";
|
|
const ctor = class EncodeBranchProbe {};
|
|
registerType({
|
|
tag,
|
|
ctor,
|
|
encode: () => {
|
|
throw "raw-string-throw";
|
|
},
|
|
decode: (v) => v,
|
|
});
|
|
try {
|
|
const probe = new ctor();
|
|
expect(() => serialize(probe)).toThrow();
|
|
expect(() => encodeBinary(probe)).toThrow();
|
|
} finally {
|
|
unregisterType(tag);
|
|
}
|
|
});
|
|
|
|
it("decode error path tags non-Error throws as SERIALIZE.CODEC.UNKNOWN", {
|
|
timeout: 5000,
|
|
}, () => {
|
|
// Register a custom decode function that throws a non-Error value to
|
|
// exercise the `: "SERIALIZE.CODEC.UNKNOWN"` branch on the decode side.
|
|
const tag = "__SerializeDecodeBranchProbe";
|
|
const ctor = class DecodeBranchProbe {};
|
|
registerType({
|
|
tag,
|
|
ctor,
|
|
encode: () => null,
|
|
decode: () => {
|
|
throw "raw-string-throw";
|
|
},
|
|
});
|
|
try {
|
|
const probe = new ctor();
|
|
const json = serialize(probe);
|
|
expect(() => deserialize(json)).toThrow();
|
|
const bytes = encodeBinary(probe);
|
|
expect(() => decodeBinary(bytes)).toThrow();
|
|
} finally {
|
|
unregisterType(tag);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("SZ-10 — generic threading", () => {
|
|
it("deserialize<T>() returns T at the type level", {
|
|
timeout: 5000,
|
|
}, () => {
|
|
const json = serialize({ name: "ada", age: 36 });
|
|
const result = deserialize<{ name: string; age: number }>(json);
|
|
expectTypeOf(result).toEqualTypeOf<{ name: string; age: number }>();
|
|
expect(result.name).toBe("ada");
|
|
});
|
|
|
|
it("decodeBinary<T>() returns T at the type level", {
|
|
timeout: 5000,
|
|
}, () => {
|
|
const bytes = encodeBinary([1, 2, 3]);
|
|
const result = decodeBinary<number[]>(bytes);
|
|
expectTypeOf(result).toEqualTypeOf<number[]>();
|
|
expect(result).toEqual([1, 2, 3]);
|
|
});
|
|
|
|
it("deserialize() defaults to unknown when T is omitted", {
|
|
timeout: 5000,
|
|
}, () => {
|
|
const result = deserialize(serialize({ a: 1 }));
|
|
expectTypeOf(result).toEqualTypeOf<unknown>();
|
|
});
|
|
});
|