/** * Copyright (c) 2026, Salesforce, Inc., * All rights reserved. * For full license text, see the LICENSE.txt file */ import { describe, expect, it } from "vitest"; import { AGGREGATE_INPUT, CREATE_INPUT, DETAIL_INPUT, DISCOVER_INPUT, LIST_INPUT, UPDATE_INPUT, } from "../input-schemas.js"; /** * W-22735537 — zod-boundary charset validation for caller-supplied field-path / * scope inputs. These guard the layered defense: a silent revert of a * `dottedGraphqlName()` / `scopeArg()` field back to `z.string()` would reopen * the selection-set injection at the MCP boundary and these tests would fail. * (filter/orderBy object-KEY validation is enforced in the builder layer, not * zod, so it is covered by the build-* specs, not here.) */ const BREAKOUT = "Id } injectedAlias: Name { value"; describe("schemas/input-schemas — field-path charset validation (W-22735537)", () => { it("CREATE_INPUT rejects a returnFields breakout; accepts a dotted path", () => { expect( CREATE_INPUT.safeParse({ org: "o", object: "Account", returnFields: [BREAKOUT] }).success, ).toBe(false); expect( CREATE_INPUT.safeParse({ org: "o", object: "Account", returnFields: ["Id", "Owner.Name"] }) .success, ).toBe(true); }); it("UPDATE_INPUT rejects a returnFields breakout", () => { expect( UPDATE_INPUT.safeParse({ org: "o", object: "Account", returnFields: [BREAKOUT] }).success, ).toBe(false); }); it("LIST_INPUT rejects fields/parentFields/scope breakouts; accepts legit", () => { expect(LIST_INPUT.safeParse({ org: "o", object: "Account", fields: [BREAKOUT] }).success).toBe( false, ); expect( LIST_INPUT.safeParse({ org: "o", object: "Account", fields: ["Id"], parentFields: [BREAKOUT], }).success, ).toBe(false); expect( LIST_INPUT.safeParse({ org: "o", object: "Account", fields: ["Id"], scope: "{}) { Id } x: Account(scope: MINE", }).success, ).toBe(false); expect( LIST_INPUT.safeParse({ org: "o", object: "Account", fields: ["Id", "Owner.Name"], scope: "MINE", }).success, ).toBe(true); expect( LIST_INPUT.safeParse({ org: "o", object: "Account", fields: ["Id"], scope: "$myScope" }) .success, ).toBe(true); }); it("LIST_INPUT rejects a childRelationships[].fields breakout at the boundary", () => { expect( LIST_INPUT.safeParse({ org: "o", object: "Account", fields: ["Id"], childRelationships: [{ relationshipName: "Contacts", fields: [BREAKOUT] }], }).success, ).toBe(false); }); it("DETAIL_INPUT rejects a fields breakout; accepts a dotted path", () => { expect( DETAIL_INPUT.safeParse({ org: "myorg", object: "Account", fields: [BREAKOUT] }).success, ).toBe(false); expect( DETAIL_INPUT.safeParse({ org: "myorg", object: "Account", fields: ["Id", "Owner.Name"] }) .success, ).toBe(true); }); it("AGGREGATE_INPUT rejects an aggregations[].field breakout; accepts a single field", () => { expect( AGGREGATE_INPUT.safeParse({ org: "o", object: "Account", aggregations: [{ function: "sum", field: "Amount } evil { value" }], }).success, ).toBe(false); expect( AGGREGATE_INPUT.safeParse({ org: "o", object: "Account", aggregations: [{ function: "sum", field: "Amount" }], }).success, ).toBe(true); }); // PR #678 review (Ciaran Hannigan): Salesforce custom-field __c syntax must be // accepted. The dotted-path / Name regexes permit it today; this pins it so a // future "no consecutive underscores" tweak can't silently start rejecting real // fields across any caller-supplied field-path input. it("accepts Salesforce custom/namespaced __c field syntax across every dotted-path input", () => { const returnFields = ["Amount__c", "Owner.Custom__c", "MyNS__Field__c"]; expect(CREATE_INPUT.safeParse({ org: "o", object: "Account", returnFields }).success).toBe( true, ); expect(UPDATE_INPUT.safeParse({ org: "o", object: "Account", returnFields }).success).toBe( true, ); expect( LIST_INPUT.safeParse({ org: "o", object: "Account", fields: ["Amount__c"], parentFields: ["Owner.Custom__c"], childRelationships: [{ relationshipName: "Contacts", fields: ["MyNS__Field__c"] }], }).success, ).toBe(true); expect( DETAIL_INPUT.safeParse({ org: "o", object: "Account", fields: ["Amount__c"], parentFields: ["Owner.Custom__c"], }).success, ).toBe(true); expect( AGGREGATE_INPUT.safeParse({ org: "o", object: "Account", aggregations: [{ function: "sum", field: "Amount__c" }], groupBy: ["MyNS__Field__c"], }).success, ).toBe(true); }); }); /** * Contract for the `sf_gql_discover` `search` guard (W-23336442). `search` is a * free-text substring filter reflected VERBATIM through the SUCCESS envelope * (schemas/tool-adapter.ts runTool), which neutralizes neither Cc nor Cf — * JSON.stringify escapes only C0 (U+0000-U+001F), leaving DEL (U+007F) and the * ENTIRE Cf class (bidi overrides, zero-width, BOM, U+E0000-E007F tag block) * raw. DISCOVER_SEARCH_RE must therefore REJECT the full Cc/Cf class, matching * lib/control-chars.ts CONTROL_CHAR_RE — not just the C0/DEL subset the earlier * /^[^\x00-\x1f\x7f]*$/ range caught. * * Injected chars are `\u` escapes, not literals, so the source carries no * invisible bytes. Ordinary Unicode (café, 日本語) may appear as literals. */ describe("schemas/input-schemas — DISCOVER_INPUT.search Cc/Cf guard (W-23336442)", () => { const base = { org: "myorg", mode: "list_objects" as const }; const parseSearch = (search: string) => DISCOVER_INPUT.safeParse({ ...base, search }); describe("REJECTS control/format (Cc/Cf) chars", () => { it.each([ // Cf class — the gap the old C0/DEL-only range let through. ["ZWSP U+200B (Cf, zero-width)", "Acc\u{200b}ount"], ["RLO U+202E (Cf, bidi override)", "Acc\u{202e}ount"], ["tag char U+E0001 (Cf, tag block)", "Acc\u{e0001}ount"], ["BOM/ZWNBSP U+FEFF (Cf)", "\u{feff}Account"], ["soft hyphen U+00AD (Cf)", "Acc\u{00ad}ount"], // Cc class — still rejected, as before. ["NUL U+0000 (Cc, C0)", "Acc\u{0000}ount"], ["ESC U+001B (Cc, C0)", "Acc\u{001b}ount"], ["DEL U+007F (Cc)", "Acc\u{007f}ount"], ["NEL U+0085 (Cc, C1)", "Acc\u{0085}ount"], ])("%s is rejected", (_label, search) => { const result = parseSearch(search); expect(result.success).toBe(false); if (!result.success) { const searchIssue = result.error.issues.find((i) => i.path[0] === "search"); expect(searchIssue?.message).toBe("search must not contain control characters"); } }); }); describe("ACCEPTS ordinary printable Unicode", () => { it.each([ ["plain ASCII", "Account"], ["accented Latin (café)", "café"], ["CJK (日本語)", "日本語"], ["spaces", "My Custom Object"], ["punctuation and digits", "Order__c v2 (2026)"], ["empty string", ""], ])("%s is accepted", (_label, search) => { const result = parseSearch(search); expect(result.success).toBe(true); if (result.success) expect(result.data.search).toBe(search); }); }); it("still enforces the 100-char length cap alongside the Cc/Cf guard", () => { const result = parseSearch("a".repeat(101)); expect(result.success).toBe(false); if (!result.success) { const searchIssue = result.error.issues.find((i) => i.path[0] === "search"); expect(searchIssue?.message).toBe("search must be 100 characters or fewer"); } }); });