/** * Copyright (c) 2026, Salesforce, Inc., * All rights reserved. * For full license text, see the LICENSE.txt file */ /** * W-22818723 — FLS-safe `@optional` field-selection policy. * * The sibling intent/mcp specs assert `@optional` against MINIMAL schemas whose * value wrapper is `{ value }` only and which never declare the directive. This * spec exercises the policy against a REALISTIC UIAPI-shaped schema — one that * (a) declares `directive @optional` (so validation passes cleanly, as the live * org does) and (b) exposes `displayValue`/`label` on its wrappers — to cover * the two behaviors those minimal schemas structurally cannot: * 1. `displayValue` IS selected when the wrapper exposes it. * 2. `@optional` produces NO validation warning when the schema declares it. * * It also reproduces the headline FLS scenario (the `Gold__c`-style restricted * field from the W-22800643 QA): a query that would hard-fail for a restricted * user is rendered with `@optional` so the field degrades gracefully instead. */ import { buildSchema } from "graphql"; import { describe, expect, it } from "vitest"; import { makeNoopPrimeDeps } from "../../__tests__/helpers/prime-deps.js"; import { buildAggregate } from "../../intent/build-aggregate.js"; import { buildList } from "../../intent/build-list.js"; import { applyGlobalSchemaPolicies, OPTIONAL_DIRECTIVE } from "../optional-fields.js"; import { renderQuery } from "../query-builder.js"; import { createSession, getChildren, selectLeaf } from "../session.js"; import { primeSchemaCache } from "../walker.js"; // A UIAPI-faithful schema: declares @optional, and wrappers carry value + // displayValue (StringValue/PicklistValue) so the "display value where it can // be included" branch is reachable. `Hero__c` mirrors the W-22800643 fixture. const SCHEMA_SDL = ` directive @optional on FIELD type Query { uiapi: UIAPI! } type UIAPI { query: RecordQuery!, aggregate: RecordQueryAggregate! } type RecordQuery { Hero__c(first: Int, after: String, where: Hero__c_Filter, orderBy: Hero__c_OrderBy): Hero__cConnection! } type RecordQueryAggregate { Hero__c(first: Int, after: String, groupBy: Hero__c_GroupBy): Hero__cAggregateConnection } input Hero__c_Filter { Gold__c: IntOperators } input Hero__c_OrderBy { Name: OrderByClause } input Hero__c_GroupBy { Gold__c: GroupByClause } input IntOperators { eq: Int, gt: Int } input OrderByClause { order: Order! } input GroupByClause { group: Boolean } enum Order { ASC DESC } type Hero__cConnection { edges: [Hero__cEdge!]!, pageInfo: PageInfo! } type Hero__cEdge { node: Hero__c! } type PageInfo { hasNextPage: Boolean!, endCursor: String } type Hero__c { Id: ID! ApiName: String Name: StringValue Gold__c: IntValue } type StringValue { value: String, displayValue: String, label: String } type IntValue { value: Int, displayValue: String } type LongValue { value: String } type Hero__cAggregateConnection { edges: [Hero__cAggregateEdge!]!, pageInfo: PageInfo! } type Hero__cAggregateEdge { node: Hero__cResult!, cursor: String! } type Hero__cResult { aggregate: Hero__cAggregate } type Hero__cAggregate { Id: IDAggregate, Gold__c: IntAggregate } type IDAggregate { value: ID, count: LongValue } type IntAggregate { value: Int, sum: LongValue, count: LongValue } `; const SCHEMA = buildSchema(SCHEMA_SDL); const ORG = "hero-org"; const ORG_URL = "https://hero-org.my.salesforce.com"; primeSchemaCache(ORG, SCHEMA); primeSchemaCache(ORG_URL, SCHEMA); const noopPrimeDeps = () => makeNoopPrimeDeps(ORG, ORG_URL, SCHEMA); describe("lib/optional-fields — applyGlobalSchemaPolicies", () => { it("marks bare record scalars @optional", async () => { // ApiName is a bare scalar (not a value wrapper) on a record scope, so it // gets @optional like any other FLS-gateable field. const out = await buildList( { org: ORG, object: "Hero__c", fields: ["ApiName"] }, noopPrimeDeps(), ); expect(out.query).toMatch(/\bApiName\s+@optional\b/); }); it("does NOT mark Id @optional — FLS can never gate it (W-22818723)", async () => { // `@optional` on Id is a no-op for degradation and only weakens the type // (`Id?: string | undefined`), so Id is exempt while its siblings are not. const out = await buildList( { org: ORG, object: "Hero__c", fields: ["Id", "Name"] }, noopPrimeDeps(), ); expect(out.query).not.toMatch(/\bId\s+@optional\b/); expect(out.query).toMatch(/\bId\b/); // still selected, just bare expect(out.query).toMatch(/Name\s+@optional\s*\{/); }); it("marks value-wrapper fields @optional and selects value + displayValue", async () => { const out = await buildList({ org: ORG, object: "Hero__c", fields: ["Name"] }, noopPrimeDeps()); // @optional sits on the wrapper field, between the name and the body. expect(out.query).toMatch(/Name\s+@optional\s*\{/); // Both value AND displayValue are selected because the wrapper exposes them. expect(out.query).toMatch(/Name\s+@optional\s*\{[^}]*\bvalue\b[^}]*\bdisplayValue\b[^}]*\}/s); }); it("emits NO validation warning when the schema declares directive @optional", async () => { const out = await buildList( { org: ORG, object: "Hero__c", fields: ["Id", "Name", "Gold__c"] }, noopPrimeDeps(), ); expect(out.warnings).toEqual([]); }); it("codegen reflects @optional fields as optional `?: T | undefined` and includes displayValue (AC)", async () => { const out = await buildList( { org: ORG, object: "Hero__c", fields: ["Id", "Name", "Gold__c"] }, noopPrimeDeps(), ); // @optional fields become optional TS properties unioned with undefined. expect(out.types).toMatch(/\|\s*undefined/); // The display value rides along on the wrapper. expect(out.types).toMatch(/displayValue/); }); it("FLS scenario: an FLS-restricted field (Gold__c) renders @optional so the query degrades gracefully", async () => { // Per W-22800643: Gold__c WITHOUT @optional hard-fails the whole query for // a restricted user; WITH @optional the field is omitted and the rest // succeeds. The tool must emit the directive by default. const out = await buildList( { org: ORG, object: "Hero__c", fields: ["Id", "Gold__c"] }, noopPrimeDeps(), ); expect(out.query).toMatch(/Gold__c\s+@optional\s*\{[^}]*\bvalue\b/s); expect(out.warnings).toEqual([]); }); it("does NOT mark structural plumbing (pageInfo / cursors)", async () => { const out = await buildList({ org: ORG, object: "Hero__c", fields: ["Id"] }, noopPrimeDeps()); // pageInfo and its cursor leaves carry no @optional. expect(out.query).not.toMatch(/pageInfo\s+@optional/); expect(out.query).not.toMatch(/hasNextPage\s+@optional/); expect(out.query).not.toMatch(/endCursor\s+@optional/); }); it("does NOT mark aggregate function selections @optional", async () => { const out = await buildAggregate( { org: ORG, object: "Hero__c", aggregations: [{ function: "sum", field: "Gold__c" }], }, noopPrimeDeps(), ); // The aggregation function leaf (sum { value }) is structural, not an // FLS-gated record field — it must stay clean. expect(out.query).not.toMatch(/sum\s+@optional/); expect(out.query).not.toMatch(/count\s+@optional/); }); it("is idempotent — running the policy twice does not double-mark", () => { const session = createSession(ORG, "query", ORG_URL); // Use a non-exempt bare scalar (ApiName); Id would never be marked at all. selectLeaf(session, ["uiapi", "query", "Hero__c", "edges", "node", "ApiName"]); applyGlobalSchemaPolicies(session, SCHEMA); applyGlobalSchemaPolicies(session, SCHEMA); const apiNameNode = session.nodes.find((n) => n.kind === "field" && n.fieldName === "ApiName"); const optionalDirectives = apiNameNode?.kind === "field" ? apiNameNode.directives.filter((d) => d.name === OPTIONAL_DIRECTIVE) : []; expect(optionalDirectives).toHaveLength(1); }); it("does not duplicate displayValue when it is already selected", () => { const session = createSession(ORG, "query", ORG_URL); const base = ["uiapi", "query", "Hero__c", "edges", "node", "Name"]; selectLeaf(session, [...base, "value"]); selectLeaf(session, [...base, "displayValue"]); applyGlobalSchemaPolicies(session, SCHEMA); const nameNode = session.nodes.find((n) => n.kind === "field" && n.fieldName === "Name"); const displayValueChildren = nameNode ? getChildren(session, nameNode.id).filter( (c) => c.kind === "field" && c.fieldName === "displayValue", ) : []; expect(displayValueChildren).toHaveLength(1); expect(renderQuery(session)).toMatch(/Name\s+@optional\s*\{/); }); });