From b8a0d0208ae4d63b3efcbc5da51310d6df7b6719 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 24 Jul 2024 11:06:28 -0600 Subject: [PATCH] [Data masking] Add `@unmask` directive with field access warnings (#11919) --- .api-reports/api-report-cache.api.md | 2 +- .api-reports/api-report-core.api.md | 2 +- .api-reports/api-report-react.api.md | 2 +- .../api-report-react_components.api.md | 2 +- .api-reports/api-report-react_context.api.md | 2 +- .api-reports/api-report-react_hoc.api.md | 2 +- .api-reports/api-report-react_hooks.api.md | 2 +- .api-reports/api-report-react_internal.api.md | 2 +- .api-reports/api-report-react_ssr.api.md | 2 +- .api-reports/api-report-testing.api.md | 2 +- .api-reports/api-report-testing_core.api.md | 2 +- .api-reports/api-report-utilities.api.md | 5 +- .api-reports/api-report.api.md | 2 +- .github/workflows/compare-build-output.yml | 3 - .size-limits.json | 4 +- src/__tests__/__snapshots__/exports.ts.snap | 1 + src/__tests__/client.ts | 220 ++ src/cache/core/__tests__/cache.ts | 4 +- src/cache/core/cache.ts | 6 +- src/core/ApolloClient.ts | 2 +- src/core/ObservableQuery.ts | 2 +- src/core/QueryManager.ts | 1 + src/core/__tests__/masking.test.ts | 2855 ++++++++++++----- src/core/masking.ts | 233 +- src/testing/core/mocking/mockLink.ts | 2 +- src/testing/internal/disposables/index.ts | 1 + .../internal/disposables/withProdMode.ts | 10 + src/utilities/graphql/__tests__/directives.ts | 135 + src/utilities/graphql/directives.ts | 45 +- src/utilities/index.ts | 1 + 30 files changed, 2741 insertions(+), 813 deletions(-) create mode 100644 src/testing/internal/disposables/withProdMode.ts diff --git a/.api-reports/api-report-cache.api.md b/.api-reports/api-report-cache.api.md index 2086ab303c5..6980d593e17 100644 --- a/.api-reports/api-report-cache.api.md +++ b/.api-reports/api-report-cache.api.md @@ -40,7 +40,7 @@ export abstract class ApolloCache implements DataProxy { // (undocumented) identify(object: StoreObject | Reference): string | undefined; // (undocumented) - maskDocument(document: DocumentNode, data: TData): TData; + maskOperation(document: DocumentNode, data: TData): TData; // (undocumented) modify = Record>(options: Cache_2.ModifyOptions): boolean; // (undocumented) diff --git a/.api-reports/api-report-core.api.md b/.api-reports/api-report-core.api.md index 2806a43bb5c..d5d20e9819b 100644 --- a/.api-reports/api-report-core.api.md +++ b/.api-reports/api-report-core.api.md @@ -55,7 +55,7 @@ export abstract class ApolloCache implements DataProxy { // (undocumented) identify(object: StoreObject | Reference): string | undefined; // (undocumented) - maskDocument(document: DocumentNode, data: TData): TData; + maskOperation(document: DocumentNode, data: TData): TData; // (undocumented) modify = Record>(options: Cache_2.ModifyOptions): boolean; // (undocumented) diff --git a/.api-reports/api-report-react.api.md b/.api-reports/api-report-react.api.md index 98dd2be7802..07c850f348a 100644 --- a/.api-reports/api-report-react.api.md +++ b/.api-reports/api-report-react.api.md @@ -53,7 +53,7 @@ abstract class ApolloCache implements DataProxy { // (undocumented) identify(object: StoreObject | Reference): string | undefined; // (undocumented) - maskDocument(document: DocumentNode, data: TData): TData; + maskOperation(document: DocumentNode, data: TData): TData; // (undocumented) modify = Record>(options: Cache_2.ModifyOptions): boolean; // Warning: (ae-forgotten-export) The symbol "Transaction" needs to be exported by the entry point index.d.ts diff --git a/.api-reports/api-report-react_components.api.md b/.api-reports/api-report-react_components.api.md index 409827e585f..33335eaa514 100644 --- a/.api-reports/api-report-react_components.api.md +++ b/.api-reports/api-report-react_components.api.md @@ -53,7 +53,7 @@ abstract class ApolloCache implements DataProxy { // (undocumented) identify(object: StoreObject | Reference): string | undefined; // (undocumented) - maskDocument(document: DocumentNode, data: TData): TData; + maskOperation(document: DocumentNode, data: TData): TData; // (undocumented) modify = Record>(options: Cache_2.ModifyOptions): boolean; // Warning: (ae-forgotten-export) The symbol "Transaction" needs to be exported by the entry point index.d.ts diff --git a/.api-reports/api-report-react_context.api.md b/.api-reports/api-report-react_context.api.md index 37830f0d577..93c0991df98 100644 --- a/.api-reports/api-report-react_context.api.md +++ b/.api-reports/api-report-react_context.api.md @@ -52,7 +52,7 @@ abstract class ApolloCache implements DataProxy { // (undocumented) identify(object: StoreObject | Reference): string | undefined; // (undocumented) - maskDocument(document: DocumentNode, data: TData): TData; + maskOperation(document: DocumentNode, data: TData): TData; // (undocumented) modify = Record>(options: Cache_2.ModifyOptions): boolean; // Warning: (ae-forgotten-export) The symbol "Transaction" needs to be exported by the entry point index.d.ts diff --git a/.api-reports/api-report-react_hoc.api.md b/.api-reports/api-report-react_hoc.api.md index befffbf4ad4..da7e3958327 100644 --- a/.api-reports/api-report-react_hoc.api.md +++ b/.api-reports/api-report-react_hoc.api.md @@ -52,7 +52,7 @@ abstract class ApolloCache implements DataProxy { // (undocumented) identify(object: StoreObject | Reference): string | undefined; // (undocumented) - maskDocument(document: DocumentNode, data: TData): TData; + maskOperation(document: DocumentNode, data: TData): TData; // (undocumented) modify = Record>(options: Cache_2.ModifyOptions): boolean; // Warning: (ae-forgotten-export) The symbol "Transaction" needs to be exported by the entry point index.d.ts diff --git a/.api-reports/api-report-react_hooks.api.md b/.api-reports/api-report-react_hooks.api.md index b4d6e73326c..0e6b9a7c957 100644 --- a/.api-reports/api-report-react_hooks.api.md +++ b/.api-reports/api-report-react_hooks.api.md @@ -51,7 +51,7 @@ abstract class ApolloCache implements DataProxy { // (undocumented) identify(object: StoreObject | Reference): string | undefined; // (undocumented) - maskDocument(document: DocumentNode, data: TData): TData; + maskOperation(document: DocumentNode, data: TData): TData; // (undocumented) modify = Record>(options: Cache_2.ModifyOptions): boolean; // Warning: (ae-forgotten-export) The symbol "Transaction" needs to be exported by the entry point index.d.ts diff --git a/.api-reports/api-report-react_internal.api.md b/.api-reports/api-report-react_internal.api.md index d0e0b5990a2..2d1bdca578c 100644 --- a/.api-reports/api-report-react_internal.api.md +++ b/.api-reports/api-report-react_internal.api.md @@ -51,7 +51,7 @@ abstract class ApolloCache implements DataProxy { // (undocumented) identify(object: StoreObject | Reference): string | undefined; // (undocumented) - maskDocument(document: DocumentNode, data: TData): TData; + maskOperation(document: DocumentNode, data: TData): TData; // (undocumented) modify = Record>(options: Cache_2.ModifyOptions): boolean; // Warning: (ae-forgotten-export) The symbol "Transaction" needs to be exported by the entry point index.d.ts diff --git a/.api-reports/api-report-react_ssr.api.md b/.api-reports/api-report-react_ssr.api.md index 692b2787008..217c8127811 100644 --- a/.api-reports/api-report-react_ssr.api.md +++ b/.api-reports/api-report-react_ssr.api.md @@ -52,7 +52,7 @@ abstract class ApolloCache implements DataProxy { // (undocumented) identify(object: StoreObject | Reference): string | undefined; // (undocumented) - maskDocument(document: DocumentNode, data: TData): TData; + maskOperation(document: DocumentNode, data: TData): TData; // (undocumented) modify = Record>(options: Cache_2.ModifyOptions): boolean; // Warning: (ae-forgotten-export) The symbol "Transaction" needs to be exported by the entry point index.d.ts diff --git a/.api-reports/api-report-testing.api.md b/.api-reports/api-report-testing.api.md index 3680400251a..aa6b14c5aff 100644 --- a/.api-reports/api-report-testing.api.md +++ b/.api-reports/api-report-testing.api.md @@ -52,7 +52,7 @@ abstract class ApolloCache implements DataProxy { // (undocumented) identify(object: StoreObject | Reference): string | undefined; // (undocumented) - maskDocument(document: DocumentNode, data: TData): TData; + maskOperation(document: DocumentNode, data: TData): TData; // (undocumented) modify = Record>(options: Cache_2.ModifyOptions): boolean; // Warning: (ae-forgotten-export) The symbol "Transaction" needs to be exported by the entry point index.d.ts diff --git a/.api-reports/api-report-testing_core.api.md b/.api-reports/api-report-testing_core.api.md index 85c5dd56811..71f63a1ea17 100644 --- a/.api-reports/api-report-testing_core.api.md +++ b/.api-reports/api-report-testing_core.api.md @@ -51,7 +51,7 @@ abstract class ApolloCache implements DataProxy { // (undocumented) identify(object: StoreObject | Reference): string | undefined; // (undocumented) - maskDocument(document: DocumentNode, data: TData): TData; + maskOperation(document: DocumentNode, data: TData): TData; // (undocumented) modify = Record>(options: Cache_2.ModifyOptions): boolean; // Warning: (ae-forgotten-export) The symbol "Transaction" needs to be exported by the entry point index.d.ts diff --git a/.api-reports/api-report-utilities.api.md b/.api-reports/api-report-utilities.api.md index 001b32b49a4..fd4219b41b9 100644 --- a/.api-reports/api-report-utilities.api.md +++ b/.api-reports/api-report-utilities.api.md @@ -66,7 +66,7 @@ abstract class ApolloCache implements DataProxy { // (undocumented) identify(object: StoreObject | Reference): string | undefined; // (undocumented) - maskDocument(document: DocumentNode, data: TData): TData; + maskOperation(document: DocumentNode, data: TData): TData; // (undocumented) modify = Record>(options: Cache_2.ModifyOptions): boolean; // Warning: (ae-forgotten-export) The symbol "Transaction" needs to be exported by the entry point index.d.ts @@ -1151,6 +1151,9 @@ export function getFragmentDefinitions(doc: DocumentNode): FragmentDefinitionNod // @public (undocumented) export function getFragmentFromSelection(selection: SelectionNode, fragmentMap?: FragmentMap | FragmentMapFunction): InlineFragmentNode | FragmentDefinitionNode | null; +// @internal (undocumented) +export function getFragmentMaskMode(fragment: FragmentSpreadNode): "mask" | "migrate" | "unmask"; + // @public export function getFragmentQueryDocument(document: DocumentNode, fragmentName?: string): DocumentNode; diff --git a/.api-reports/api-report.api.md b/.api-reports/api-report.api.md index be7ad76570a..427a9923c21 100644 --- a/.api-reports/api-report.api.md +++ b/.api-reports/api-report.api.md @@ -57,7 +57,7 @@ export abstract class ApolloCache implements DataProxy { // (undocumented) identify(object: StoreObject | Reference): string | undefined; // (undocumented) - maskDocument(document: DocumentNode, data: TData): TData; + maskOperation(document: DocumentNode, data: TData): TData; // (undocumented) modify = Record>(options: Cache_2.ModifyOptions): boolean; // (undocumented) diff --git a/.github/workflows/compare-build-output.yml b/.github/workflows/compare-build-output.yml index 7b97c556665..b367e4672e6 100644 --- a/.github/workflows/compare-build-output.yml +++ b/.github/workflows/compare-build-output.yml @@ -1,9 +1,6 @@ name: Compare Build Output on: pull_request: - branches: - - main - - release-* concurrency: ${{ github.workflow }}-${{ github.ref }} diff --git a/.size-limits.json b/.size-limits.json index 7aac2be3e4d..310f0c33d3c 100644 --- a/.size-limits.json +++ b/.size-limits.json @@ -1,4 +1,4 @@ { - "dist/apollo-client.min.cjs": 39923, - "import { ApolloClient, InMemoryCache, HttpLink } from \"dist/index.js\" (production)": 33211 + "dist/apollo-client.min.cjs": 40154, + "import { ApolloClient, InMemoryCache, HttpLink } from \"dist/index.js\" (production)": 33429 } diff --git a/src/__tests__/__snapshots__/exports.ts.snap b/src/__tests__/__snapshots__/exports.ts.snap index d3ce1568654..f694b58f7af 100644 --- a/src/__tests__/__snapshots__/exports.ts.snap +++ b/src/__tests__/__snapshots__/exports.ts.snap @@ -440,6 +440,7 @@ Array [ "getFragmentDefinition", "getFragmentDefinitions", "getFragmentFromSelection", + "getFragmentMaskMode", "getFragmentQueryDocument", "getGraphQLErrorsFromResult", "getInclusionDirectives", diff --git a/src/__tests__/client.ts b/src/__tests__/client.ts index a8d943c9262..1325ffde287 100644 --- a/src/__tests__/client.ts +++ b/src/__tests__/client.ts @@ -6,6 +6,7 @@ import { Kind, print, visit, + FragmentSpreadNode, } from "graphql"; import gql from "graphql-tag"; @@ -6599,6 +6600,149 @@ describe("data masking", () => { } }); + it("does not mask fragments marked with @unmask", async () => { + interface Query { + currentUser: { + __typename: "User"; + id: number; + name: string; + }; + } + + const query: TypedDocumentNode = gql` + query UnmaskedQuery { + currentUser { + id + name + ...UserFields @unmask + } + } + + fragment UserFields on User { + age + } + `; + + const mocks = [ + { + request: { query }, + result: { + data: { + currentUser: { + __typename: "User", + id: 1, + name: "Test User", + age: 30, + }, + }, + }, + }, + ]; + + const client = new ApolloClient({ + dataMasking: true, + cache: new InMemoryCache(), + link: new MockLink(mocks), + }); + + const observable = client.watchQuery({ query }); + + const stream = new ObservableStream(observable); + + { + const { data } = await stream.takeNext(); + + expect(data).toEqual({ + currentUser: { + __typename: "User", + id: 1, + name: "Test User", + age: 30, + }, + }); + } + }); + + it("does not mask fragments marked with @unmask added by document transforms", async () => { + const documentTransform = new DocumentTransform((document) => { + return visit(document, { + FragmentSpread(node) { + return { + ...node, + directives: [ + { + kind: Kind.DIRECTIVE, + name: { kind: Kind.NAME, value: "unmask" }, + }, + ], + } satisfies FragmentSpreadNode; + }, + }); + }); + + interface Query { + currentUser: { + __typename: "User"; + id: number; + name: string; + }; + } + + const query: TypedDocumentNode = gql` + query UnmaskedQuery { + currentUser { + id + name + ...UserFields + } + } + + fragment UserFields on User { + age + } + `; + + const mocks = [ + { + request: { query }, + result: { + data: { + currentUser: { + __typename: "User", + id: 1, + name: "Test User", + age: 30, + }, + }, + }, + }, + ]; + + const client = new ApolloClient({ + dataMasking: true, + cache: new InMemoryCache(), + link: new MockLink(mocks), + documentTransform, + }); + + const observable = client.watchQuery({ query }); + + const stream = new ObservableStream(observable); + + { + const { data } = await stream.takeNext(); + + expect(data).toEqual({ + currentUser: { + __typename: "User", + id: 1, + name: "Test User", + age: 30, + }, + }); + } + }); + it("does not mask query when using a cache that does not support it", async () => { using _ = spyOnConsole("warn"); @@ -7252,6 +7396,82 @@ describe("data masking", () => { } } ); + + it("warns when accessing a unmasked field while using @unmask with mode: 'migrate'", async () => { + using consoleSpy = spyOnConsole("warn"); + + interface Query { + currentUser: { + __typename: "User"; + id: number; + name: string; + age: number; + }; + } + + const query: TypedDocumentNode = gql` + query UnmaskedQuery { + currentUser { + id + name + ...UserFields @unmask(mode: "migrate") + } + } + + fragment UserFields on User { + age + name + } + `; + + const mocks = [ + { + request: { query }, + result: { + data: { + currentUser: { + __typename: "User", + id: 1, + name: "Test User", + age: 34, + }, + }, + }, + delay: 20, + }, + ]; + + const client = new ApolloClient({ + dataMasking: true, + cache: new InMemoryCache(), + link: new MockLink(mocks), + }); + + const observable = client.watchQuery({ query }); + const stream = new ObservableStream(observable); + + { + const { data } = await stream.takeNext(); + data.currentUser.__typename; + data.currentUser.id; + data.currentUser.name; + + expect(consoleSpy.warn).not.toHaveBeenCalled(); + + data.currentUser.age; + + expect(consoleSpy.warn).toHaveBeenCalledTimes(1); + expect(consoleSpy.warn).toHaveBeenCalledWith( + "Accessing unmasked field on %s at path '%s'. This field will not be available when masking is enabled. Please read the field from the fragment instead.", + "query 'UnmaskedQuery'", + "currentUser.age" + ); + + // Ensure we only warn once + data.currentUser.age; + expect(consoleSpy.warn).toHaveBeenCalledTimes(1); + } + }); }); function clientRoundtrip( diff --git a/src/cache/core/__tests__/cache.ts b/src/cache/core/__tests__/cache.ts index 0e477d28aa5..5b6e61cb956 100644 --- a/src/cache/core/__tests__/cache.ts +++ b/src/cache/core/__tests__/cache.ts @@ -185,7 +185,7 @@ describe("abstract cache", () => { }; const cache = new TestCache(); - const result = cache.maskDocument(query, data); + const result = cache.maskOperation(query, data); expect(result).toBe(data); expect(consoleSpy.warn).toHaveBeenCalledTimes(1); @@ -223,7 +223,7 @@ describe("abstract cache", () => { user: { __typename: "User", id: 1, name: "Mister Masked" }, }; - const result = cache.maskDocument(query, data); + const result = cache.maskOperation(query, data); expect(result).toEqual({ user: { __typename: "User", id: 1 }, diff --git a/src/cache/core/cache.ts b/src/cache/core/cache.ts index 1298c848611..e98ba554efb 100644 --- a/src/cache/core/cache.ts +++ b/src/cache/core/cache.ts @@ -23,7 +23,7 @@ import type { } from "../../core/types.js"; import type { MissingTree } from "./types/common.js"; import { equalByQuery } from "../../core/equalByQuery.js"; -import { maskQuery } from "../../core/masking.js"; +import { maskOperation } from "../../core/masking.js"; import { invariant } from "../../utilities/globals/index.js"; export type Transaction = (c: ApolloCache) => void; @@ -370,7 +370,7 @@ export abstract class ApolloCache implements DataProxy { }); } - public maskDocument(document: DocumentNode, data: TData) { + public maskOperation(document: DocumentNode, data: TData) { if (!this.fragmentMatches) { if (__DEV__) { invariant.warn( @@ -381,7 +381,7 @@ export abstract class ApolloCache implements DataProxy { return data; } - return maskQuery(data, document, this.fragmentMatches.bind(this)); + return maskOperation(data, document, this.fragmentMatches.bind(this)); } /** diff --git a/src/core/ApolloClient.ts b/src/core/ApolloClient.ts index 49b68bf1139..af8863c9e96 100644 --- a/src/core/ApolloClient.ts +++ b/src/core/ApolloClient.ts @@ -124,7 +124,7 @@ export interface ApolloClientOptions { /** * Determines if data masking is enabled for the client. * - * @default false + * @defaultValue false */ dataMasking?: boolean; } diff --git a/src/core/ObservableQuery.ts b/src/core/ObservableQuery.ts index d63f47b724a..4787a8ea050 100644 --- a/src/core/ObservableQuery.ts +++ b/src/core/ObservableQuery.ts @@ -1068,7 +1068,7 @@ Did you mean to call refetch(variables) instead of refetch({ variables })?`, const { queryManager } = this; return queryManager.dataMasking ? - queryManager.cache.maskDocument(this.query, data) + queryManager.cache.maskOperation(this.query, data) : data; } } diff --git a/src/core/QueryManager.ts b/src/core/QueryManager.ts index 78ba9084778..921c99ba114 100644 --- a/src/core/QueryManager.ts +++ b/src/core/QueryManager.ts @@ -694,6 +694,7 @@ export class QueryManager { { name: "client", remove: true }, { name: "connection" }, { name: "nonreactive" }, + { name: "unmask" }, ], document ), diff --git a/src/core/__tests__/masking.test.ts b/src/core/__tests__/masking.test.ts index 27054380a8c..1186da58143 100644 --- a/src/core/__tests__/masking.test.ts +++ b/src/core/__tests__/masking.test.ts @@ -1,233 +1,336 @@ -import { maskFragment, maskQuery } from "../masking.js"; +import { maskFragment, maskOperation } from "../masking.js"; import { InMemoryCache, gql } from "../index.js"; import { InlineFragmentNode } from "graphql"; import { deepFreeze } from "../../utilities/common/maybeDeepFreeze.js"; import { InvariantError } from "../../utilities/globals/index.js"; +import { spyOnConsole, withProdMode } from "../../testing/internal/index.js"; -test("strips top-level fragment data from query", () => { - const query = gql` - query { - foo - ...QueryFields - } +describe("maskOperation", () => { + test("throws when passing document with no operation to maskOperation", () => { + const document = gql` + fragment Foo on Bar { + foo + } + `; + + expect(() => + maskOperation({}, document, createFragmentMatcher(new InMemoryCache())) + ).toThrow( + new InvariantError( + "Expected a parsed GraphQL document with a query, mutation, or subscription." + ) + ); + }); - fragment QueryFields on Query { - bar + test("throws when passing string query to maskOperation", () => { + const document = ` + query Foo { + foo } `; - const data = maskQuery( - { foo: true, bar: true }, - query, - createFragmentMatcher(new InMemoryCache()) - ); + expect(() => + maskOperation( + {}, + // @ts-expect-error + document, + createFragmentMatcher(new InMemoryCache()) + ) + ).toThrow( + new InvariantError( + 'Expecting a parsed GraphQL document. Perhaps you need to wrap the query string in a "gql" tag? http://docs.apollostack.com/apollo-client/core.html#gql' + ) + ); + }); - expect(data).toEqual({ foo: true }); -}); + test("throws when passing multiple operations to maskOperation", () => { + const document = gql` + query Foo { + foo + } -test("strips fragment data from nested object", () => { - const query = gql` - query { - user { - __typename - id - ...UserFields + query Bar { + bar } - } + `; - fragment UserFields on User { - name - } - `; + expect(() => + maskOperation({}, document, createFragmentMatcher(new InMemoryCache())) + ).toThrow( + new InvariantError("Ambiguous GraphQL document: contains 2 operations") + ); + }); - const data = maskQuery( - { user: { __typename: "User", id: 1, name: "Test User" } }, - query, - createFragmentMatcher(new InMemoryCache()) - ); + test("strips top-level fragment data from query", () => { + const query = gql` + query { + foo + ...QueryFields + } - expect(data).toEqual({ user: { __typename: "User", id: 1 } }); -}); + fragment QueryFields on Query { + bar + } + `; -test("strips fragment data from arrays", () => { - const query = gql` - query { - users { - __typename - id - ...UserFields + const data = maskOperation( + { foo: true, bar: true }, + query, + createFragmentMatcher(new InMemoryCache()) + ); + + expect(data).toEqual({ foo: true }); + }); + + test("strips fragment data from nested object", () => { + const query = gql` + query { + user { + __typename + id + ...UserFields + } } - } - fragment UserFields on User { - name - } - `; + fragment UserFields on User { + name + } + `; - const data = maskQuery( - { - users: [ - { __typename: "User", id: 1, name: "Test User 1" }, - { __typename: "User", id: 2, name: "Test User 2" }, - ], - }, - query, - createFragmentMatcher(new InMemoryCache()) - ); + const data = maskOperation( + deepFreeze({ user: { __typename: "User", id: 1, name: "Test User" } }), + query, + createFragmentMatcher(new InMemoryCache()) + ); - expect(data).toEqual({ - users: [ - { __typename: "User", id: 1 }, - { __typename: "User", id: 2 }, - ], + expect(data).toEqual({ user: { __typename: "User", id: 1 } }); }); -}); -test("strips multiple fragments in the same selection set", () => { - const query = gql` - query { - user { - __typename - id - ...UserProfileFields - ...UserAvatarFields + test("deep freezes the masked result if the original data is frozen", () => { + const query = gql` + query { + user { + __typename + id + ...UserFields + } } - } - fragment UserProfileFields on User { - age - } + fragment UserFields on User { + name + } + `; - fragment UserAvatarFields on User { - avatarUrl - } - `; + const frozenData = maskOperation( + deepFreeze({ user: { __typename: "User", id: 1, name: "Test User" } }), + query, + createFragmentMatcher(new InMemoryCache()) + ); - const data = maskQuery( - { - user: { - __typename: "User", - id: 1, - age: 30, - avatarUrl: "https://example.com/avatar.jpg", - }, - }, - query, - createFragmentMatcher(new InMemoryCache()) - ); + const nonFrozenData = maskOperation( + { user: { __typename: "User", id: 1, name: "Test User" } }, + query, + createFragmentMatcher(new InMemoryCache()) + ); - expect(data).toEqual({ - user: { __typename: "User", id: 1 }, + expect(Object.isFrozen(frozenData)).toBe(true); + expect(Object.isFrozen(nonFrozenData)).toBe(false); }); -}); -test("strips multiple fragments across different selection sets", () => { - const query = gql` - query { - user { - __typename - id - ...UserFields + test("strips fragment data from arrays", () => { + const query = gql` + query { + users { + __typename + id + ...UserFields + } } - post { - __typename - id - ...PostFields + + fragment UserFields on User { + name } - } + `; + + const data = maskOperation( + deepFreeze({ + users: [ + { __typename: "User", id: 1, name: "Test User 1" }, + { __typename: "User", id: 2, name: "Test User 2" }, + ], + }), + query, + createFragmentMatcher(new InMemoryCache()) + ); - fragment UserFields on User { - name - } + expect(data).toEqual({ + users: [ + { __typename: "User", id: 1 }, + { __typename: "User", id: 2 }, + ], + }); + }); - fragment PostFields on Post { - title - } - `; + test("strips multiple fragments in the same selection set", () => { + const query = gql` + query { + user { + __typename + id + ...UserProfileFields + ...UserAvatarFields + } + } - const data = maskQuery( - { - user: { - __typename: "User", - id: 1, - name: "test user", - }, - post: { - __typename: "Post", - id: 1, - title: "Test Post", + fragment UserProfileFields on User { + age + } + + fragment UserAvatarFields on User { + avatarUrl + } + `; + + const data = maskOperation( + { + user: { + __typename: "User", + id: 1, + age: 30, + avatarUrl: "https://example.com/avatar.jpg", + }, }, - }, - query, - createFragmentMatcher(new InMemoryCache()) - ); + query, + createFragmentMatcher(new InMemoryCache()) + ); - expect(data).toEqual({ - user: { __typename: "User", id: 1 }, - post: { __typename: "Post", id: 1 }, + expect(data).toEqual({ + user: { __typename: "User", id: 1 }, + }); }); -}); -test("leaves overlapping fields in query", () => { - const query = gql` - query { - user { - __typename - id - birthdate - ...UserProfileFields + test("strips multiple fragments across different selection sets", () => { + const query = gql` + query { + user { + __typename + id + ...UserFields + } + post { + __typename + id + ...PostFields + } } - } - fragment UserProfileFields on User { - birthdate - name - } - `; + fragment UserFields on User { + name + } - const data = maskQuery( - { - user: { - __typename: "User", - id: 1, - birthdate: "1990-01-01", - name: "Test User", + fragment PostFields on Post { + title + } + `; + + const data = maskOperation( + { + user: { + __typename: "User", + id: 1, + name: "test user", + }, + post: { + __typename: "Post", + id: 1, + title: "Test Post", + }, }, - }, - query, - createFragmentMatcher(new InMemoryCache()) - ); + query, + createFragmentMatcher(new InMemoryCache()) + ); - expect(data).toEqual({ - user: { __typename: "User", id: 1, birthdate: "1990-01-01" }, + expect(data).toEqual({ + user: { __typename: "User", id: 1 }, + post: { __typename: "Post", id: 1 }, + }); }); -}); -test("does not strip inline fragments", () => { - const cache = new InMemoryCache({ - possibleTypes: { Profile: ["UserProfile"] }, + test("leaves overlapping fields in query", () => { + const query = gql` + query { + user { + __typename + id + birthdate + ...UserProfileFields + } + } + + fragment UserProfileFields on User { + birthdate + name + } + `; + + const data = maskOperation( + deepFreeze({ + user: { + __typename: "User", + id: 1, + birthdate: "1990-01-01", + name: "Test User", + }, + }), + query, + createFragmentMatcher(new InMemoryCache()) + ); + + expect(data).toEqual({ + user: { __typename: "User", id: 1, birthdate: "1990-01-01" }, + }); }); - const query = gql` - query { - user { - __typename - id - ... @defer { - name + test("does not strip inline fragments", () => { + const cache = new InMemoryCache({ + possibleTypes: { Profile: ["UserProfile"] }, + }); + + const query = gql` + query { + user { + __typename + id + ... @defer { + name + } } - } - profile { - __typename - ... on UserProfile { - avatarUrl + profile { + __typename + ... on UserProfile { + avatarUrl + } } } - } - `; + `; + + const data = maskOperation( + deepFreeze({ + user: { + __typename: "User", + id: 1, + name: "Test User", + }, + profile: { + __typename: "UserProfile", + avatarUrl: "https://example.com/avatar.jpg", + }, + }), + query, + createFragmentMatcher(cache) + ); - const data = maskQuery( - { + expect(data).toEqual({ user: { __typename: "User", id: 1, @@ -237,245 +340,275 @@ test("does not strip inline fragments", () => { __typename: "UserProfile", avatarUrl: "https://example.com/avatar.jpg", }, - }, - query, - createFragmentMatcher(cache) - ); - - expect(data).toEqual({ - user: { - __typename: "User", - id: 1, - name: "Test User", - }, - profile: { - __typename: "UserProfile", - avatarUrl: "https://example.com/avatar.jpg", - }, + }); }); -}); -test("strips named fragments inside inline fragments", () => { - const cache = new InMemoryCache({ - possibleTypes: { Industry: ["TechIndustry"], Profile: ["UserProfile"] }, - }); - const query = gql` - query { - user { - __typename - id - ... @defer { - name - ...UserFields - } - } - profile { - __typename - ... on UserProfile { - avatarUrl - ...UserProfileFields + test("strips named fragments inside inline fragments", () => { + const cache = new InMemoryCache({ + possibleTypes: { Industry: ["TechIndustry"], Profile: ["UserProfile"] }, + }); + const query = gql` + query { + user { + __typename + id + ... @defer { + name + ...UserFields + } } - industry { + profile { __typename - ... on TechIndustry { - ...TechIndustryFields + ... on UserProfile { + avatarUrl + ...UserProfileFields + } + industry { + __typename + ... on TechIndustry { + ...TechIndustryFields + } } } } - } - fragment UserFields on User { - age - } + fragment UserFields on User { + age + } - fragment UserProfileFields on UserProfile { - hometown - } + fragment UserProfileFields on UserProfile { + hometown + } - fragment TechIndustryFields on TechIndustry { - favoriteLanguage - } - `; + fragment TechIndustryFields on TechIndustry { + favoriteLanguage + } + `; + + const data = maskOperation( + deepFreeze({ + user: { + __typename: "User", + id: 1, + name: "Test User", + age: 30, + }, + profile: { + __typename: "UserProfile", + avatarUrl: "https://example.com/avatar.jpg", + industry: { + __typename: "TechIndustry", + primaryLanguage: "TypeScript", + }, + }, + }), + query, + createFragmentMatcher(cache) + ); - const data = maskQuery( - { + expect(data).toEqual({ user: { __typename: "User", id: 1, name: "Test User", - age: 30, }, profile: { __typename: "UserProfile", avatarUrl: "https://example.com/avatar.jpg", - industry: { __typename: "TechIndustry", primaryLanguage: "TypeScript" }, + industry: { __typename: "TechIndustry" }, }, - }, - query, - createFragmentMatcher(cache) - ); - - expect(data).toEqual({ - user: { - __typename: "User", - id: 1, - name: "Test User", - }, - profile: { - __typename: "UserProfile", - avatarUrl: "https://example.com/avatar.jpg", - industry: { __typename: "TechIndustry" }, - }, + }); }); -}); -test("handles objects with no matching inline fragment condition", () => { - const cache = new InMemoryCache({ - possibleTypes: { - Drink: ["HotChocolate", "Juice"], - }, - }); + test("handles objects with no matching inline fragment condition", () => { + const cache = new InMemoryCache({ + possibleTypes: { + Drink: ["HotChocolate", "Juice"], + }, + }); - const query = gql` - query { - drinks { - __typename - id - ... on Juice { - fruitBase + const query = gql` + query { + drinks { + __typename + id + ... on Juice { + fruitBase + } } } - } - `; - - const data = maskQuery( - { + `; + + const data = maskOperation( + deepFreeze({ + drinks: [ + { __typename: "HotChocolate", id: 1 }, + { __typename: "Juice", id: 2, fruitBase: "Strawberry" }, + ], + }), + query, + createFragmentMatcher(cache) + ); + + expect(data).toEqual({ drinks: [ { __typename: "HotChocolate", id: 1 }, { __typename: "Juice", id: 2, fruitBase: "Strawberry" }, ], - }, - query, - createFragmentMatcher(cache) - ); - - expect(data).toEqual({ - drinks: [ - { __typename: "HotChocolate", id: 1 }, - { __typename: "Juice", id: 2, fruitBase: "Strawberry" }, - ], + }); }); -}); -test("handles field aliases", () => { - const query = gql` - query { - user { - __typename - id - fullName: name - ... @defer { - userAddress: address + test("handles field aliases", () => { + const query = gql` + query { + user { + __typename + id + fullName: name + ... @defer { + userAddress: address + } } } - } - `; + `; - const data = maskQuery( - { + const data = maskOperation( + deepFreeze({ + user: { + __typename: "User", + id: 1, + fullName: "Test User", + userAddress: "1234 Main St", + }, + }), + query, + createFragmentMatcher(new InMemoryCache()) + ); + + expect(data).toEqual({ user: { __typename: "User", id: 1, fullName: "Test User", userAddress: "1234 Main St", }, - }, - query, - createFragmentMatcher(new InMemoryCache()) - ); - - expect(data).toEqual({ - user: { - __typename: "User", - id: 1, - fullName: "Test User", - userAddress: "1234 Main St", - }, + }); }); -}); -test("handles overlapping fields inside multiple inline fragments", () => { - const cache = new InMemoryCache({ - possibleTypes: { - Drink: [ - "Espresso", - "Latte", - "Cappuccino", - "Cortado", - "Juice", - "HotChocolate", - ], - Espresso: ["Latte", "Cappuccino", "Cortado"], - }, - }); - const query = gql` - query { - drinks { - __typename - id - ... @defer { - amount - } - ... on Espresso { - milkType - ... on Latte { - flavor { - __typename - name - ...FlavorFields + test("handles overlapping fields inside multiple inline fragments", () => { + const cache = new InMemoryCache({ + possibleTypes: { + Drink: [ + "Espresso", + "Latte", + "Cappuccino", + "Cortado", + "Juice", + "HotChocolate", + ], + Espresso: ["Latte", "Cappuccino", "Cortado"], + }, + }); + const query = gql` + query { + drinks { + __typename + id + ... @defer { + amount + } + ... on Espresso { + milkType + ... on Latte { + flavor { + __typename + name + ...FlavorFields + } + } + ... on Cappuccino { + roast + } + ... on Cortado { + ...CortadoFields } } - ... on Cappuccino { - roast + ... on Latte { + ... @defer { + shots + } } - ... on Cortado { - ...CortadoFields + ... on Juice { + ...JuiceFields } - } - ... on Latte { - ... @defer { - shots + ... on HotChocolate { + milkType + ...HotChocolateFields } } - ... on Juice { - ...JuiceFields - } - ... on HotChocolate { - milkType - ...HotChocolateFields - } } - } - fragment JuiceFields on Juice { - fruitBase - } + fragment JuiceFields on Juice { + fruitBase + } - fragment HotChocolateFields on HotChocolate { - chocolateType - } + fragment HotChocolateFields on HotChocolate { + chocolateType + } - fragment FlavorFields on Flavor { - sweetness - } + fragment FlavorFields on Flavor { + sweetness + } - fragment CortadoFields on Cortado { - temperature - } - `; + fragment CortadoFields on Cortado { + temperature + } + `; + + const data = maskOperation( + deepFreeze({ + drinks: [ + { + __typename: "Latte", + id: 1, + amount: 12, + shots: 2, + milkType: "Cow", + flavor: { + __typename: "Flavor", + name: "Cookie Butter", + sweetness: "high", + }, + }, + { + __typename: "Cappuccino", + id: 2, + amount: 12, + milkType: "Cow", + roast: "medium", + }, + { + __typename: "Cortado", + id: 3, + amount: 12, + milkType: "Cow", + temperature: 150, + }, + { __typename: "Juice", id: 4, amount: 10, fruitBase: "Apple" }, + { + __typename: "HotChocolate", + id: 5, + amount: 8, + milkType: "Cow", + chocolateType: "dark", + }, + ], + }), + query, + createFragmentMatcher(cache) + ); - const data = maskQuery( - { + expect(data).toEqual({ drinks: [ { __typename: "Latte", @@ -486,7 +619,6 @@ test("handles overlapping fields inside multiple inline fragments", () => { flavor: { __typename: "Flavor", name: "Cookie Butter", - sweetness: "high", }, }, { @@ -501,485 +633,1662 @@ test("handles overlapping fields inside multiple inline fragments", () => { id: 3, amount: 12, milkType: "Cow", - temperature: 150, }, - { __typename: "Juice", id: 4, amount: 10, fruitBase: "Apple" }, + { __typename: "Juice", id: 4, amount: 10 }, { __typename: "HotChocolate", id: 5, amount: 8, milkType: "Cow", - chocolateType: "dark", }, ], - }, - query, - createFragmentMatcher(cache) - ); - - expect(data).toEqual({ - drinks: [ - { - __typename: "Latte", - id: 1, - amount: 12, - shots: 2, - milkType: "Cow", - flavor: { - __typename: "Flavor", - name: "Cookie Butter", - }, - }, - { - __typename: "Cappuccino", - id: 2, - amount: 12, - milkType: "Cow", - roast: "medium", - }, - { - __typename: "Cortado", - id: 3, - amount: 12, - milkType: "Cow", - }, - { __typename: "Juice", id: 4, amount: 10 }, - { - __typename: "HotChocolate", - id: 5, - amount: 8, - milkType: "Cow", - }, - ], + }); }); -}); -test("does nothing if there are no fragments to mask", () => { - const query = gql` - query { - user { - __typename - id - name + test("does nothing if there are no fragments to mask", () => { + const query = gql` + query { + user { + __typename + id + name + } } - } - `; + `; - const data = maskQuery( - { user: { __typename: "User", id: 1, name: "Test User" } }, - query, - createFragmentMatcher(new InMemoryCache()) - ); + const data = maskOperation( + deepFreeze({ user: { __typename: "User", id: 1, name: "Test User" } }), + query, + createFragmentMatcher(new InMemoryCache()) + ); - expect(data).toEqual({ - user: { __typename: "User", id: 1, name: "Test User" }, + expect(data).toEqual({ + user: { __typename: "User", id: 1, name: "Test User" }, + }); }); -}); -test("maintains referential equality on subtrees that did not change", () => { - const query = gql` - query { - user { - __typename - id - profile { + test("maintains referential equality on subtrees that did not change", () => { + const query = gql` + query { + user { __typename - avatarUrl - } - ...UserFields - } - post { - __typename - id - title - } - authors { - __typename - id - name - } - industries { - __typename - ... on TechIndustry { - languageRequirements + id + profile { + __typename + avatarUrl + } + ...UserFields } - ... on FinanceIndustry { - ...FinanceIndustryFields + post { + __typename + id + title } - ... on TradeIndustry { + authors { + __typename id - yearsInBusiness - ...TradeIndustryFields + name } - } - drink { - __typename - ... on SportsDrink { - saltContent + industries { + __typename + ... on TechIndustry { + languageRequirements + } + ... on FinanceIndustry { + ...FinanceIndustryFields + } + ... on TradeIndustry { + id + yearsInBusiness + ...TradeIndustryFields + } } - ... on Espresso { + drink { __typename + ... on SportsDrink { + saltContent + } + ... on Espresso { + __typename + } } } - } - fragment UserFields on User { - name - } + fragment UserFields on User { + name + } - fragment FinanceIndustryFields on FinanceIndustry { - yearsInBusiness - } + fragment FinanceIndustryFields on FinanceIndustry { + yearsInBusiness + } - fragment TradeIndustryFields on TradeIndustry { - languageRequirements - } - `; + fragment TradeIndustryFields on TradeIndustry { + languageRequirements + } + `; - const profile = { - __typename: "Profile", - avatarUrl: "https://example.com/avatar.jpg", - }; - const user = { __typename: "User", id: 1, name: "Test User", profile }; - const post = { __typename: "Post", id: 1, title: "Test Post" }; - const authors = [{ __typename: "Author", id: 1, name: "A Author" }]; - const industries = [ - { __typename: "TechIndustry", languageRequirements: ["TypeScript"] }, - { __typename: "FinanceIndustry", yearsInBusiness: 10 }, - { - __typename: "TradeIndustry", - id: 10, - yearsInBusiness: 15, - languageRequirements: ["English", "German"], - }, - ]; - const drink = { __typename: "Espresso" }; - const originalData = deepFreeze({ user, post, authors, industries, drink }); - - const data = maskQuery( - originalData, - query, - createFragmentMatcher(new InMemoryCache()) - ); - - expect(data).toEqual({ - user: { - __typename: "User", - id: 1, - profile: { - __typename: "Profile", - avatarUrl: "https://example.com/avatar.jpg", - }, - }, - post: { __typename: "Post", id: 1, title: "Test Post" }, - authors: [{ __typename: "Author", id: 1, name: "A Author" }], - industries: [ + const profile = { + __typename: "Profile", + avatarUrl: "https://example.com/avatar.jpg", + }; + const user = { __typename: "User", id: 1, name: "Test User", profile }; + const post = { __typename: "Post", id: 1, title: "Test Post" }; + const authors = [{ __typename: "Author", id: 1, name: "A Author" }]; + const industries = [ { __typename: "TechIndustry", languageRequirements: ["TypeScript"] }, - { __typename: "FinanceIndustry" }, - { __typename: "TradeIndustry", id: 10, yearsInBusiness: 15 }, - ], - drink: { __typename: "Espresso" }, - }); - - expect(data).not.toBe(originalData); - expect(data.user).not.toBe(user); - expect(data.user.profile).toBe(profile); - expect(data.post).toBe(post); - expect(data.authors).toBe(authors); - expect(data.industries).not.toBe(industries); - expect(data.industries[0]).toBe(industries[0]); - expect(data.industries[1]).not.toBe(industries[1]); - expect(data.industries[2]).not.toBe(industries[2]); - expect(data.drink).toBe(drink); -}); + { __typename: "FinanceIndustry", yearsInBusiness: 10 }, + { + __typename: "TradeIndustry", + id: 10, + yearsInBusiness: 15, + languageRequirements: ["English", "German"], + }, + ]; + const drink = { __typename: "Espresso" }; + const originalData = deepFreeze({ user, post, authors, industries, drink }); -test("maintains referential equality the entire result if there are no fragments", () => { - const query = gql` - query { - user { - __typename - id - name - } - } - `; + const data = maskOperation( + originalData, + query, + createFragmentMatcher(new InMemoryCache()) + ); - const originalData = deepFreeze({ - user: { - __typename: "User", - id: 1, - name: "Test User", - }, + expect(data).toEqual({ + user: { + __typename: "User", + id: 1, + profile: { + __typename: "Profile", + avatarUrl: "https://example.com/avatar.jpg", + }, + }, + post: { __typename: "Post", id: 1, title: "Test Post" }, + authors: [{ __typename: "Author", id: 1, name: "A Author" }], + industries: [ + { __typename: "TechIndustry", languageRequirements: ["TypeScript"] }, + { __typename: "FinanceIndustry" }, + { __typename: "TradeIndustry", id: 10, yearsInBusiness: 15 }, + ], + drink: { __typename: "Espresso" }, + }); + + expect(data).not.toBe(originalData); + expect(data.user).not.toBe(user); + expect(data.user.profile).toBe(profile); + expect(data.post).toBe(post); + expect(data.authors).toBe(authors); + expect(data.industries).not.toBe(industries); + expect(data.industries[0]).toBe(industries[0]); + expect(data.industries[1]).not.toBe(industries[1]); + expect(data.industries[2]).not.toBe(industries[2]); + expect(data.drink).toBe(drink); }); - const data = maskQuery( - originalData, - query, - createFragmentMatcher(new InMemoryCache()) - ); - - expect(data).toBe(originalData); -}); + test("maintains referential equality the entire result if there are no fragments", () => { + const query = gql` + query { + user { + __typename + id + name + } + } + `; -test("masks named fragments in fragment documents", () => { - const fragment = gql` - fragment UserFields on User { - __typename - id - ...UserProfile - } + const originalData = deepFreeze({ + user: { + __typename: "User", + id: 1, + name: "Test User", + }, + }); - fragment UserProfile on User { - age - } - `; + const data = maskOperation( + originalData, + query, + createFragmentMatcher(new InMemoryCache()) + ); - const data = maskFragment( - { __typename: "User", id: 1, age: 30 }, - fragment, - createFragmentMatcher(new InMemoryCache()), - "UserFields" - ); + expect(data).toBe(originalData); + }); - expect(data).toEqual({ __typename: "User", id: 1 }); -}); + test("does not mask named fragment fields and returns original object when using `@unmask` directive", () => { + const query = gql` + query UnmaskedQuery { + currentUser { + id + name + ...UserFields @unmask + __typename + } + } -test("masks named fragments in nested fragment objects", () => { - const fragment = gql` - fragment UserFields on User { - __typename - id - profile { - __typename - ...UserProfile + fragment UserFields on User { + age } - } + `; - fragment UserProfile on User { - age - } - `; + const queryData = deepFreeze({ + currentUser: { + __typename: "User", + id: 1, + name: "Test User", + age: 30, + }, + }); - const data = maskFragment( - { __typename: "User", id: 1, profile: { __typename: "Profile", age: 30 } }, - fragment, - createFragmentMatcher(new InMemoryCache()), - "UserFields" - ); + const data = maskOperation( + queryData, + query, + createFragmentMatcher(new InMemoryCache()) + ); - expect(data).toEqual({ - __typename: "User", - id: 1, - profile: { __typename: "Profile" }, + expect(data).toBe(queryData); }); -}); -test("does not mask inline fragment in fragment documents", () => { - const fragment = gql` - fragment UserFields on User { - __typename - id - ... @defer { - age + test("maintains referential equality on subtrees that contain @unmask", () => { + const query = gql` + query { + user { + __typename + id + profile { + __typename + avatarUrl + } + ...UserFields @unmask + } + post { + __typename + id + title + } + authors { + __typename + id + name + } + industries { + __typename + ... on TechIndustry { + ...TechIndustryFields @unmask + } + ... on FinanceIndustry { + ...FinanceIndustryFields + } + ... on TradeIndustry { + id + yearsInBusiness + ...TradeIndustryFields @unmask + } + } } - } - `; - const data = maskFragment( - { __typename: "User", id: 1, age: 30 }, - fragment, - createFragmentMatcher(new InMemoryCache()), - "UserFields" - ); + fragment UserFields on User { + name + ...UserSubfields @unmask + } - expect(data).toEqual({ __typename: "User", id: 1, age: 30 }); -}); + fragment UserSubfields on User { + age + } -test("throws when document contains more than 1 fragment without a fragmentName", () => { - const fragment = gql` - fragment UserFields on User { - __typename - id - ...UserProfile - } + fragment FinanceIndustryFields on FinanceIndustry { + yearsInBusiness + } - fragment UserProfile on User { - age - } - `; + fragment TradeIndustryFields on TradeIndustry { + languageRequirements + } - expect(() => - maskFragment( - { __typename: "User", id: 1, age: 30 }, - fragment, - createFragmentMatcher(new InMemoryCache()) - ) - ).toThrow( - new InvariantError( - "Found 2 fragments. `fragmentName` must be provided when there is not exactly 1 fragment." - ) - ); -}); + fragment TechIndustryFields on TechIndustry { + languageRequirements + ...TechIndustrySubFields + } -test("throws when fragment cannot be found within document", () => { - const fragment = gql` - fragment UserFields on User { - __typename - id - ...UserProfile - } + fragment TechIndustrySubFields on TechIndustry { + focus + } + `; - fragment UserProfile on User { - age - } - `; + const profile = { + __typename: "Profile", + avatarUrl: "https://example.com/avatar.jpg", + }; + const user = { + __typename: "User", + id: 1, + name: "Test User", + profile, + age: 30, + }; + const post = { __typename: "Post", id: 1, title: "Test Post" }; + const authors = [{ __typename: "Author", id: 1, name: "A Author" }]; + const industries = [ + { + __typename: "TechIndustry", + languageRequirements: ["TypeScript"], + focus: "innovation", + }, + { __typename: "FinanceIndustry", yearsInBusiness: 10 }, + { + __typename: "TradeIndustry", + id: 10, + yearsInBusiness: 15, + languageRequirements: ["English", "German"], + }, + ]; + const originalData = deepFreeze({ user, post, authors, industries }); - expect(() => - maskFragment( - { __typename: "User", id: 1, age: 30 }, - fragment, - createFragmentMatcher(new InMemoryCache()), - "ProfileFields" - ) - ).toThrow( - new InvariantError('Could not find fragment with name "ProfileFields".') - ); -}); + const data = maskOperation( + originalData, + query, + createFragmentMatcher(new InMemoryCache()) + ); -test("maintains referential equality on fragment subtrees that did not change", () => { - const fragment = gql` - fragment UserFields on User { - __typename - id - profile { - __typename - ...ProfileFields + expect(data).toEqual({ + user: { + __typename: "User", + name: "Test User", + id: 1, + profile: { + __typename: "Profile", + avatarUrl: "https://example.com/avatar.jpg", + }, + age: 30, + }, + post: { __typename: "Post", id: 1, title: "Test Post" }, + authors: [{ __typename: "Author", id: 1, name: "A Author" }], + industries: [ + { __typename: "TechIndustry", languageRequirements: ["TypeScript"] }, + { __typename: "FinanceIndustry" }, + { + __typename: "TradeIndustry", + id: 10, + yearsInBusiness: 15, + languageRequirements: ["English", "German"], + }, + ], + }); + + expect(data).not.toBe(originalData); + expect(data.user).toBe(user); + expect(data.user.profile).toBe(profile); + expect(data.post).toBe(post); + expect(data.authors).toBe(authors); + expect(data.industries).not.toBe(industries); + expect(data.industries[0]).not.toBe(industries[0]); + expect(data.industries[1]).not.toBe(industries[1]); + expect(data.industries[2]).toBe(industries[2]); + }); + + test("warns when accessing unmasked fields when using `@unmask` directive with mode 'migrate'", () => { + using _ = spyOnConsole("warn"); + const query = gql` + query UnmaskedQuery { + currentUser { + __typename + id + name + ...UserFields @unmask(mode: "migrate") + } } - post { - __typename - id - title + + fragment UserFields on User { + age } - industries { - __typename - ... on TechIndustry { - languageRequirements - } - ... on FinanceIndustry { - ...FinanceIndustryFields - } - ... on TradeIndustry { + `; + + const anonymousQuery = gql` + query { + currentUser { + __typename id - yearsInBusiness - ...TradeIndustryFields + name + ...UserFields @unmask(mode: "migrate") } } - drinks { - __typename - ... on SportsDrink { - ...SportsDrinkFields - } - ... on Espresso { + + fragment UserFields on User { + age + } + `; + + const currentUser = { + __typename: "User", + id: 1, + name: "Test User", + age: 30, + }; + + const fragmentMatcher = createFragmentMatcher(new InMemoryCache()); + + const data = maskOperation( + deepFreeze({ currentUser }), + query, + fragmentMatcher + ); + + const dataFromAnonymous = maskOperation( + { currentUser }, + anonymousQuery, + fragmentMatcher + ); + + data.currentUser.age; + + expect(console.warn).toHaveBeenCalledTimes(1); + expect(console.warn).toHaveBeenCalledWith( + "Accessing unmasked field on %s at path '%s'. This field will not be available when masking is enabled. Please read the field from the fragment instead.", + "query 'UnmaskedQuery'", + "currentUser.age" + ); + + dataFromAnonymous.currentUser.age; + + expect(console.warn).toHaveBeenCalledTimes(2); + expect(console.warn).toHaveBeenCalledWith( + "Accessing unmasked field on %s at path '%s'. This field will not be available when masking is enabled. Please read the field from the fragment instead.", + "anonymous query", + "currentUser.age" + ); + + data.currentUser.age; + dataFromAnonymous.currentUser.age; + + // Ensure we only warn once for each masked field + expect(console.warn).toHaveBeenCalledTimes(2); + }); + + test("does not warn when accessing unmasked fields when using `@unmask` directive with mode 'migrate' in non-DEV mode", () => { + using _ = withProdMode(); + using __ = spyOnConsole("warn"); + + const query = gql` + query UnmaskedQuery { + currentUser { __typename + id + name + ...UserFields @unmask(mode: "migrate") } } - } - fragment ProfileFields on Profile { - age - } + fragment UserFields on User { + age + } + `; - fragment FinanceIndustryFields on FinanceIndustry { - yearsInBusiness - } + const data = maskOperation( + deepFreeze({ + currentUser: { + __typename: "User", + id: 1, + name: "Test User", + age: 30, + }, + }), + query, + createFragmentMatcher(new InMemoryCache()) + ); - fragment TradeIndustryFields on TradeIndustry { - languageRequirements - } + const age = data.currentUser.age; - fragment SportsDrinkFields on SportsDrink { - saltContent - } - `; + expect(age).toBe(30); + expect(console.warn).not.toHaveBeenCalled(); + }); - const profile = { - __typename: "Profile", - age: 30, - }; - const post = { __typename: "Post", id: 1, title: "Test Post" }; - const industries = [ - { __typename: "TechIndustry", languageRequirements: ["TypeScript"] }, - { __typename: "FinanceIndustry", yearsInBusiness: 10 }, - { - __typename: "TradeIndustry", - id: 10, - yearsInBusiness: 15, - languageRequirements: ["English", "German"], - }, - ]; - const drinks = [ - { __typename: "Espresso" }, - { __typename: "SportsDrink", saltContent: "1000mg" }, - ]; - const user = deepFreeze({ - __typename: "User", - id: 1, - profile, - post, - industries, - drinks, - }); - - const data = maskFragment( - user, - fragment, - createFragmentMatcher(new InMemoryCache()), - "UserFields" - ); - - expect(data).toEqual({ - __typename: "User", - id: 1, - profile: { __typename: "Profile" }, - post: { __typename: "Post", id: 1, title: "Test Post" }, - industries: [ - { __typename: "TechIndustry", languageRequirements: ["TypeScript"] }, - { __typename: "FinanceIndustry" }, - { __typename: "TradeIndustry", id: 10, yearsInBusiness: 15 }, - ], - drinks: [{ __typename: "Espresso" }, { __typename: "SportsDrink" }], - }); - - expect(data).not.toBe(user); - expect(data.profile).not.toBe(profile); - expect(data.post).toBe(post); - expect(data.industries).not.toBe(industries); - expect(data.industries[0]).toBe(industries[0]); - expect(data.industries[1]).not.toBe(industries[1]); - expect(data.industries[2]).not.toBe(industries[2]); - expect(data.drinks).not.toBe(drinks); - expect(data.drinks[0]).toBe(drinks[0]); - expect(data.drinks[1]).not.toBe(drinks[1]); -}); + test("warns when accessing unmasked fields in arrays with mode: 'migrate'", () => { + using _ = spyOnConsole("warn"); + const query = gql` + query UnmaskedQuery { + users { + __typename + id + name + ...UserFields @unmask(mode: "migrate") + } + } -test("maintains referential equality on fragment when no data is masked", () => { - const fragment = gql` - fragment UserFields on User { - __typename - id - age - } - `; + fragment UserFields on User { + age + } + `; + + const fragmentMatcher = createFragmentMatcher(new InMemoryCache()); + + const data = maskOperation( + deepFreeze({ + users: [ + { __typename: "User", id: 1, name: "John Doe", age: 30 }, + { __typename: "User", id: 2, name: "Jane Doe", age: 30 }, + ], + }), + query, + fragmentMatcher + ); + + data.users[0].age; + data.users[1].age; + + expect(console.warn).toHaveBeenCalledTimes(2); + expect(console.warn).toHaveBeenCalledWith( + "Accessing unmasked field on %s at path '%s'. This field will not be available when masking is enabled. Please read the field from the fragment instead.", + "query 'UnmaskedQuery'", + "users[0].age" + ); + + expect(console.warn).toHaveBeenCalledWith( + "Accessing unmasked field on %s at path '%s'. This field will not be available when masking is enabled. Please read the field from the fragment instead.", + "query 'UnmaskedQuery'", + "users[1].age" + ); + }); - const user = { __typename: "User", id: 1, age: 30 }; + test("can mix and match masked vs unmasked fragment fields with proper warnings", () => { + using _ = spyOnConsole("warn"); - const data = maskFragment( - user, - fragment, - createFragmentMatcher(new InMemoryCache()) - ); + const query = gql` + query UnmaskedQuery { + currentUser { + __typename + id + name + ...UserFields @unmask + } + } - expect(data).toBe(user); + fragment UserFields on User { + age + profile { + __typename + email + ... @defer { + username + } + ...ProfileFields + } + skills { + __typename + name + ...SkillFields @unmask(mode: "migrate") + } + } + + fragment ProfileFields on Profile { + settings { + __typename + darkMode + } + } + + fragment SkillFields on Skill { + description + } + `; + + const data = maskOperation( + deepFreeze({ + currentUser: { + __typename: "User", + id: 1, + name: "Test User", + age: 30, + profile: { + __typename: "Profile", + email: "testuser@example.com", + username: "testuser", + settings: { + __typename: "Settings", + darkMode: true, + }, + }, + skills: [ + { + __typename: "Skill", + name: "Skill 1", + description: "Skill 1 description", + }, + { + __typename: "Skill", + name: "Skill 2", + description: "Skill 2 description", + }, + ], + }, + }), + query, + createFragmentMatcher(new InMemoryCache()) + ); + + expect(data).toEqual({ + currentUser: { + __typename: "User", + id: 1, + name: "Test User", + age: 30, + profile: { + __typename: "Profile", + email: "testuser@example.com", + username: "testuser", + }, + skills: [ + { + __typename: "Skill", + name: "Skill 1", + description: "Skill 1 description", + }, + { + __typename: "Skill", + name: "Skill 2", + description: "Skill 2 description", + }, + ], + }, + }); + + expect(console.warn).toHaveBeenCalledTimes(2); + expect(console.warn).toHaveBeenCalledWith( + "Accessing unmasked field on %s at path '%s'. This field will not be available when masking is enabled. Please read the field from the fragment instead.", + "query 'UnmaskedQuery'", + "currentUser.skills[0].description" + ); + expect(console.warn).toHaveBeenCalledWith( + "Accessing unmasked field on %s at path '%s'. This field will not be available when masking is enabled. Please read the field from the fragment instead.", + "query 'UnmaskedQuery'", + "currentUser.skills[1].description" + ); + }); + + test("masks child fragments of @unmask(mode: 'migrate')", () => { + using _ = spyOnConsole("warn"); + + const query = gql` + query UnmaskedQuery { + currentUser { + __typename + id + name + ...UserFields @unmask(mode: "migrate") + } + } + + fragment UserFields on User { + age + ...UserSubfields + ...UserSubfields2 @unmask + } + + fragment UserSubfields on User { + username + } + + fragment UserSubfields2 on User { + email + } + `; + + const data = maskOperation( + deepFreeze({ + currentUser: { + __typename: "User", + id: 1, + name: "Test User", + age: 30, + username: "testuser", + email: "test@example.com", + }, + }), + query, + createFragmentMatcher(new InMemoryCache()) + ); + + expect(data).toEqual({ + currentUser: { + __typename: "User", + id: 1, + name: "Test User", + age: 30, + email: "test@example.com", + }, + }); + + expect(console.warn).toHaveBeenCalledTimes(1); + expect(console.warn).toHaveBeenCalledWith( + "Accessing unmasked field on %s at path '%s'. This field will not be available when masking is enabled. Please read the field from the fragment instead.", + "query 'UnmaskedQuery'", + "currentUser.age" + ); + }); + + test("warns when accessing unmasked fields with complex selections with mode: 'migrate'", () => { + using _ = spyOnConsole("warn"); + const query = gql` + query UnmaskedQuery { + currentUser { + __typename + id + name + ...UserFields @unmask(mode: "migrate") + } + } + + fragment UserFields on User { + age + profile { + __typename + email + ... @defer { + username + } + ...ProfileFields @unmask(mode: "migrate") + } + skills { + __typename + name + ...SkillFields @unmask(mode: "migrate") + } + } + + fragment ProfileFields on Profile { + settings { + __typename + dark: darkMode + } + } + + fragment SkillFields on Skill { + description + } + `; + + const currentUser = { + __typename: "User", + id: 1, + name: "Test User", + age: 30, + profile: { + __typename: "Profile", + email: "testuser@example.com", + username: "testuser", + settings: { + __typename: "Settings", + dark: true, + }, + }, + skills: [ + { + __typename: "Skill", + name: "Skill 1", + description: "Skill 1 description", + }, + { + __typename: "Skill", + name: "Skill 2", + description: "Skill 2 description", + }, + ], + }; + + const fragmentMatcher = createFragmentMatcher(new InMemoryCache()); + + const data = maskOperation( + deepFreeze({ currentUser }), + query, + fragmentMatcher + ); + + data.currentUser.age; + data.currentUser.profile.email; + data.currentUser.profile.username; + data.currentUser.profile.settings; + data.currentUser.profile.settings.dark; + data.currentUser.skills[0].description; + data.currentUser.skills[1].description; + + expect(console.warn).toHaveBeenCalledTimes(9); + expect(console.warn).toHaveBeenCalledWith( + "Accessing unmasked field on %s at path '%s'. This field will not be available when masking is enabled. Please read the field from the fragment instead.", + "query 'UnmaskedQuery'", + "currentUser.age" + ); + + expect(console.warn).toHaveBeenCalledWith( + "Accessing unmasked field on %s at path '%s'. This field will not be available when masking is enabled. Please read the field from the fragment instead.", + "query 'UnmaskedQuery'", + "currentUser.profile" + ); + + expect(console.warn).toHaveBeenCalledWith( + "Accessing unmasked field on %s at path '%s'. This field will not be available when masking is enabled. Please read the field from the fragment instead.", + "query 'UnmaskedQuery'", + "currentUser.profile.email" + ); + + expect(console.warn).toHaveBeenCalledWith( + "Accessing unmasked field on %s at path '%s'. This field will not be available when masking is enabled. Please read the field from the fragment instead.", + "query 'UnmaskedQuery'", + "currentUser.profile.username" + ); + + expect(console.warn).toHaveBeenCalledWith( + "Accessing unmasked field on %s at path '%s'. This field will not be available when masking is enabled. Please read the field from the fragment instead.", + "query 'UnmaskedQuery'", + "currentUser.profile.settings" + ); + + expect(console.warn).toHaveBeenCalledWith( + "Accessing unmasked field on %s at path '%s'. This field will not be available when masking is enabled. Please read the field from the fragment instead.", + "query 'UnmaskedQuery'", + "currentUser.profile.settings.dark" + ); + + expect(console.warn).toHaveBeenCalledWith( + "Accessing unmasked field on %s at path '%s'. This field will not be available when masking is enabled. Please read the field from the fragment instead.", + "query 'UnmaskedQuery'", + "currentUser.skills" + ); + + expect(console.warn).toHaveBeenCalledWith( + "Accessing unmasked field on %s at path '%s'. This field will not be available when masking is enabled. Please read the field from the fragment instead.", + "query 'UnmaskedQuery'", + "currentUser.skills[0].description" + ); + + expect(console.warn).toHaveBeenCalledWith( + "Accessing unmasked field on %s at path '%s'. This field will not be available when masking is enabled. Please read the field from the fragment instead.", + "query 'UnmaskedQuery'", + "currentUser.skills[1].description" + ); + }); + + test("does not warn when accessing fields shared between the query and fragment with mode: 'migrate'", () => { + using _ = spyOnConsole("warn"); + const query = gql` + query UnmaskedQuery { + currentUser { + __typename + id + name + age + ...UserFields @unmask(mode: "migrate") + email + } + } + + fragment UserFields on User { + age + email + } + `; + + const fragmentMatcher = createFragmentMatcher(new InMemoryCache()); + + const data = maskOperation( + deepFreeze({ + currentUser: { + __typename: "User", + id: 1, + name: "Test User", + age: 30, + email: "testuser@example.com", + }, + }), + query, + fragmentMatcher + ); + + data.currentUser.age; + data.currentUser.email; + + expect(console.warn).not.toHaveBeenCalled(); + }); + + test("does not warn accessing fields with `@unmask` without mode argument", () => { + using _ = spyOnConsole("warn"); + const query = gql` + query UnmaskedQuery { + currentUser { + __typename + id + name + ...UserFields @unmask + } + } + + fragment UserFields on User { + age + } + `; + + const fragmentMatcher = createFragmentMatcher(new InMemoryCache()); + + const data = maskOperation( + deepFreeze({ + currentUser: { + __typename: "User", + id: 1, + name: "Test User", + age: 30, + }, + }), + query, + fragmentMatcher + ); + + data.currentUser.age; + + expect(console.warn).not.toHaveBeenCalled(); + }); + + test("masks fragments in subscription documents", () => { + const subscription = gql` + subscription { + onUserUpdated { + __typename + id + ...UserFields + } + } + + fragment UserFields on User { + name + } + `; + + const data = maskOperation( + deepFreeze({ + onUserUpdated: { __typename: "User", id: 1, name: "Test User" }, + }), + subscription, + createFragmentMatcher(new InMemoryCache()) + ); + + expect(data).toEqual({ onUserUpdated: { __typename: "User", id: 1 } }); + }); + + test("honors @unmask used in subscription documents", () => { + const subscription = gql` + subscription { + onUserUpdated { + __typename + id + ...UserFields @unmask + } + } + + fragment UserFields on User { + name + } + `; + + const subscriptionData = deepFreeze({ + onUserUpdated: { __typename: "User", id: 1, name: "Test User" }, + }); + + const data = maskOperation( + subscriptionData, + subscription, + createFragmentMatcher(new InMemoryCache()) + ); + + expect(data).toBe(subscriptionData); + }); + + test("warns when accessing unmasked fields used in subscription documents with @unmask(mode: 'migrate')", () => { + using _ = spyOnConsole("warn"); + + const subscription = gql` + subscription UserUpdatedSubscription { + onUserUpdated { + __typename + id + ...UserFields @unmask(mode: "migrate") + } + } + + fragment UserFields on User { + name + } + `; + + const subscriptionData = deepFreeze({ + onUserUpdated: { __typename: "User", id: 1, name: "Test User" }, + }); + + const data = maskOperation( + subscriptionData, + subscription, + createFragmentMatcher(new InMemoryCache()) + ); + + data.onUserUpdated.name; + + expect(console.warn).toHaveBeenCalledTimes(1); + expect(console.warn).toHaveBeenCalledWith( + "Accessing unmasked field on %s at path '%s'. This field will not be available when masking is enabled. Please read the field from the fragment instead.", + "subscription 'UserUpdatedSubscription'", + "onUserUpdated.name" + ); + }); + + test("masks fragments in mutation documents", () => { + const mutation = gql` + mutation { + updateUser { + __typename + id + ...UserFields + } + } + + fragment UserFields on User { + name + } + `; + + const data = maskOperation( + deepFreeze({ + updateUser: { __typename: "User", id: 1, name: "Test User" }, + }), + mutation, + createFragmentMatcher(new InMemoryCache()) + ); + + expect(data).toEqual({ updateUser: { __typename: "User", id: 1 } }); + }); + + test("honors @unmask used in mutation documents", () => { + const mutation = gql` + mutation { + updateUser { + __typename + id + ...UserFields @unmask + } + } + + fragment UserFields on User { + name + } + `; + + const mutationData = deepFreeze({ + updateUser: { __typename: "User", id: 1, name: "Test User" }, + }); + + const data = maskOperation( + mutationData, + mutation, + createFragmentMatcher(new InMemoryCache()) + ); + + expect(data).toBe(mutationData); + }); + + test("warns when accessing unmasked fields used in mutation documents with @unmask(mode: 'migrate')", () => { + using _ = spyOnConsole("warn"); + + const mutation = gql` + mutation UpdateUserMutation { + updateUser { + __typename + id + ...UserFields @unmask(mode: "migrate") + } + } + + fragment UserFields on User { + name + } + `; + + const mutationData = deepFreeze({ + updateUser: { __typename: "User", id: 1, name: "Test User" }, + }); + + const data = maskOperation( + mutationData, + mutation, + createFragmentMatcher(new InMemoryCache()) + ); + + data.updateUser.name; + + expect(console.warn).toHaveBeenCalledTimes(1); + expect(console.warn).toHaveBeenCalledWith( + "Accessing unmasked field on %s at path '%s'. This field will not be available when masking is enabled. Please read the field from the fragment instead.", + "mutation 'UpdateUserMutation'", + "updateUser.name" + ); + }); +}); + +describe("maskFragment", () => { + test("masks named fragments in fragment documents", () => { + const fragment = gql` + fragment UserFields on User { + __typename + id + ...UserProfile + } + + fragment UserProfile on User { + age + } + `; + + const data = maskFragment( + deepFreeze({ __typename: "User", id: 1, age: 30 }), + fragment, + createFragmentMatcher(new InMemoryCache()), + "UserFields" + ); + + expect(data).toEqual({ __typename: "User", id: 1 }); + }); + + test("masks named fragments in nested fragment objects", () => { + const fragment = gql` + fragment UserFields on User { + __typename + id + profile { + __typename + ...UserProfile + } + } + + fragment UserProfile on User { + age + } + `; + + const data = maskFragment( + deepFreeze({ + __typename: "User", + id: 1, + profile: { __typename: "Profile", age: 30 }, + }), + fragment, + createFragmentMatcher(new InMemoryCache()), + "UserFields" + ); + + expect(data).toEqual({ + __typename: "User", + id: 1, + profile: { __typename: "Profile" }, + }); + }); + + test("deep freezes the masked result if the original data is frozen", () => { + const fragment = gql` + fragment UserFields on User { + __typename + id + profile { + __typename + ...UserProfile + } + } + + fragment UserProfile on User { + age + } + `; + + const frozenData = maskFragment( + deepFreeze({ + __typename: "User", + id: 1, + profile: { __typename: "Profile", age: 30 }, + }), + fragment, + createFragmentMatcher(new InMemoryCache()), + "UserFields" + ); + + const nonFrozenData = maskFragment( + { + __typename: "User", + id: 1, + profile: { __typename: "Profile", age: 30 }, + }, + fragment, + createFragmentMatcher(new InMemoryCache()), + "UserFields" + ); + + expect(Object.isFrozen(frozenData)).toBe(true); + expect(Object.isFrozen(nonFrozenData)).toBe(false); + }); + + test("does not mask inline fragment in fragment documents", () => { + const fragment = gql` + fragment UserFields on User { + __typename + id + ... @defer { + age + } + } + `; + + const data = maskFragment( + deepFreeze({ __typename: "User", id: 1, age: 30 }), + fragment, + createFragmentMatcher(new InMemoryCache()), + "UserFields" + ); + + expect(data).toEqual({ __typename: "User", id: 1, age: 30 }); + }); + + test("throws when document contains more than 1 fragment without a fragmentName", () => { + const fragment = gql` + fragment UserFields on User { + __typename + id + ...UserProfile + } + + fragment UserProfile on User { + age + } + `; + + expect(() => + maskFragment( + deepFreeze({ __typename: "User", id: 1, age: 30 }), + fragment, + createFragmentMatcher(new InMemoryCache()) + ) + ).toThrow( + new InvariantError( + "Found 2 fragments. `fragmentName` must be provided when there is not exactly 1 fragment." + ) + ); + }); + + test("throws when fragment cannot be found within document", () => { + const fragment = gql` + fragment UserFields on User { + __typename + id + ...UserProfile + } + + fragment UserProfile on User { + age + } + `; + + expect(() => + maskFragment( + deepFreeze({ __typename: "User", id: 1, age: 30 }), + fragment, + createFragmentMatcher(new InMemoryCache()), + "ProfileFields" + ) + ).toThrow( + new InvariantError('Could not find fragment with name "ProfileFields".') + ); + }); + + test("maintains referential equality on fragment subtrees that did not change", () => { + const fragment = gql` + fragment UserFields on User { + __typename + id + profile { + __typename + ...ProfileFields + } + post { + __typename + id + title + } + industries { + __typename + ... on TechIndustry { + languageRequirements + } + ... on FinanceIndustry { + ...FinanceIndustryFields + } + ... on TradeIndustry { + id + yearsInBusiness + ...TradeIndustryFields + } + } + drinks { + __typename + ... on SportsDrink { + ...SportsDrinkFields + } + ... on Espresso { + __typename + } + } + } + + fragment ProfileFields on Profile { + age + } + + fragment FinanceIndustryFields on FinanceIndustry { + yearsInBusiness + } + + fragment TradeIndustryFields on TradeIndustry { + languageRequirements + } + + fragment SportsDrinkFields on SportsDrink { + saltContent + } + `; + + const profile = { + __typename: "Profile", + age: 30, + }; + const post = { __typename: "Post", id: 1, title: "Test Post" }; + const industries = [ + { __typename: "TechIndustry", languageRequirements: ["TypeScript"] }, + { __typename: "FinanceIndustry", yearsInBusiness: 10 }, + { + __typename: "TradeIndustry", + id: 10, + yearsInBusiness: 15, + languageRequirements: ["English", "German"], + }, + ]; + const drinks = [ + { __typename: "Espresso" }, + { __typename: "SportsDrink", saltContent: "1000mg" }, + ]; + const user = deepFreeze({ + __typename: "User", + id: 1, + profile, + post, + industries, + drinks, + }); + + const data = maskFragment( + user, + fragment, + createFragmentMatcher(new InMemoryCache()), + "UserFields" + ); + + expect(data).toEqual({ + __typename: "User", + id: 1, + profile: { __typename: "Profile" }, + post: { __typename: "Post", id: 1, title: "Test Post" }, + industries: [ + { __typename: "TechIndustry", languageRequirements: ["TypeScript"] }, + { __typename: "FinanceIndustry" }, + { __typename: "TradeIndustry", id: 10, yearsInBusiness: 15 }, + ], + drinks: [{ __typename: "Espresso" }, { __typename: "SportsDrink" }], + }); + + expect(data).not.toBe(user); + expect(data.profile).not.toBe(profile); + expect(data.post).toBe(post); + expect(data.industries).not.toBe(industries); + expect(data.industries[0]).toBe(industries[0]); + expect(data.industries[1]).not.toBe(industries[1]); + expect(data.industries[2]).not.toBe(industries[2]); + expect(data.drinks).not.toBe(drinks); + expect(data.drinks[0]).toBe(drinks[0]); + expect(data.drinks[1]).not.toBe(drinks[1]); + }); + + test("maintains referential equality on fragment when no data is masked", () => { + const fragment = gql` + fragment UserFields on User { + __typename + id + age + } + `; + + const user = { __typename: "User", id: 1, age: 30 }; + + const data = maskFragment( + deepFreeze(user), + fragment, + createFragmentMatcher(new InMemoryCache()) + ); + + expect(data).toBe(user); + }); + + test("does not mask named fragments and returns original object when using `@unmask` directive", () => { + const fragment = gql` + fragment UnmaskedFragment on User { + id + name + ...UserFields @unmask + __typename + } + + fragment UserFields on User { + age + } + `; + + const fragmentData = deepFreeze({ + __typename: "User", + id: 1, + name: "Test User", + age: 30, + }); + + const data = maskFragment( + fragmentData, + fragment, + createFragmentMatcher(new InMemoryCache()), + "UnmaskedFragment" + ); + + expect(data).toBe(fragmentData); + }); + + test("warns when accessing unmasked fields when using `@unmask` directive with mode 'migrate'", () => { + using _ = spyOnConsole("warn"); + const query = gql` + fragment UnmaskedFragment on User { + __typename + id + name + ...UserFields @unmask(mode: "migrate") + } + + fragment UserFields on User { + age + } + `; + + const fragmentMatcher = createFragmentMatcher(new InMemoryCache()); + + const data = maskFragment( + deepFreeze({ + currentUser: { + __typename: "User", + id: 1, + name: "Test User", + age: 30, + }, + }), + query, + fragmentMatcher, + "UnmaskedFragment" + ); + + data.age; + + expect(console.warn).toHaveBeenCalledTimes(1); + expect(console.warn).toHaveBeenCalledWith( + "Accessing unmasked field on %s at path '%s'. This field will not be available when masking is enabled. Please read the field from the fragment instead.", + "fragment 'UnmaskedFragment'", + "age" + ); + + data.age; + + // Ensure we only warn once for each masked field + expect(console.warn).toHaveBeenCalledTimes(1); + }); + + test("maintains referential equality on `@unmask` fragment subtrees", () => { + const fragment = gql` + fragment UserFields on User { + __typename + id + profile { + __typename + ...ProfileFields @unmask + } + post { + __typename + id + title + } + industries { + __typename + ... on TechIndustry { + languageRequirements + } + ... on FinanceIndustry { + ...FinanceIndustryFields + } + ... on TradeIndustry { + id + yearsInBusiness + ...TradeIndustryFields + } + } + drinks { + __typename + ... on SportsDrink { + ...SportsDrinkFields @unmask + } + ... on Espresso { + __typename + } + } + } + + fragment ProfileFields on Profile { + age + ...ProfileSubfields @unmask + } + + fragment ProfileSubfields on Profile { + name + } + + fragment FinanceIndustryFields on FinanceIndustry { + yearsInBusiness + } + + fragment TradeIndustryFields on TradeIndustry { + languageRequirements + } + + fragment SportsDrinkFields on SportsDrink { + saltContent + } + `; + + const profile = { + __typename: "Profile", + age: 30, + name: "Test User", + }; + const post = { __typename: "Post", id: 1, title: "Test Post" }; + const industries = [ + { __typename: "TechIndustry", languageRequirements: ["TypeScript"] }, + { __typename: "FinanceIndustry", yearsInBusiness: 10 }, + { + __typename: "TradeIndustry", + id: 10, + yearsInBusiness: 15, + languageRequirements: ["English", "German"], + }, + ]; + const drinks = [ + { __typename: "Espresso" }, + { __typename: "SportsDrink", saltContent: "1000mg" }, + ]; + const user = deepFreeze({ + __typename: "User", + id: 1, + profile, + post, + industries, + drinks, + }); + + const data = maskFragment( + user, + fragment, + createFragmentMatcher(new InMemoryCache()), + "UserFields" + ); + + expect(data).toEqual({ + __typename: "User", + id: 1, + profile: { __typename: "Profile", age: 30, name: "Test User" }, + post: { __typename: "Post", id: 1, title: "Test Post" }, + industries: [ + { __typename: "TechIndustry", languageRequirements: ["TypeScript"] }, + { __typename: "FinanceIndustry" }, + { __typename: "TradeIndustry", id: 10, yearsInBusiness: 15 }, + ], + drinks: [ + { __typename: "Espresso" }, + { __typename: "SportsDrink", saltContent: "1000mg" }, + ], + }); + + expect(data).not.toBe(user); + expect(data.profile).toBe(profile); + expect(data.post).toBe(post); + expect(data.industries).not.toBe(industries); + expect(data.industries[0]).toBe(industries[0]); + expect(data.industries[1]).not.toBe(industries[1]); + expect(data.industries[2]).not.toBe(industries[2]); + expect(data.drinks).toBe(drinks); + expect(data.drinks[0]).toBe(drinks[0]); + expect(data.drinks[1]).toBe(drinks[1]); + }); + + test("masks child fragments of @unmask(mode: 'migrate')", () => { + using _ = spyOnConsole("warn"); + + const fragment = gql` + fragment UnmaskedUser on User { + __typename + id + name + ...UserFields @unmask(mode: "migrate") + } + + fragment UserFields on User { + age + ...UserSubfields + ...UserSubfields2 @unmask + } + + fragment UserSubfields on User { + username + } + + fragment UserSubfields2 on User { + email + } + `; + + const data = maskFragment( + deepFreeze({ + __typename: "User", + id: 1, + name: "Test User", + age: 30, + username: "testuser", + email: "test@example.com", + }), + fragment, + createFragmentMatcher(new InMemoryCache()), + "UnmaskedUser" + ); + + expect(data).toEqual({ + __typename: "User", + id: 1, + name: "Test User", + age: 30, + email: "test@example.com", + }); + + expect(console.warn).toHaveBeenCalledTimes(1); + expect(console.warn).toHaveBeenCalledWith( + "Accessing unmasked field on %s at path '%s'. This field will not be available when masking is enabled. Please read the field from the fragment instead.", + "fragment 'UnmaskedUser'", + "age" + ); + }); }); function createFragmentMatcher(cache: InMemoryCache) { diff --git a/src/core/masking.ts b/src/core/masking.ts index 4c77d94a675..8303215edd6 100644 --- a/src/core/masking.ts +++ b/src/core/masking.ts @@ -5,9 +5,14 @@ import type { SelectionSetNode, } from "graphql"; import { - getMainDefinition, + createFragmentMap, resultKeyNameFromField, + getFragmentDefinitions, + getFragmentMaskMode, + getOperationDefinition, + maybeDeepFreeze, } from "../utilities/index.js"; +import type { FragmentMap } from "../utilities/index.js"; import type { DocumentNode, TypedDocumentNode } from "./index.js"; import { invariant } from "../utilities/globals/index.js"; @@ -16,18 +21,45 @@ type MatchesFragmentFn = ( typename: string ) => boolean; -export function maskQuery( +interface MaskingContext { + operationType: "query" | "mutation" | "subscription" | "fragment"; + operationName: string | undefined; + fragmentMap: FragmentMap; + matchesFragment: MatchesFragmentFn; + disableWarnings?: boolean; +} + +export function maskOperation( data: TData, document: TypedDocumentNode | DocumentNode, matchesFragment: MatchesFragmentFn ): TData { - const definition = getMainDefinition(document); + const definition = getOperationDefinition(document); + + invariant( + definition, + "Expected a parsed GraphQL document with a query, mutation, or subscription." + ); + + const context: MaskingContext = { + operationType: definition.operation, + operationName: definition.name?.value, + fragmentMap: createFragmentMap(getFragmentDefinitions(document)), + matchesFragment, + }; + const [masked, changed] = maskSelectionSet( data, definition.selectionSet, - matchesFragment + context ); + if (Object.isFrozen(data)) { + context.disableWarnings = true; + maybeDeepFreeze(masked); + context.disableWarnings = false; + } + return changed ? masked : data; } @@ -61,28 +93,43 @@ export function maskFragment( fragmentName ); + const context: MaskingContext = { + operationType: "fragment", + operationName: fragment.name.value, + fragmentMap: createFragmentMap(getFragmentDefinitions(document)), + matchesFragment, + }; + const [masked, changed] = maskSelectionSet( data, fragment.selectionSet, - matchesFragment + context ); + if (Object.isFrozen(data)) { + context.disableWarnings = true; + maybeDeepFreeze(masked); + context.disableWarnings = false; + } + return changed ? masked : data; } function maskSelectionSet( data: any, selectionSet: SelectionSetNode, - matchesFragment: MatchesFragmentFn + context: MaskingContext, + path?: string | undefined ): [data: any, changed: boolean] { if (Array.isArray(data)) { let changed = false; - const masked = data.map((item) => { + const masked = data.map((item, index) => { const [masked, itemChanged] = maskSelectionSet( item, selectionSet, - matchesFragment + context, + __DEV__ ? `${path || ""}[${index}]` : void 0 ); changed ||= itemChanged; @@ -105,7 +152,8 @@ function maskSelectionSet( const [masked, childChanged] = maskSelectionSet( data[keyName], childSelectionSet, - matchesFragment + context, + __DEV__ ? `${path || ""}.${keyName}` : void 0 ); if (childChanged) { @@ -119,7 +167,7 @@ function maskSelectionSet( case Kind.INLINE_FRAGMENT: { if ( selection.typeCondition && - !matchesFragment(selection, data.__typename) + !context.matchesFragment(selection, data.__typename) ) { return [memo, changed]; } @@ -127,7 +175,8 @@ function maskSelectionSet( const [fragmentData, childChanged] = maskSelectionSet( data, selection.selectionSet, - matchesFragment + context, + path ); return [ @@ -138,10 +187,168 @@ function maskSelectionSet( changed || childChanged, ]; } - default: - return [memo, true]; + case Kind.FRAGMENT_SPREAD: { + const fragment = context.fragmentMap[selection.name.value]; + const mode = getFragmentMaskMode(selection); + + if (mode === "mask") { + return [memo, true]; + } + + if (__DEV__) { + if (mode === "migrate") { + return [ + addFieldAccessorWarnings( + memo, + data, + fragment.selectionSet, + path || "", + context + ), + true, + ]; + } + } + + const [fragmentData, changed] = maskSelectionSet( + data, + fragment.selectionSet, + context, + path + ); + + return [{ ...memo, ...fragmentData }, changed]; + } } }, [Object.create(null), false] ); } + +function addFieldAccessorWarnings( + memo: Record, + data: Record, + selectionSetNode: SelectionSetNode, + path: string, + context: MaskingContext +) { + if (Array.isArray(data)) { + return data.map((item, index): unknown => { + return addFieldAccessorWarnings( + memo[index] || Object.create(null), + item, + selectionSetNode, + `${path}[${index}]`, + context + ); + }); + } + + return selectionSetNode.selections.reduce((memo, selection) => { + switch (selection.kind) { + case Kind.FIELD: { + const keyName = resultKeyNameFromField(selection); + const childSelectionSet = selection.selectionSet; + + if (keyName in memo) { + return memo; + } + + let value = data[keyName]; + + if (childSelectionSet) { + value = addFieldAccessorWarnings( + memo[keyName] || Object.create(null), + data[keyName] as Record, + childSelectionSet, + `${path}.${keyName}`, + context + ); + } + + if (__DEV__) { + addAccessorWarning(memo, value, keyName, path, context); + } + + if (!__DEV__) { + memo[keyName] = data[keyName]; + } + + return memo; + } + case Kind.INLINE_FRAGMENT: { + return addFieldAccessorWarnings( + memo, + data, + selection.selectionSet, + path, + context + ); + } + case Kind.FRAGMENT_SPREAD: { + const fragment = context.fragmentMap[selection.name.value]; + const mode = getFragmentMaskMode(selection); + + if (mode === "mask") { + return memo; + } + + if (mode === "unmask") { + const [fragmentData] = maskSelectionSet( + data, + fragment.selectionSet, + context, + path + ); + + return Object.assign(memo, fragmentData); + } + + return addFieldAccessorWarnings( + memo, + data, + fragment.selectionSet, + path, + context + ); + } + } + }, memo); +} + +function addAccessorWarning( + data: Record, + value: any, + fieldName: string, + path: string, + context: MaskingContext +) { + let getValue = () => { + if (context.disableWarnings) { + return value; + } + + invariant.warn( + "Accessing unmasked field on %s at path '%s'. This field will not be available when masking is enabled. Please read the field from the fragment instead.", + context.operationName ? + `${context.operationType} '${context.operationName}'` + : `anonymous ${context.operationType}`, + `${path}.${fieldName}`.replace(/^\./, "") + ); + + getValue = () => value; + + return value; + }; + + Object.defineProperty(data, fieldName, { + get() { + return getValue(); + }, + set(value) { + getValue = () => value; + }, + enumerable: true, + configurable: true, + }); +} diff --git a/src/testing/core/mocking/mockLink.ts b/src/testing/core/mocking/mockLink.ts index f38474e1c20..81430a66306 100644 --- a/src/testing/core/mocking/mockLink.ts +++ b/src/testing/core/mocking/mockLink.ts @@ -211,7 +211,7 @@ ${unmatchedVars.map((d) => ` ${stringifyForDisplay(d)}`).join("\n")} ): MockedResponse { const newMockedResponse = cloneDeep(mockedResponse); const queryWithoutClientOnlyDirectives = removeDirectivesFromDocument( - [{ name: "connection" }, { name: "nonreactive" }], + [{ name: "connection" }, { name: "nonreactive" }, { name: "unmask" }], checkDocument(newMockedResponse.request.query) ); invariant(queryWithoutClientOnlyDirectives, "query is required"); diff --git a/src/testing/internal/disposables/index.ts b/src/testing/internal/disposables/index.ts index 9895d129589..1a853f86de1 100644 --- a/src/testing/internal/disposables/index.ts +++ b/src/testing/internal/disposables/index.ts @@ -1,3 +1,4 @@ export { disableActWarnings } from "./disableActWarnings.js"; export { spyOnConsole } from "./spyOnConsole.js"; export { withCleanup } from "./withCleanup.js"; +export { withProdMode } from "./withProdMode.js"; diff --git a/src/testing/internal/disposables/withProdMode.ts b/src/testing/internal/disposables/withProdMode.ts new file mode 100644 index 00000000000..ecdf3b408c2 --- /dev/null +++ b/src/testing/internal/disposables/withProdMode.ts @@ -0,0 +1,10 @@ +import { withCleanup } from "./withCleanup.js"; + +export function withProdMode() { + const prev = { prevDEV: __DEV__ }; + Object.defineProperty(globalThis, "__DEV__", { value: false }); + + return withCleanup(prev, ({ prevDEV }) => { + Object.defineProperty(globalThis, "__DEV__", { value: prevDEV }); + }); +} diff --git a/src/utilities/graphql/__tests__/directives.ts b/src/utilities/graphql/__tests__/directives.ts index 2e1d891754c..a46581c3b00 100644 --- a/src/utilities/graphql/__tests__/directives.ts +++ b/src/utilities/graphql/__tests__/directives.ts @@ -7,7 +7,11 @@ import { hasDirectives, hasAnyDirectives, hasAllDirectives, + getFragmentMaskMode, } from "../directives"; +import { spyOnConsole } from "../../../testing/internal"; +import { BREAK, visit } from "graphql"; +import type { DocumentNode, FragmentSpreadNode } from "graphql"; describe("hasDirectives", () => { it("should allow searching the ast for a directive", () => { @@ -512,3 +516,134 @@ describe("shouldInclude", () => { }).toThrow(); }); }); + +describe("getFragmentMaskMode", () => { + it("returns 'unmask' when @unmask used on fragment node", () => { + const fragmentNode = getFragmentSpreadNode(gql` + query { + ...MyFragment @unmask + } + `); + + const mode = getFragmentMaskMode(fragmentNode); + + expect(mode).toBe("unmask"); + }); + + it("returns 'mask' when no directives are present", () => { + const fragmentNode = getFragmentSpreadNode(gql` + query { + ...MyFragment + } + `); + + const mode = getFragmentMaskMode(fragmentNode); + + expect(mode).toBe("mask"); + }); + + it("returns 'mask' when a different directive is used", () => { + const fragmentNode = getFragmentSpreadNode(gql` + query { + ...MyFragment @myDirective + } + `); + + const mode = getFragmentMaskMode(fragmentNode); + + expect(mode).toBe("mask"); + }); + + it("returns 'unmask' when used with other directives", () => { + const fragmentNode = getFragmentSpreadNode(gql` + query { + ...MyFragment @myDirective @unmask + } + `); + + const mode = getFragmentMaskMode(fragmentNode); + + expect(mode).toBe("unmask"); + }); + + it("returns 'migrate' when passing mode: 'migrate' as argument", () => { + const fragmentNode = getFragmentSpreadNode(gql` + query { + ...MyFragment @unmask(mode: "migrate") + } + `); + + const mode = getFragmentMaskMode(fragmentNode); + + expect(mode).toBe("migrate"); + }); + + it("warns and returns 'unmask' when using variable for mode argument", () => { + using _ = spyOnConsole("warn"); + const fragmentNode = getFragmentSpreadNode(gql` + query ($mode: String!) { + ...MyFragment @unmask(mode: $mode) + } + `); + + const mode = getFragmentMaskMode(fragmentNode); + + expect(console.warn).toHaveBeenCalledTimes(1); + expect(console.warn).toHaveBeenCalledWith( + "@unmask 'mode' argument does not support variables." + ); + expect(mode).toBe("unmask"); + }); + + it("warns and returns 'unmask' when passing a non-string argument to mode", () => { + using _ = spyOnConsole("warn"); + const fragmentNode = getFragmentSpreadNode(gql` + query { + ...MyFragment @unmask(mode: true) + } + `); + + const mode = getFragmentMaskMode(fragmentNode); + + expect(console.warn).toHaveBeenCalledTimes(1); + expect(console.warn).toHaveBeenCalledWith( + "@unmask 'mode' argument must be of type string." + ); + expect(mode).toBe("unmask"); + }); + + it("warns and returns 'unmask' when passing a value other than 'migrate' to mode", () => { + using _ = spyOnConsole("warn"); + const fragmentNode = getFragmentSpreadNode(gql` + query { + ...MyFragment @unmask(mode: "invalid") + } + `); + + const mode = getFragmentMaskMode(fragmentNode); + + expect(console.warn).toHaveBeenCalledTimes(1); + expect(console.warn).toHaveBeenCalledWith( + "@unmask 'mode' argument does not recognize value '%s'.", + "invalid" + ); + expect(mode).toBe("unmask"); + }); +}); + +function getFragmentSpreadNode(document: DocumentNode): FragmentSpreadNode { + let fragmentSpreadNode: FragmentSpreadNode | undefined = undefined; + + visit(document, { + FragmentSpread: (node) => { + fragmentSpreadNode = node; + return BREAK; + }, + }); + + if (!fragmentSpreadNode) { + throw new Error("Must give a document with a fragment spread"); + } + + return fragmentSpreadNode; +} diff --git a/src/utilities/graphql/directives.ts b/src/utilities/graphql/directives.ts index 797823f00f1..40a55ade632 100644 --- a/src/utilities/graphql/directives.ts +++ b/src/utilities/graphql/directives.ts @@ -11,8 +11,9 @@ import type { ArgumentNode, ValueNode, ASTNode, + FragmentSpreadNode, } from "graphql"; -import { visit, BREAK } from "graphql"; +import { visit, BREAK, Kind } from "graphql"; export type DirectiveInfo = { [fieldName: string]: { [argName: string]: any }; @@ -133,3 +134,45 @@ export function getInclusionDirectives( return result; } + +/** @internal */ +export function getFragmentMaskMode( + fragment: FragmentSpreadNode +): "mask" | "migrate" | "unmask" { + const directive = fragment.directives?.find( + ({ name }) => name.value === "unmask" + ); + + if (!directive) { + return "mask"; + } + + const modeArg = directive.arguments?.find( + ({ name }) => name.value === "mode" + ); + + if (__DEV__) { + if (modeArg) { + if (modeArg.value.kind === Kind.VARIABLE) { + invariant.warn("@unmask 'mode' argument does not support variables."); + } else if (modeArg.value.kind !== Kind.STRING) { + invariant.warn("@unmask 'mode' argument must be of type string."); + } else if (modeArg.value.value !== "migrate") { + invariant.warn( + "@unmask 'mode' argument does not recognize value '%s'.", + modeArg.value.value + ); + } + } + } + + if ( + modeArg && + "value" in modeArg.value && + modeArg.value.value === "migrate" + ) { + return "migrate"; + } + + return "unmask"; +} diff --git a/src/utilities/index.ts b/src/utilities/index.ts index 01530ca7881..5523d2cba8c 100644 --- a/src/utilities/index.ts +++ b/src/utilities/index.ts @@ -12,6 +12,7 @@ export { hasClientExports, getDirectiveNames, getInclusionDirectives, + getFragmentMaskMode, } from "./graphql/directives.js"; export type { DocumentTransformCacheKey } from "./graphql/DocumentTransform.js";