/** * Copyright (c) 2026, Salesforce, Inc., * All rights reserved. * For full license text, see the LICENSE.txt file */ import { describe, expect, it } from "vitest"; import { z } from "zod"; import { enumStripControlChars } from "../fields.js"; /** * Unit-level contract for enumStripControlChars (W-23336443): the z.preprocess * wrapper that STRIPS Unicode control/format chars from a string before enum * validation, so a poisoned value can never be reflected verbatim by the MCP * SDK's upstream validation. End-to-end proof that it closes the actual SDK * reflection channel lives in the sf-gql-* tool specs; here we pin the wrapper's * behavior in isolation (accept-after-strip, clean rejection, pass-throughs) and * document the discriminatedUnion caveat with an executable proof. * * Injected chars are `\u` escapes, not literals, so the source has no invisible * bytes. Representative one-per-class: U+202E (bidi RLO), U+200B (ZWSP), U+007F * (DEL) -- the exact trio the SDK's JSON.stringify leaves raw (it escapes only * C0, U+0000-U+001F). */ describe("schemas/fields — enumStripControlChars (W-23336443)", () => { const MODE = enumStripControlChars(z.enum(["list_objects", "describe_object", "describe_field"])); describe("accept-after-strip", () => { it.each([ ["trailing bidi RLO", "describe_object\u{202e}", "describe_object"], ["leading ZWSP", "\u{200b}list_objects", "list_objects"], ["embedded DEL", "describe\x7f_field", "describe_field"], ["interior ZWJ", "describe_ob\u{200d}ject", "describe_object"], ])("%s strips to the valid member and PARSES to it", (_label, input, expected) => { const result = MODE.safeParse(input); expect(result.success).toBe(true); if (result.success) expect(result.data).toBe(expected); }); it("a clean value is unchanged", () => { const result = MODE.safeParse("list_objects"); expect(result.success).toBe(true); if (result.success) expect(result.data).toBe("list_objects"); }); it.each([ ["trailing line separator U+2028", "list_objects\u{2028}", "list_objects"], ["leading paragraph separator U+2029", "\u{2029}describe_object", "describe_object"], ])( "%s is ALSO stripped (Zl/Zp, out of the Cc/Cf class but stripped on this path)", (_label, input, expected) => { // W-23336443 / F2: this rejection path reaches the host UPSTREAM of the // envelope's own line-separator strip, so enumStripControlChars must // strip U+2028/U+2029 itself (raw, they trip a Claude.AI 408). They are // NOT Cc/Cf, so stripControlChars alone would miss them. const result = MODE.safeParse(input); expect(result.success).toBe(true); if (result.success) expect(result.data).toBe(expected); }, ); it("a genuinely-invalid value poisoned with U+2028/U+2029 rejects with neither separator raw", () => { const result = MODE.safeParse("bo\u{2028}gus\u{2029}"); expect(result.success).toBe(false); if (!result.success) { const serialized = JSON.stringify(result.error.issues); expect(serialized).not.toContain("\u{2028}"); expect(serialized).not.toContain("\u{2029}"); const received = (result.error.issues[0] as { received?: string }).received; expect(received).toBe("bogus"); } }); }); describe("clean rejection", () => { it("a poisoned genuinely-invalid value rejects with NO raw control char in the message", () => { // "bogus" + bidi + ZWSP + DEL. Strips to "bogus", which is not a member, // so it rejects -- but zod reflects the STRIPPED value, so the specific // injected code points must be absent from the issue. const result = MODE.safeParse("bo\u{202e}gus\u{200b}\x7f"); expect(result.success).toBe(false); if (!result.success) { const serialized = JSON.stringify(result.error.issues); // Assert the SPECIFIC injected code points are gone (a whole-class // /[\p{Cc}\p{Cf}]/ check would false-positive on any structural // whitespace zod/JSON introduce -- see control-chars.spec.ts note). expect(serialized).not.toContain("\u{202e}"); expect(serialized).not.toContain("\u{200b}"); expect(serialized).not.toContain("\x7f"); // The reflected received value is the stripped token. const received = (result.error.issues[0] as { received?: string }).received; expect(received).toBe("bogus"); } }); it("preserves the enum options in the issue (LLM still learns the allowed set)", () => { const result = MODE.safeParse("bogus"); expect(result.success).toBe(false); if (!result.success) { const issue = result.error.issues[0] as { options?: string[] }; expect(issue.options).toEqual(["list_objects", "describe_object", "describe_field"]); } }); }); describe("pass-through of non-strings and optional/undefined", () => { it("undefined passes through an optional wrapped enum (stays optional)", () => { const OP = enumStripControlChars(z.enum(["query", "mutation", "aggregate"]).optional()); const result = OP.safeParse(undefined); expect(result.success).toBe(true); if (result.success) expect(result.data).toBeUndefined(); }); it("a non-string value is handed to the inner schema untouched (rejects as a type error)", () => { const result = MODE.safeParse(42); expect(result.success).toBe(false); // The preprocess only strips strings; a number falls straight through // to z.enum, which rejects it as an invalid_type / invalid_enum_value. }); }); describe("published JSON-Schema shape is preserved (zod .describe passes through)", () => { it("keeps the .describe() description on the wrapped enum", () => { const described = enumStripControlChars(z.enum(["query", "mutation", "aggregate"])).describe( "Operation root.", ); expect(described.description).toBe("Operation root."); }); }); describe("discriminatedUnion caveat (why the aggregate `function` enum is NOT wrapped)", () => { // Executable proof that leaving discriminators un-wrapped is SAFE, and of // the true reason (the WI's "breaks construction / narrow residual is // reflected" rationale was imprecise for zod 3.25.76): a bad discriminator // raises `invalid_union_discriminator`, whose issue lists only the EXPECTED // options and does NOT echo the received value — so, unlike a plain // `invalid_enum_value`, there is no verbatim-reflection channel to close. it("a bad discriminator does NOT echo the received value (no reflection channel)", () => { const du = z.discriminatedUnion("function", [ z.object({ function: z.enum(["count", "countDistinct"]), field: z.string().optional() }), z.object({ function: z.enum(["sum", "avg", "min", "max"]), field: z.string() }), ]); // A control-char-poisoned discriminator: it never matches a branch, so // discrimination fails BEFORE any per-branch enum check would run. const result = du.safeParse({ function: "count\u{202e}\u{200b}\x7f", field: "Amount" }); expect(result.success).toBe(false); if (!result.success) { const issue = result.error.issues[0] as { code: string; options?: string[]; received?: unknown; }; expect(issue.code).toBe("invalid_union_discriminator"); // Lists the expected set... expect(issue.options).toEqual(["count", "countDistinct", "sum", "avg", "min", "max"]); // ...and crucially carries NO `received` echo of the poisoned input. const serialized = JSON.stringify(result.error.issues); expect(serialized).not.toContain("\u{202e}"); expect(serialized).not.toContain("\u{200b}"); expect(serialized).not.toContain("\x7f"); } }); it("a plain z.union member CAN be wrapped safely (the groupBy `function` case)", () => { const grouped = z.union([ z.string(), z.object({ field: z.string(), function: enumStripControlChars(z.enum(["CALENDAR_MONTH", "CALENDAR_YEAR"])), }), ]); const result = grouped.safeParse({ field: "CreatedDate", function: "CALENDAR_MONTH\u{202e}", }); expect(result.success).toBe(true); if (result.success && typeof result.data === "object") { expect((result.data as { function: string }).function).toBe("CALENDAR_MONTH"); } }); }); });