From 1085a95e4430da35d19033613e73f315a0aede9e Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 13 Nov 2024 10:17:39 -0700 Subject: [PATCH] [Data masking] Warn when using data masking with `no-cache` operations (#12121) --- .api-reports/api-report-core.api.md | 8 +- .api-reports/api-report-react.api.md | 8 +- .../api-report-react_components.api.md | 12 +- .api-reports/api-report-react_context.api.md | 12 +- .api-reports/api-report-react_hoc.api.md | 12 +- .api-reports/api-report-react_hooks.api.md | 8 +- .api-reports/api-report-react_internal.api.md | 8 +- .api-reports/api-report-react_ssr.api.md | 12 +- .api-reports/api-report-testing.api.md | 12 +- .api-reports/api-report-testing_core.api.md | 12 +- .api-reports/api-report-utilities.api.md | 15 +- .api-reports/api-report.api.md | 8 +- .size-limits.json | 4 +- src/__tests__/__snapshots__/exports.ts.snap | 1 + src/__tests__/dataMasking.ts | 1651 ++++++++++++++--- src/core/ApolloClient.ts | 4 + src/core/ObservableQuery.ts | 2 + src/core/QueryManager.ts | 36 +- src/utilities/graphql/fragments.ts | 19 + src/utilities/index.ts | 1 + 20 files changed, 1527 insertions(+), 318 deletions(-) diff --git a/.api-reports/api-report-core.api.md b/.api-reports/api-report-core.api.md index 3a31996bbd3..e46102d87c7 100644 --- a/.api-reports/api-report-core.api.md +++ b/.api-reports/api-report-core.api.md @@ -1365,6 +1365,10 @@ interface MaskOperationOptions { data: TData; // (undocumented) document: DocumentNode; + // (undocumented) + fetchPolicy?: WatchQueryFetchPolicy; + // (undocumented) + id: string; } // @public (undocumented) @@ -2438,8 +2442,8 @@ interface WriteContext extends ReadMergeModifyContext { // src/cache/inmemory/types.ts:139:3 - (ae-forgotten-export) The symbol "KeyFieldsFunction" needs to be exported by the entry point index.d.ts // src/core/ObservableQuery.ts:120:5 - (ae-forgotten-export) The symbol "QueryManager" needs to be exported by the entry point index.d.ts // src/core/ObservableQuery.ts:121:5 - (ae-forgotten-export) The symbol "QueryInfo" needs to be exported by the entry point index.d.ts -// src/core/QueryManager.ts:155:5 - (ae-forgotten-export) The symbol "MutationStoreValue" needs to be exported by the entry point index.d.ts -// src/core/QueryManager.ts:408:7 - (ae-forgotten-export) The symbol "UpdateQueries" needs to be exported by the entry point index.d.ts +// src/core/QueryManager.ts:159:5 - (ae-forgotten-export) The symbol "MutationStoreValue" needs to be exported by the entry point index.d.ts +// src/core/QueryManager.ts:414:7 - (ae-forgotten-export) The symbol "UpdateQueries" needs to be exported by the entry point index.d.ts // src/core/watchQueryOptions.ts:277:2 - (ae-forgotten-export) The symbol "UpdateQueryFn" needs to be exported by the entry point index.d.ts // src/link/http/selectHttpOptionsAndBody.ts:128:32 - (ae-forgotten-export) The symbol "HttpQueryOptions" needs to be exported by the entry point index.d.ts diff --git a/.api-reports/api-report-react.api.md b/.api-reports/api-report-react.api.md index 818587e9537..2422d04f284 100644 --- a/.api-reports/api-report-react.api.md +++ b/.api-reports/api-report-react.api.md @@ -1110,6 +1110,10 @@ interface MaskOperationOptions { data: TData; // (undocumented) document: DocumentNode; + // (undocumented) + fetchPolicy?: WatchQueryFetchPolicy; + // (undocumented) + id: string; } // @public (undocumented) @@ -2449,8 +2453,8 @@ interface WatchQueryOptions { data: TData; // (undocumented) document: DocumentNode; + // Warning: (ae-forgotten-export) The symbol "WatchQueryFetchPolicy" needs to be exported by the entry point index.d.ts + // + // (undocumented) + fetchPolicy?: WatchQueryFetchPolicy; + // (undocumented) + id: string; } // @public (undocumented) @@ -1164,8 +1170,6 @@ enum NetworkStatus { // @public (undocumented) interface NextFetchPolicyContext { - // Warning: (ae-forgotten-export) The symbol "WatchQueryFetchPolicy" needs to be exported by the entry point index.d.ts - // // (undocumented) initialFetchPolicy: WatchQueryFetchPolicy; // (undocumented) @@ -1931,8 +1935,8 @@ interface WatchQueryOptions { data: TData; // (undocumented) document: DocumentNode; + // Warning: (ae-forgotten-export) The symbol "WatchQueryFetchPolicy" needs to be exported by the entry point index.d.ts + // + // (undocumented) + fetchPolicy?: WatchQueryFetchPolicy; + // (undocumented) + id: string; } // @public (undocumented) @@ -1118,8 +1124,6 @@ enum NetworkStatus { // @public (undocumented) interface NextFetchPolicyContext { - // Warning: (ae-forgotten-export) The symbol "WatchQueryFetchPolicy" needs to be exported by the entry point index.d.ts - // // (undocumented) initialFetchPolicy: WatchQueryFetchPolicy; // (undocumented) @@ -1851,8 +1855,8 @@ interface WatchQueryOptions { data: TData; // (undocumented) document: DocumentNode; + // Warning: (ae-forgotten-export) The symbol "WatchQueryFetchPolicy" needs to be exported by the entry point index.d.ts + // + // (undocumented) + fetchPolicy?: WatchQueryFetchPolicy; + // (undocumented) + id: string; } // @public (undocumented) @@ -1157,8 +1163,6 @@ enum NetworkStatus { // @public (undocumented) interface NextFetchPolicyContext { - // Warning: (ae-forgotten-export) The symbol "WatchQueryFetchPolicy" needs to be exported by the entry point index.d.ts - // // (undocumented) initialFetchPolicy: WatchQueryFetchPolicy; // (undocumented) @@ -1878,8 +1882,8 @@ export function withSubscription { data: TData; // (undocumented) document: DocumentNode; + // (undocumented) + fetchPolicy?: WatchQueryFetchPolicy; + // (undocumented) + id: string; } // @public (undocumented) @@ -2273,8 +2277,8 @@ interface WatchQueryOptions { data: TData; // (undocumented) document: DocumentNode; + // (undocumented) + fetchPolicy?: WatchQueryFetchPolicy; + // (undocumented) + id: string; } // @public (undocumented) @@ -2336,8 +2340,8 @@ export function wrapQueryRef(inter // src/core/LocalState.ts:46:5 - (ae-forgotten-export) The symbol "FragmentMap" needs to be exported by the entry point index.d.ts // src/core/ObservableQuery.ts:120:5 - (ae-forgotten-export) The symbol "QueryManager" needs to be exported by the entry point index.d.ts // src/core/ObservableQuery.ts:121:5 - (ae-forgotten-export) The symbol "QueryInfo" needs to be exported by the entry point index.d.ts -// src/core/QueryManager.ts:155:5 - (ae-forgotten-export) The symbol "MutationStoreValue" needs to be exported by the entry point index.d.ts -// src/core/QueryManager.ts:408:7 - (ae-forgotten-export) The symbol "UpdateQueries" needs to be exported by the entry point index.d.ts +// src/core/QueryManager.ts:159:5 - (ae-forgotten-export) The symbol "MutationStoreValue" needs to be exported by the entry point index.d.ts +// src/core/QueryManager.ts:414:7 - (ae-forgotten-export) The symbol "UpdateQueries" needs to be exported by the entry point index.d.ts // src/core/types.ts:175:3 - (ae-forgotten-export) The symbol "MutationQueryReducer" needs to be exported by the entry point index.d.ts // src/core/types.ts:204:5 - (ae-forgotten-export) The symbol "Resolver" needs to be exported by the entry point index.d.ts // src/core/watchQueryOptions.ts:277:2 - (ae-forgotten-export) The symbol "UpdateQueryFn" 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 95c4d46cd9f..c93a3387a0a 100644 --- a/.api-reports/api-report-react_ssr.api.md +++ b/.api-reports/api-report-react_ssr.api.md @@ -953,6 +953,12 @@ interface MaskOperationOptions { data: TData; // (undocumented) document: DocumentNode; + // Warning: (ae-forgotten-export) The symbol "WatchQueryFetchPolicy" needs to be exported by the entry point index.d.ts + // + // (undocumented) + fetchPolicy?: WatchQueryFetchPolicy; + // (undocumented) + id: string; } // @public (undocumented) @@ -1103,8 +1109,6 @@ enum NetworkStatus { // @public (undocumented) interface NextFetchPolicyContext { - // Warning: (ae-forgotten-export) The symbol "WatchQueryFetchPolicy" needs to be exported by the entry point index.d.ts - // // (undocumented) initialFetchPolicy: WatchQueryFetchPolicy; // (undocumented) @@ -1836,8 +1840,8 @@ interface WatchQueryOptions { data: TData; // (undocumented) document: DocumentNode; + // Warning: (ae-forgotten-export) The symbol "WatchQueryFetchPolicy" needs to be exported by the entry point index.d.ts + // + // (undocumented) + fetchPolicy?: WatchQueryFetchPolicy; + // (undocumented) + id: string; } // @public (undocumented) @@ -1224,8 +1230,6 @@ enum NetworkStatus { // @public (undocumented) interface NextFetchPolicyContext { - // Warning: (ae-forgotten-export) The symbol "WatchQueryFetchPolicy" needs to be exported by the entry point index.d.ts - // // (undocumented) initialFetchPolicy: WatchQueryFetchPolicy; // (undocumented) @@ -1904,8 +1908,8 @@ export function withWarningSpy(it: (...args: TArgs // src/core/LocalState.ts:46:5 - (ae-forgotten-export) The symbol "FragmentMap" needs to be exported by the entry point index.d.ts // src/core/ObservableQuery.ts:120:5 - (ae-forgotten-export) The symbol "QueryManager" needs to be exported by the entry point index.d.ts // src/core/ObservableQuery.ts:121:5 - (ae-forgotten-export) The symbol "QueryInfo" needs to be exported by the entry point index.d.ts -// src/core/QueryManager.ts:155:5 - (ae-forgotten-export) The symbol "MutationStoreValue" needs to be exported by the entry point index.d.ts -// src/core/QueryManager.ts:408:7 - (ae-forgotten-export) The symbol "UpdateQueries" needs to be exported by the entry point index.d.ts +// src/core/QueryManager.ts:159:5 - (ae-forgotten-export) The symbol "MutationStoreValue" needs to be exported by the entry point index.d.ts +// src/core/QueryManager.ts:414:7 - (ae-forgotten-export) The symbol "UpdateQueries" needs to be exported by the entry point index.d.ts // src/core/types.ts:175:3 - (ae-forgotten-export) The symbol "MutationQueryReducer" needs to be exported by the entry point index.d.ts // src/core/types.ts:204:5 - (ae-forgotten-export) The symbol "Resolver" needs to be exported by the entry point index.d.ts // src/core/watchQueryOptions.ts:277:2 - (ae-forgotten-export) The symbol "UpdateQueryFn" 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 621bb36846b..9695c5890d4 100644 --- a/.api-reports/api-report-testing_core.api.md +++ b/.api-reports/api-report-testing_core.api.md @@ -941,6 +941,12 @@ interface MaskOperationOptions { data: TData; // (undocumented) document: DocumentNode; + // Warning: (ae-forgotten-export) The symbol "WatchQueryFetchPolicy" needs to be exported by the entry point index.d.ts + // + // (undocumented) + fetchPolicy?: WatchQueryFetchPolicy; + // (undocumented) + id: string; } // @public (undocumented) @@ -1179,8 +1185,6 @@ enum NetworkStatus { // @public (undocumented) interface NextFetchPolicyContext { - // Warning: (ae-forgotten-export) The symbol "WatchQueryFetchPolicy" needs to be exported by the entry point index.d.ts - // // (undocumented) initialFetchPolicy: WatchQueryFetchPolicy; // (undocumented) @@ -1861,8 +1865,8 @@ export function withWarningSpy(it: (...args: TArgs // src/core/LocalState.ts:46:5 - (ae-forgotten-export) The symbol "FragmentMap" needs to be exported by the entry point index.d.ts // src/core/ObservableQuery.ts:120:5 - (ae-forgotten-export) The symbol "QueryManager" needs to be exported by the entry point index.d.ts // src/core/ObservableQuery.ts:121:5 - (ae-forgotten-export) The symbol "QueryInfo" needs to be exported by the entry point index.d.ts -// src/core/QueryManager.ts:155:5 - (ae-forgotten-export) The symbol "MutationStoreValue" needs to be exported by the entry point index.d.ts -// src/core/QueryManager.ts:408:7 - (ae-forgotten-export) The symbol "UpdateQueries" needs to be exported by the entry point index.d.ts +// src/core/QueryManager.ts:159:5 - (ae-forgotten-export) The symbol "MutationStoreValue" needs to be exported by the entry point index.d.ts +// src/core/QueryManager.ts:414:7 - (ae-forgotten-export) The symbol "UpdateQueries" needs to be exported by the entry point index.d.ts // src/core/types.ts:175:3 - (ae-forgotten-export) The symbol "MutationQueryReducer" needs to be exported by the entry point index.d.ts // src/core/types.ts:204:5 - (ae-forgotten-export) The symbol "Resolver" needs to be exported by the entry point index.d.ts // src/core/watchQueryOptions.ts:277:2 - (ae-forgotten-export) The symbol "UpdateQueryFn" 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 32893220155..dd76c0acc5d 100644 --- a/.api-reports/api-report-utilities.api.md +++ b/.api-reports/api-report-utilities.api.md @@ -1480,6 +1480,9 @@ export function isExecutionPatchResult(value: FetchResult): value is Execu // @public (undocumented) export function isField(selection: SelectionNode): selection is FieldNode; +// @public (undocumented) +export function isFullyUnmaskedOperation(document: DocumentNode): boolean; + // @public (undocumented) export function isInlineFragment(selection: SelectionNode): selection is InlineFragmentNode; @@ -1642,6 +1645,12 @@ interface MaskOperationOptions { data: TData; // (undocumented) document: DocumentNode; + // Warning: (ae-forgotten-export) The symbol "WatchQueryFetchPolicy" needs to be exported by the entry point index.d.ts + // + // (undocumented) + fetchPolicy?: WatchQueryFetchPolicy; + // (undocumented) + id: string; } // @public (undocumented) @@ -1833,8 +1842,6 @@ enum NetworkStatus { // @public (undocumented) interface NextFetchPolicyContext { - // Warning: (ae-forgotten-export) The symbol "WatchQueryFetchPolicy" needs to be exported by the entry point index.d.ts - // // (undocumented) initialFetchPolicy: WatchQueryFetchPolicy; // (undocumented) @@ -2802,8 +2809,8 @@ interface WriteContext extends ReadMergeModifyContext { // src/core/LocalState.ts:71:3 - (ae-forgotten-export) The symbol "ApolloClient" needs to be exported by the entry point index.d.ts // src/core/ObservableQuery.ts:120:5 - (ae-forgotten-export) The symbol "QueryManager" needs to be exported by the entry point index.d.ts // src/core/ObservableQuery.ts:121:5 - (ae-forgotten-export) The symbol "QueryInfo" needs to be exported by the entry point index.d.ts -// src/core/QueryManager.ts:155:5 - (ae-forgotten-export) The symbol "MutationStoreValue" needs to be exported by the entry point index.d.ts -// src/core/QueryManager.ts:408:7 - (ae-forgotten-export) The symbol "UpdateQueries" needs to be exported by the entry point index.d.ts +// src/core/QueryManager.ts:159:5 - (ae-forgotten-export) The symbol "MutationStoreValue" needs to be exported by the entry point index.d.ts +// src/core/QueryManager.ts:414:7 - (ae-forgotten-export) The symbol "UpdateQueries" needs to be exported by the entry point index.d.ts // src/core/types.ts:175:3 - (ae-forgotten-export) The symbol "MutationQueryReducer" needs to be exported by the entry point index.d.ts // src/core/types.ts:204:5 - (ae-forgotten-export) The symbol "Resolver" needs to be exported by the entry point index.d.ts // src/core/watchQueryOptions.ts:277:2 - (ae-forgotten-export) The symbol "UpdateQueryFn" needs to be exported by the entry point index.d.ts diff --git a/.api-reports/api-report.api.md b/.api-reports/api-report.api.md index 0a2c8f55e31..21a585568a5 100644 --- a/.api-reports/api-report.api.md +++ b/.api-reports/api-report.api.md @@ -1546,6 +1546,10 @@ interface MaskOperationOptions { data: TData; // (undocumented) document: DocumentNode; + // (undocumented) + fetchPolicy?: WatchQueryFetchPolicy; + // (undocumented) + id: string; } // @public (undocumented) @@ -3149,8 +3153,8 @@ interface WriteContext extends ReadMergeModifyContext { // src/cache/inmemory/types.ts:139:3 - (ae-forgotten-export) The symbol "KeyFieldsFunction" needs to be exported by the entry point index.d.ts // src/core/ObservableQuery.ts:120:5 - (ae-forgotten-export) The symbol "QueryManager" needs to be exported by the entry point index.d.ts // src/core/ObservableQuery.ts:121:5 - (ae-forgotten-export) The symbol "QueryInfo" needs to be exported by the entry point index.d.ts -// src/core/QueryManager.ts:155:5 - (ae-forgotten-export) The symbol "MutationStoreValue" needs to be exported by the entry point index.d.ts -// src/core/QueryManager.ts:408:7 - (ae-forgotten-export) The symbol "UpdateQueries" needs to be exported by the entry point index.d.ts +// src/core/QueryManager.ts:159:5 - (ae-forgotten-export) The symbol "MutationStoreValue" needs to be exported by the entry point index.d.ts +// src/core/QueryManager.ts:414:7 - (ae-forgotten-export) The symbol "UpdateQueries" needs to be exported by the entry point index.d.ts // src/core/watchQueryOptions.ts:277:2 - (ae-forgotten-export) The symbol "UpdateQueryFn" needs to be exported by the entry point index.d.ts // src/link/http/selectHttpOptionsAndBody.ts:128:32 - (ae-forgotten-export) The symbol "HttpQueryOptions" needs to be exported by the entry point index.d.ts // src/react/hooks/useBackgroundQuery.ts:38:3 - (ae-forgotten-export) The symbol "SubscribeToMoreFunction" needs to be exported by the entry point index.d.ts diff --git a/.size-limits.json b/.size-limits.json index c88de213411..5a2f2b7fb59 100644 --- a/.size-limits.json +++ b/.size-limits.json @@ -1,4 +1,4 @@ { - "dist/apollo-client.min.cjs": 41520, - "import { ApolloClient, InMemoryCache, HttpLink } from \"dist/index.js\" (production)": 34303 + "dist/apollo-client.min.cjs": 41580, + "import { ApolloClient, InMemoryCache, HttpLink } from \"dist/index.js\" (production)": 34368 } diff --git a/src/__tests__/__snapshots__/exports.ts.snap b/src/__tests__/__snapshots__/exports.ts.snap index 92acb14a1da..7527cb36224 100644 --- a/src/__tests__/__snapshots__/exports.ts.snap +++ b/src/__tests__/__snapshots__/exports.ts.snap @@ -465,6 +465,7 @@ Array [ "isExecutionPatchInitialResult", "isExecutionPatchResult", "isField", + "isFullyUnmaskedOperation", "isInlineFragment", "isMutationOperation", "isNonEmptyArray", diff --git a/src/__tests__/dataMasking.ts b/src/__tests__/dataMasking.ts index dfe2988c0f3..90ecaa89fbc 100644 --- a/src/__tests__/dataMasking.ts +++ b/src/__tests__/dataMasking.ts @@ -26,6 +26,9 @@ import { createFragmentRegistry } from "../cache/inmemory/fragmentRegistry"; import { isSubscriptionOperation } from "../utilities"; import { MaskedDocumentNode } from "../masking"; +const NO_CACHE_WARNING = + '[%s]: Fragments masked by data masking are inaccessible when using fetch policy "no-cache". Please add `@unmask` to each fragment spread to access the data.'; + describe("client.watchQuery", () => { test("masks queries when dataMasking is `true`", async () => { type UserFieldsFragment = { @@ -2317,221 +2320,504 @@ describe("client.watchQuery", () => { }, }); }); -}); -describe("client.watchFragment", () => { - test("masks watched fragments when dataMasking is `true`", async () => { + test("warns and returns masked result when used with no-cache fetch policy", async () => { + using _ = spyOnConsole("warn"); type UserFieldsFragment = { - __typename: "User"; - id: number; age: number; - } & { " $fragmentName"?: "UserFieldsFragment" } & { - " $fragmentRefs"?: { NameFieldsFragment: NameFieldsFragment }; - }; + } & { " $fragmentName"?: "UserFieldsFragment" }; - type NameFieldsFragment = { - __typename: "User"; - firstName: string; - lastName: string; - } & { " $fragmentName"?: "NameFieldsFragment" }; + interface Query { + currentUser: { + __typename: "User"; + id: number; + name: string; + } & { " $fragmentRefs"?: { UserFieldsFragment: UserFieldsFragment } }; + } - const nameFieldsFragment: MaskedDocumentNode = gql` - fragment NameFields on User { - firstName - lastName + const query: MaskedDocumentNode = gql` + query MaskedQuery { + currentUser { + id + name + ...UserFields + } } - `; - const userFieldsFragment: MaskedDocumentNode = gql` fragment UserFields on User { - id age - ...NameFields } - - ${nameFieldsFragment} `; + 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), }); - client.writeFragment({ - fragment: userFieldsFragment, - fragmentName: "UserFields", - data: { - __typename: "User", - id: 1, - age: 30, - firstName: "Test", - lastName: "User", - }, - }); - - const fragmentStream = new ObservableStream( - client.watchFragment({ - fragment: userFieldsFragment, - fragmentName: "UserFields", - from: { __typename: "User", id: 1 }, - }) - ); - - const { data, complete } = await fragmentStream.takeNext(); - - expect(data).toEqual({ __typename: "User", id: 1, age: 30 }); - expect(complete).toBe(true); - invariant(complete, "Should never be incomplete"); - - const nestedFragmentStream = new ObservableStream( - client.watchFragment({ fragment: nameFieldsFragment, from: data }) - ); + const observable = client.watchQuery({ query, fetchPolicy: "no-cache" }); + const stream = new ObservableStream(observable); { - const { data, complete } = await nestedFragmentStream.takeNext(); + const { data } = await stream.takeNext(); - expect(complete).toBe(true); expect(data).toEqual({ - __typename: "User", - firstName: "Test", - lastName: "User", + currentUser: { + __typename: "User", + id: 1, + name: "Test User", + }, }); } + + expect(console.warn).toHaveBeenCalledTimes(1); + expect(console.warn).toHaveBeenCalledWith(NO_CACHE_WARNING, "MaskedQuery"); }); - test("does not mask watched fragments when dataMasking is disabled", async () => { + test("does not warn on no-cache queries when data masking is disabled", async () => { + using _ = spyOnConsole("warn"); type UserFieldsFragment = { - __typename: "User"; - id: number; age: number; - } & { " $fragmentName"?: "UserFieldsFragment" } & { - " $fragmentRefs"?: { NameFieldsFragment: NameFieldsFragment }; - }; + } & { " $fragmentName"?: "UserFieldsFragment" }; - type NameFieldsFragment = { - __typename: "User"; - firstName: string; - lastName: string; - } & { " $fragmentName"?: "NameFieldsFragment" }; + interface Query { + currentUser: { + __typename: "User"; + id: number; + name: string; + } & { " $fragmentRefs"?: { UserFieldsFragment: UserFieldsFragment } }; + } - const nameFieldsFragment: TypedDocumentNode = gql` - fragment NameFields on User { - __typename - firstName - lastName + const query: MaskedDocumentNode = gql` + query MaskedQuery { + currentUser { + id + name + ...UserFields + } } - `; - const userFieldsFragment: TypedDocumentNode = gql` fragment UserFields on User { - __typename - id age - ...NameFields } - - ${nameFieldsFragment} `; + const mocks = [ + { + request: { query }, + result: { + data: { + currentUser: { + __typename: "User", + id: 1, + name: "Test User", + age: 30, + }, + }, + }, + }, + ]; + const client = new ApolloClient({ dataMasking: false, cache: new InMemoryCache(), + link: new MockLink(mocks), }); - client.writeFragment({ - fragment: userFieldsFragment, - fragmentName: "UserFields", - data: { - __typename: "User", - id: 1, - age: 30, - firstName: "Test", - lastName: "User", - }, - }); - - const fragmentStream = new ObservableStream( - client.watchFragment({ - fragment: userFieldsFragment, - fragmentName: "UserFields", - from: { __typename: "User", id: 1 }, - }) - ); - - const { data, complete } = await fragmentStream.takeNext(); - - expect(data).toEqual({ - __typename: "User", - id: 1, - age: 30, - firstName: "Test", - lastName: "User", - }); - expect(complete).toBe(true); - invariant(complete, "Should never be incomplete"); - - const nestedFragmentStream = new ObservableStream( - client.watchFragment({ fragment: nameFieldsFragment, from: data }) - ); + const observable = client.watchQuery({ query, fetchPolicy: "no-cache" }); + const stream = new ObservableStream(observable); { - const { data, complete } = await nestedFragmentStream.takeNext(); + const { data } = await stream.takeNext(); - expect(complete).toBe(true); expect(data).toEqual({ - __typename: "User", - firstName: "Test", - lastName: "User", + currentUser: { + __typename: "User", + id: 1, + name: "Test User", + age: 30, + }, }); } + + expect(console.warn).not.toHaveBeenCalled(); }); - test("does not mask watched fragments by default", async () => { + test("does not warn on no-cache queries when all fragments use `@unmask`", async () => { + using _ = spyOnConsole("warn"); type UserFieldsFragment = { - __typename: "User"; - id: number; age: number; - } & { " $fragmentName"?: "UserFieldsFragment" } & { - " $fragmentRefs"?: { NameFieldsFragment: NameFieldsFragment }; - }; + } & { " $fragmentName"?: "UserFieldsFragment" }; - type NameFieldsFragment = { - __typename: "User"; - firstName: string; - lastName: string; - } & { " $fragmentName"?: "NameFieldsFragment" }; + interface Query { + currentUser: { + __typename: "User"; + id: number; + name: string; + } & { " $fragmentRefs"?: { UserFieldsFragment: UserFieldsFragment } }; + } - const nameFieldsFragment: TypedDocumentNode = gql` - fragment NameFields on User { - __typename - firstName - lastName + const query: MaskedDocumentNode = gql` + query MaskedQuery { + currentUser { + id + name + ...UserFields @unmask + } } - `; - const userFieldsFragment: TypedDocumentNode = gql` fragment UserFields on User { - __typename - id age - ...NameFields } - - ${nameFieldsFragment} `; - const client = new ApolloClient({ - cache: new InMemoryCache(), - }); - - client.writeFragment({ - fragment: userFieldsFragment, - fragmentName: "UserFields", - data: { - __typename: "User", - id: 1, - age: 30, - firstName: "Test", + 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, fetchPolicy: "no-cache" }); + const stream = new ObservableStream(observable); + + { + const { data } = await stream.takeNext(); + + expect(data).toEqual({ + currentUser: { + __typename: "User", + id: 1, + name: "Test User", + age: 30, + }, + }); + } + + expect(console.warn).not.toHaveBeenCalled(); + }); + + test("warns on no-cache queries when at least one fragment does not use `@unmask`", async () => { + using _ = spyOnConsole("warn"); + type UserFieldsFragment = { + age: number; + } & { " $fragmentName"?: "UserFieldsFragment" }; + + interface Query { + currentUser: { + __typename: "User"; + id: number; + name: string; + } & { " $fragmentRefs"?: { UserFieldsFragment: UserFieldsFragment } }; + } + + const query: MaskedDocumentNode = gql` + query MaskedQuery { + currentUser { + id + name + ...UserFields @unmask + } + } + + fragment UserFields on User { + age + ...ProfileFields + } + + fragment ProfileFields on User { + username + } + `; + + const mocks = [ + { + request: { query }, + result: { + data: { + currentUser: { + __typename: "User", + id: 1, + name: "Test User", + age: 30, + username: "testuser", + }, + }, + }, + }, + ]; + + const client = new ApolloClient({ + dataMasking: true, + cache: new InMemoryCache(), + link: new MockLink(mocks), + }); + + const observable = client.watchQuery({ query, fetchPolicy: "no-cache" }); + const stream = new ObservableStream(observable); + + { + const { data } = await stream.takeNext(); + + expect(data).toEqual({ + currentUser: { + __typename: "User", + id: 1, + name: "Test User", + age: 30, + }, + }); + } + + expect(console.warn).toHaveBeenCalledTimes(1); + expect(console.warn).toHaveBeenCalledWith(NO_CACHE_WARNING, "MaskedQuery"); + }); +}); + +describe("client.watchFragment", () => { + test("masks watched fragments when dataMasking is `true`", async () => { + type UserFieldsFragment = { + __typename: "User"; + id: number; + age: number; + } & { " $fragmentName"?: "UserFieldsFragment" } & { + " $fragmentRefs"?: { NameFieldsFragment: NameFieldsFragment }; + }; + + type NameFieldsFragment = { + __typename: "User"; + firstName: string; + lastName: string; + } & { " $fragmentName"?: "NameFieldsFragment" }; + + const nameFieldsFragment: MaskedDocumentNode = gql` + fragment NameFields on User { + firstName + lastName + } + `; + + const userFieldsFragment: MaskedDocumentNode = gql` + fragment UserFields on User { + id + age + ...NameFields + } + + ${nameFieldsFragment} + `; + + const client = new ApolloClient({ + dataMasking: true, + cache: new InMemoryCache(), + }); + + client.writeFragment({ + fragment: userFieldsFragment, + fragmentName: "UserFields", + data: { + __typename: "User", + id: 1, + age: 30, + firstName: "Test", + lastName: "User", + }, + }); + + const fragmentStream = new ObservableStream( + client.watchFragment({ + fragment: userFieldsFragment, + fragmentName: "UserFields", + from: { __typename: "User", id: 1 }, + }) + ); + + const { data, complete } = await fragmentStream.takeNext(); + + expect(data).toEqual({ __typename: "User", id: 1, age: 30 }); + expect(complete).toBe(true); + invariant(complete, "Should never be incomplete"); + + const nestedFragmentStream = new ObservableStream( + client.watchFragment({ fragment: nameFieldsFragment, from: data }) + ); + + { + const { data, complete } = await nestedFragmentStream.takeNext(); + + expect(complete).toBe(true); + expect(data).toEqual({ + __typename: "User", + firstName: "Test", + lastName: "User", + }); + } + }); + + test("does not mask watched fragments when dataMasking is disabled", async () => { + type UserFieldsFragment = { + __typename: "User"; + id: number; + age: number; + } & { " $fragmentName"?: "UserFieldsFragment" } & { + " $fragmentRefs"?: { NameFieldsFragment: NameFieldsFragment }; + }; + + type NameFieldsFragment = { + __typename: "User"; + firstName: string; + lastName: string; + } & { " $fragmentName"?: "NameFieldsFragment" }; + + const nameFieldsFragment: TypedDocumentNode = gql` + fragment NameFields on User { + __typename + firstName + lastName + } + `; + + const userFieldsFragment: TypedDocumentNode = gql` + fragment UserFields on User { + __typename + id + age + ...NameFields + } + + ${nameFieldsFragment} + `; + + const client = new ApolloClient({ + dataMasking: false, + cache: new InMemoryCache(), + }); + + client.writeFragment({ + fragment: userFieldsFragment, + fragmentName: "UserFields", + data: { + __typename: "User", + id: 1, + age: 30, + firstName: "Test", + lastName: "User", + }, + }); + + const fragmentStream = new ObservableStream( + client.watchFragment({ + fragment: userFieldsFragment, + fragmentName: "UserFields", + from: { __typename: "User", id: 1 }, + }) + ); + + const { data, complete } = await fragmentStream.takeNext(); + + expect(data).toEqual({ + __typename: "User", + id: 1, + age: 30, + firstName: "Test", + lastName: "User", + }); + expect(complete).toBe(true); + invariant(complete, "Should never be incomplete"); + + const nestedFragmentStream = new ObservableStream( + client.watchFragment({ fragment: nameFieldsFragment, from: data }) + ); + + { + const { data, complete } = await nestedFragmentStream.takeNext(); + + expect(complete).toBe(true); + expect(data).toEqual({ + __typename: "User", + firstName: "Test", + lastName: "User", + }); + } + }); + + test("does not mask watched fragments by default", async () => { + type UserFieldsFragment = { + __typename: "User"; + id: number; + age: number; + } & { " $fragmentName"?: "UserFieldsFragment" } & { + " $fragmentRefs"?: { NameFieldsFragment: NameFieldsFragment }; + }; + + type NameFieldsFragment = { + __typename: "User"; + firstName: string; + lastName: string; + } & { " $fragmentName"?: "NameFieldsFragment" }; + + const nameFieldsFragment: TypedDocumentNode = gql` + fragment NameFields on User { + __typename + firstName + lastName + } + `; + + const userFieldsFragment: TypedDocumentNode = gql` + fragment UserFields on User { + __typename + id + age + ...NameFields + } + + ${nameFieldsFragment} + `; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + }); + + client.writeFragment({ + fragment: userFieldsFragment, + fragmentName: "UserFields", + data: { + __typename: "User", + id: 1, + age: 30, + firstName: "Test", lastName: "User", }, }); @@ -3564,27 +3850,288 @@ describe("client.query", () => { link: new MockLink(mocks), }); - const { data, errors } = await client.query({ query, errorPolicy: "all" }); + const { data, errors } = await client.query({ query, errorPolicy: "all" }); + + expect(data).toEqual({ + currentUser: null, + }); + + expect(errors).toEqual([{ message: "User not logged in" }]); + }); + + test("masks fragment data in fields nulled by errors when using errorPolicy `all`", async () => { + const query = gql` + query MaskedQuery { + currentUser { + id + name + ...UserFields + } + } + + fragment UserFields on User { + age + } + `; + + const mocks = [ + { + request: { query }, + result: { + data: { + currentUser: { + __typename: "User", + id: 1, + name: "Test User", + age: null, + }, + }, + errors: [{ message: "Could not determine age" }], + }, + }, + ]; + + const client = new ApolloClient({ + dataMasking: true, + cache: new InMemoryCache(), + link: new MockLink(mocks), + }); + + const { data, errors } = await client.query({ query, errorPolicy: "all" }); + + expect(data).toEqual({ + currentUser: { + __typename: "User", + id: 1, + name: "Test User", + }, + }); + + expect(errors).toEqual([{ message: "Could not determine age" }]); + }); + + test("warns and returns masked result when used with no-cache fetch policy", async () => { + using _ = spyOnConsole("warn"); + type UserFieldsFragment = { + age: number; + } & { " $fragmentName"?: "UserFieldsFragment" }; + + interface Query { + currentUser: { + __typename: "User"; + id: number; + name: string; + } & { " $fragmentRefs"?: { UserFieldsFragment: UserFieldsFragment } }; + } + + const query: MaskedDocumentNode = gql` + query MaskedQuery { + 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), + }); + + const { data } = await client.query({ query, fetchPolicy: "no-cache" }); + + expect(data).toEqual({ + currentUser: { + __typename: "User", + id: 1, + name: "Test User", + }, + }); + + expect(console.warn).toHaveBeenCalledTimes(1); + expect(console.warn).toHaveBeenCalledWith(NO_CACHE_WARNING, "MaskedQuery"); + }); + + test("does not warn on no-cache queries when data masking is disabled", async () => { + using _ = spyOnConsole("warn"); + type UserFieldsFragment = { + age: number; + } & { " $fragmentName"?: "UserFieldsFragment" }; + + interface Query { + currentUser: { + __typename: "User"; + id: number; + name: string; + } & { " $fragmentRefs"?: { UserFieldsFragment: UserFieldsFragment } }; + } + + const query: MaskedDocumentNode = gql` + query MaskedQuery { + 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: false, + cache: new InMemoryCache(), + link: new MockLink(mocks), + }); + + const { data } = await client.query({ query, fetchPolicy: "no-cache" }); + + expect(data).toEqual({ + currentUser: { + __typename: "User", + id: 1, + name: "Test User", + age: 30, + }, + }); + + expect(console.warn).not.toHaveBeenCalled(); + }); + + test("does not warn on no-cache queries when all fragments use `@unmask`", async () => { + using _ = spyOnConsole("warn"); + type UserFieldsFragment = { + age: number; + } & { " $fragmentName"?: "UserFieldsFragment" }; + + interface Query { + currentUser: { + __typename: "User"; + id: number; + name: string; + } & { " $fragmentRefs"?: { UserFieldsFragment: UserFieldsFragment } }; + } + + const query: MaskedDocumentNode = gql` + query MaskedQuery { + 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 { data } = await client.query({ query, fetchPolicy: "no-cache" }); expect(data).toEqual({ - currentUser: null, + currentUser: { + __typename: "User", + id: 1, + name: "Test User", + age: 30, + }, }); - expect(errors).toEqual([{ message: "User not logged in" }]); + expect(console.warn).not.toHaveBeenCalled(); }); - test("masks fragment data in fields nulled by errors when using errorPolicy `all`", async () => { - const query = gql` + test("warns on no-cache queries when at least one fragment does not use `@unmask`", async () => { + using _ = spyOnConsole("warn"); + type UserFieldsFragment = { + age: number; + } & { " $fragmentName"?: "UserFieldsFragment" }; + + interface Query { + currentUser: { + __typename: "User"; + id: number; + name: string; + } & { " $fragmentRefs"?: { UserFieldsFragment: UserFieldsFragment } }; + } + + const query: MaskedDocumentNode = gql` query MaskedQuery { currentUser { id name - ...UserFields + ...UserFields @unmask } } fragment UserFields on User { age + ...ProfileFields + } + + fragment ProfileFields on User { + username } `; @@ -3597,10 +4144,10 @@ describe("client.query", () => { __typename: "User", id: 1, name: "Test User", - age: null, + age: 30, + username: "testuser", }, }, - errors: [{ message: "Could not determine age" }], }, }, ]; @@ -3611,17 +4158,19 @@ describe("client.query", () => { link: new MockLink(mocks), }); - const { data, errors } = await client.query({ query, errorPolicy: "all" }); + const { data } = await client.query({ query, fetchPolicy: "no-cache" }); expect(data).toEqual({ currentUser: { __typename: "User", id: 1, name: "Test User", + age: 30, }, }); - expect(errors).toEqual([{ message: "Could not determine age" }]); + expect(console.warn).toHaveBeenCalledTimes(1); + expect(console.warn).toHaveBeenCalledWith(NO_CACHE_WARNING, "MaskedQuery"); }); }); @@ -3810,23 +4359,259 @@ describe("client.subscribe", () => { data: { addedComment: null, }, - errors: [{ message: "Something went wrong" }], + errors: [{ message: "Something went wrong" }], + }, + }); + + const error = await stream.takeError(); + + expect(error).toEqual( + new ApolloError({ graphQLErrors: [{ message: "Something went wrong" }] }) + ); + }); + + test("handles errors returned from the subscription when errorPolicy is `all`", async () => { + const subscription = gql` + subscription NewCommentSubscription { + addedComment { + id + ...CommentFields + } + } + + fragment CommentFields on Comment { + comment + author + } + `; + + const link = new MockSubscriptionLink(); + + const client = new ApolloClient({ + dataMasking: true, + cache: new InMemoryCache(), + link, + }); + + const observable = client.subscribe({ + query: subscription, + errorPolicy: "all", + }); + const stream = new ObservableStream(observable); + + link.simulateResult({ + result: { + data: { + addedComment: null, + }, + errors: [{ message: "Something went wrong" }], + }, + }); + + const { data, errors } = await stream.takeNext(); + + expect(data).toEqual({ addedComment: null }); + expect(errors).toEqual([{ message: "Something went wrong" }]); + }); + + test("masks partial data for errors returned from the subscription when errorPolicy is `all`", async () => { + const subscription = gql` + subscription NewCommentSubscription { + addedComment { + id + ...CommentFields + } + } + + fragment CommentFields on Comment { + comment + author + } + `; + + const link = new MockSubscriptionLink(); + + const client = new ApolloClient({ + dataMasking: true, + cache: new InMemoryCache(), + link, + }); + + const observable = client.subscribe({ + query: subscription, + errorPolicy: "all", + }); + const stream = new ObservableStream(observable); + + link.simulateResult({ + result: { + data: { + addedComment: { + __typename: "Comment", + id: 1, + comment: "Test comment", + author: null, + }, + }, + errors: [{ message: "Could not get author" }], + }, + }); + + const { data, errors } = await stream.takeNext(); + + expect(data).toEqual({ addedComment: { __typename: "Comment", id: 1 } }); + expect(errors).toEqual([{ message: "Could not get author" }]); + }); + + test("warns and returns masked result when used with no-cache fetch policy", async () => { + using _ = spyOnConsole("warn"); + const subscription = gql` + subscription NewCommentSubscription { + addedComment { + id + ...CommentFields + } + } + + fragment CommentFields on Comment { + comment + author + } + `; + + const link = new MockSubscriptionLink(); + + const client = new ApolloClient({ + dataMasking: true, + cache: new InMemoryCache(), + link, + }); + + const observable = client.subscribe({ + query: subscription, + fetchPolicy: "no-cache", + }); + const stream = new ObservableStream(observable); + + link.simulateResult({ + result: { + data: { + addedComment: { + __typename: "Comment", + id: 1, + comment: "Test comment", + author: "Test User", + }, + }, + }, + }); + + { + const { data } = await stream.takeNext(); + + expect(data).toEqual({ + addedComment: { + __typename: "Comment", + id: 1, + }, + }); + } + + link.simulateResult({ + result: { + data: { + addedComment: { + __typename: "Comment", + id: 2, + comment: "Test comment 2", + author: "Test User", + }, + }, + }, + }); + + { + const { data } = await stream.takeNext(); + + expect(data).toEqual({ + addedComment: { + __typename: "Comment", + id: 2, + }, + }); + } + + expect(console.warn).toHaveBeenCalledTimes(1); + expect(console.warn).toHaveBeenCalledWith( + NO_CACHE_WARNING, + "NewCommentSubscription" + ); + }); + + test("does not warn on no-cache queries when data masking is disabled", async () => { + using _ = spyOnConsole("warn"); + const subscription = gql` + subscription NewCommentSubscription { + addedComment { + id + ...CommentFields + } + } + + fragment CommentFields on Comment { + comment + author + } + `; + + const link = new MockSubscriptionLink(); + + const client = new ApolloClient({ + dataMasking: false, + cache: new InMemoryCache(), + link, + }); + + const observable = client.subscribe({ + query: subscription, + fetchPolicy: "no-cache", + }); + const stream = new ObservableStream(observable); + + link.simulateResult({ + result: { + data: { + addedComment: { + __typename: "Comment", + id: 1, + comment: "Test comment", + author: "Test User", + }, + }, }, }); - const error = await stream.takeError(); + const { data } = await stream.takeNext(); - expect(error).toEqual( - new ApolloError({ graphQLErrors: [{ message: "Something went wrong" }] }) - ); + expect(data).toEqual({ + addedComment: { + __typename: "Comment", + id: 1, + comment: "Test comment", + author: "Test User", + }, + }); + + expect(console.warn).not.toHaveBeenCalled(); }); - test("handles errors returned from the subscription when errorPolicy is `all`", async () => { + test("does not warn on no-cache queries when all fragments use `@unmask`", async () => { + using _ = spyOnConsole("warn"); const subscription = gql` subscription NewCommentSubscription { addedComment { id - ...CommentFields + ...CommentFields @unmask } } @@ -3846,37 +4631,56 @@ describe("client.subscribe", () => { const observable = client.subscribe({ query: subscription, - errorPolicy: "all", + fetchPolicy: "no-cache", }); const stream = new ObservableStream(observable); link.simulateResult({ result: { data: { - addedComment: null, + addedComment: { + __typename: "Comment", + id: 1, + comment: "Test comment", + author: "Test User", + }, }, - errors: [{ message: "Something went wrong" }], }, }); - const { data, errors } = await stream.takeNext(); + const { data } = await stream.takeNext(); - expect(data).toEqual({ addedComment: null }); - expect(errors).toEqual([{ message: "Something went wrong" }]); + expect(data).toEqual({ + addedComment: { + __typename: "Comment", + id: 1, + comment: "Test comment", + author: "Test User", + }, + }); + + expect(console.warn).not.toHaveBeenCalled(); }); - test("masks partial data for errors returned from the subscription when errorPolicy is `all`", async () => { + test("warns on no-cache queries when at least one fragment does not use `@unmask`", async () => { + using _ = spyOnConsole("warn"); const subscription = gql` subscription NewCommentSubscription { addedComment { id - ...CommentFields + ...CommentFields @unmask } } fragment CommentFields on Comment { comment - author + author { + ...AuthorFields + } + } + + fragment AuthorFields on User { + name } `; @@ -3890,7 +4694,7 @@ describe("client.subscribe", () => { const observable = client.subscribe({ query: subscription, - errorPolicy: "all", + fetchPolicy: "no-cache", }); const stream = new ObservableStream(observable); @@ -3901,17 +4705,28 @@ describe("client.subscribe", () => { __typename: "Comment", id: 1, comment: "Test comment", - author: null, + author: { __typename: "User", name: "Test User" }, }, }, - errors: [{ message: "Could not get author" }], }, }); - const { data, errors } = await stream.takeNext(); + const { data } = await stream.takeNext(); - expect(data).toEqual({ addedComment: { __typename: "Comment", id: 1 } }); - expect(errors).toEqual([{ message: "Could not get author" }]); + expect(data).toEqual({ + addedComment: { + __typename: "Comment", + id: 1, + comment: "Test comment", + author: { __typename: "User" }, + }, + }); + + expect(console.warn).toHaveBeenCalledTimes(1); + expect(console.warn).toHaveBeenCalledWith( + NO_CACHE_WARNING, + "NewCommentSubscription" + ); }); }); @@ -4298,10 +5113,197 @@ describe("observableQuery.subscribeToMore", () => { }); } }); -}); +}); + +describe("client.mutate", () => { + test("masks data returned from client.mutate when dataMasking is `true`", async () => { + type UserFieldsFragment = { + age: number; + } & { " $fragmentName"?: "UserFieldsFragment" }; + + interface Mutation { + updateUser: { + __typename: "User"; + id: number; + name: string; + } & { + " $fragmentRefs"?: { UserFieldsFragment: UserFieldsFragment }; + }; + } + + const mutation: MaskedDocumentNode = gql` + mutation MaskedMutation { + updateUser { + id + name + ...UserFields + } + } + + fragment UserFields on User { + age + } + `; + + const mocks = [ + { + request: { query: mutation }, + result: { + data: { + updateUser: { + __typename: "User", + id: 1, + name: "Test User", + age: 30, + }, + }, + }, + }, + ]; + + const client = new ApolloClient({ + dataMasking: true, + cache: new InMemoryCache(), + link: new MockLink(mocks), + }); + + const { data } = await client.mutate({ mutation }); + + expect(data).toEqual({ + updateUser: { + __typename: "User", + id: 1, + name: "Test User", + }, + }); + }); + + test("does not mask data returned from client.mutate when dataMasking is `false`", async () => { + type UserFieldsFragment = { + age: number; + } & { " $fragmentName"?: "UserFieldsFragment" }; + + interface Mutation { + updateUser: { + __typename: "User"; + id: number; + name: string; + } & { + " $fragmentRefs"?: { UserFieldsFragment: UserFieldsFragment }; + }; + } + + const mutation: TypedDocumentNode = gql` + mutation MaskedMutation { + updateUser { + id + name + ...UserFields + } + } + + fragment UserFields on User { + age + } + `; + + const mocks = [ + { + request: { query: mutation }, + result: { + data: { + updateUser: { + __typename: "User", + id: 1, + name: "Test User", + age: 30, + }, + }, + }, + }, + ]; + + const client = new ApolloClient({ + dataMasking: false, + cache: new InMemoryCache(), + link: new MockLink(mocks), + }); + + const { data } = await client.mutate({ mutation }); + + expect(data).toEqual({ + updateUser: { + __typename: "User", + id: 1, + name: "Test User", + age: 30, + }, + }); + }); + + test("does not mask data returned from client.mutate by default", async () => { + type UserFieldsFragment = { + age: number; + } & { " $fragmentName"?: "UserFieldsFragment" }; + + interface Mutation { + updateUser: { + __typename: "User"; + id: number; + name: string; + } & { + " $fragmentRefs"?: { UserFieldsFragment: UserFieldsFragment }; + }; + } + + const mutation: TypedDocumentNode = gql` + mutation MaskedMutation { + updateUser { + id + name + ...UserFields + } + } + + fragment UserFields on User { + age + } + `; + + const mocks = [ + { + request: { query: mutation }, + result: { + data: { + updateUser: { + __typename: "User", + id: 1, + name: "Test User", + age: 30, + }, + }, + }, + }, + ]; + + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: new MockLink(mocks), + }); + + const { data } = await client.mutate({ mutation }); + + expect(data).toEqual({ + updateUser: { + __typename: "User", + id: 1, + name: "Test User", + age: 30, + }, + }); + }); -describe("client.mutate", () => { - test("masks data returned from client.mutate when dataMasking is `true`", async () => { + test("does not mask data passed to update function", async () => { type UserFieldsFragment = { age: number; } & { " $fragmentName"?: "UserFieldsFragment" }; @@ -4346,24 +5348,34 @@ describe("client.mutate", () => { }, ]; + const cache = new InMemoryCache(); const client = new ApolloClient({ dataMasking: true, - cache: new InMemoryCache(), + cache, link: new MockLink(mocks), }); - const { data } = await client.mutate({ mutation }); + const update = jest.fn(); + await client.mutate({ mutation, update }); - expect(data).toEqual({ - updateUser: { - __typename: "User", - id: 1, - name: "Test User", + expect(update).toHaveBeenCalledTimes(1); + expect(update).toHaveBeenCalledWith( + cache, + { + data: { + updateUser: { + __typename: "User", + id: 1, + name: "Test User", + age: 30, + }, + }, }, - }); + { context: undefined, variables: {} } + ); }); - test("does not mask data returned from client.mutate when dataMasking is `false`", async () => { + test("handles errors returned when using errorPolicy `none`", async () => { type UserFieldsFragment = { age: number; } & { " $fragmentName"?: "UserFieldsFragment" }; @@ -4378,7 +5390,7 @@ describe("client.mutate", () => { }; } - const mutation: TypedDocumentNode = gql` + const mutation: MaskedDocumentNode = gql` mutation MaskedMutation { updateUser { id @@ -4396,37 +5408,83 @@ describe("client.mutate", () => { { request: { query: mutation }, result: { - data: { - updateUser: { - __typename: "User", - id: 1, - name: "Test User", - age: 30, - }, - }, + errors: [{ message: "User not logged in" }], }, }, ]; const client = new ApolloClient({ - dataMasking: false, + dataMasking: true, cache: new InMemoryCache(), link: new MockLink(mocks), }); - const { data } = await client.mutate({ mutation }); + await expect( + client.mutate({ mutation, errorPolicy: "none" }) + ).rejects.toEqual( + new ApolloError({ + graphQLErrors: [{ message: "User not logged in" }], + }) + ); + }); - expect(data).toEqual({ - updateUser: { - __typename: "User", - id: 1, - name: "Test User", - age: 30, + test("handles errors returned when using errorPolicy `all`", async () => { + type UserFieldsFragment = { + age: number; + } & { " $fragmentName"?: "UserFieldsFragment" }; + + interface Mutation { + updateUser: + | ({ + __typename: "User"; + id: number; + name: string; + } & { + " $fragmentRefs"?: { UserFieldsFragment: UserFieldsFragment }; + }) + | null; + } + + const mutation: MaskedDocumentNode = gql` + mutation MaskedMutation { + updateUser { + id + name + ...UserFields + } + } + + fragment UserFields on User { + age + } + `; + + const mocks = [ + { + request: { query: mutation }, + result: { + data: { updateUser: null }, + errors: [{ message: "User not logged in" }], + }, }, + ]; + + const client = new ApolloClient({ + dataMasking: true, + cache: new InMemoryCache(), + link: new MockLink(mocks), + }); + + const { data, errors } = await client.mutate({ + mutation, + errorPolicy: "all", }); + + expect(data).toEqual({ updateUser: null }); + expect(errors).toEqual([{ message: "User not logged in" }]); }); - test("does not mask data returned from client.mutate by default", async () => { + test("masks fragment data in fields nulled by errors when using errorPolicy `all`", async () => { type UserFieldsFragment = { age: number; } & { " $fragmentName"?: "UserFieldsFragment" }; @@ -4441,7 +5499,7 @@ describe("client.mutate", () => { }; } - const mutation: TypedDocumentNode = gql` + const mutation: MaskedDocumentNode = gql` mutation MaskedMutation { updateUser { id @@ -4464,31 +5522,38 @@ describe("client.mutate", () => { __typename: "User", id: 1, name: "Test User", - age: 30, + age: null, }, }, + errors: [{ message: "Could not determine age" }], }, }, ]; const client = new ApolloClient({ + dataMasking: true, cache: new InMemoryCache(), link: new MockLink(mocks), }); - const { data } = await client.mutate({ mutation }); + const { data, errors } = await client.mutate({ + mutation, + errorPolicy: "all", + }); expect(data).toEqual({ updateUser: { __typename: "User", id: 1, name: "Test User", - age: 30, }, }); + + expect(errors).toEqual([{ message: "Could not determine age" }]); }); - test("does not mask data passed to update function", async () => { + test("warns and returns masked result when used with no-cache fetch policy", async () => { + using _ = spyOnConsole("warn"); type UserFieldsFragment = { age: number; } & { " $fragmentName"?: "UserFieldsFragment" }; @@ -4533,34 +5598,31 @@ describe("client.mutate", () => { }, ]; - const cache = new InMemoryCache(); const client = new ApolloClient({ dataMasking: true, - cache, + cache: new InMemoryCache(), link: new MockLink(mocks), }); - const update = jest.fn(); - await client.mutate({ mutation, update }); + const { data } = await client.mutate({ mutation, fetchPolicy: "no-cache" }); - expect(update).toHaveBeenCalledTimes(1); - expect(update).toHaveBeenCalledWith( - cache, - { - data: { - updateUser: { - __typename: "User", - id: 1, - name: "Test User", - age: 30, - }, - }, + expect(data).toEqual({ + updateUser: { + __typename: "User", + id: 1, + name: "Test User", }, - { context: undefined, variables: {} } + }); + + expect(console.warn).toHaveBeenCalledTimes(1); + expect(console.warn).toHaveBeenCalledWith( + NO_CACHE_WARNING, + "MaskedMutation" ); }); - test("handles errors returned when using errorPolicy `none`", async () => { + test("does not warn on no-cache queries when data masking is disabled", async () => { + using _ = spyOnConsole("warn"); type UserFieldsFragment = { age: number; } & { " $fragmentName"?: "UserFieldsFragment" }; @@ -4593,41 +5655,52 @@ describe("client.mutate", () => { { request: { query: mutation }, result: { - errors: [{ message: "User not logged in" }], + data: { + updateUser: { + __typename: "User", + id: 1, + name: "Test User", + age: 30, + }, + }, }, }, ]; const client = new ApolloClient({ - dataMasking: true, + dataMasking: false, cache: new InMemoryCache(), link: new MockLink(mocks), }); - await expect( - client.mutate({ mutation, errorPolicy: "none" }) - ).rejects.toEqual( - new ApolloError({ - graphQLErrors: [{ message: "User not logged in" }], - }) - ); + const { data } = await client.mutate({ mutation, fetchPolicy: "no-cache" }); + + expect(data).toEqual({ + updateUser: { + __typename: "User", + id: 1, + name: "Test User", + age: 30, + }, + }); + + expect(console.warn).not.toHaveBeenCalled(); }); - test("handles errors returned when using errorPolicy `all`", async () => { + test("does not warn on no-cache queries when all fragments use `@unmask`", async () => { + using _ = spyOnConsole("warn"); type UserFieldsFragment = { age: number; } & { " $fragmentName"?: "UserFieldsFragment" }; interface Mutation { - updateUser: - | ({ - __typename: "User"; - id: number; - name: string; - } & { - " $fragmentRefs"?: { UserFieldsFragment: UserFieldsFragment }; - }) - | null; + updateUser: { + __typename: "User"; + id: number; + name: string; + } & { + " $fragmentRefs"?: { UserFieldsFragment: UserFieldsFragment }; + }; } const mutation: MaskedDocumentNode = gql` @@ -4635,7 +5708,7 @@ describe("client.mutate", () => { updateUser { id name - ...UserFields + ...UserFields @unmask } } @@ -4648,8 +5721,14 @@ describe("client.mutate", () => { { request: { query: mutation }, result: { - data: { updateUser: null }, - errors: [{ message: "User not logged in" }], + data: { + updateUser: { + __typename: "User", + id: 1, + name: "Test User", + age: 30, + }, + }, }, }, ]; @@ -4660,19 +5739,30 @@ describe("client.mutate", () => { link: new MockLink(mocks), }); - const { data, errors } = await client.mutate({ - mutation, - errorPolicy: "all", + const { data } = await client.mutate({ mutation, fetchPolicy: "no-cache" }); + + expect(data).toEqual({ + updateUser: { + __typename: "User", + id: 1, + name: "Test User", + age: 30, + }, }); - expect(data).toEqual({ updateUser: null }); - expect(errors).toEqual([{ message: "User not logged in" }]); + expect(console.warn).not.toHaveBeenCalled(); }); - test("masks fragment data in fields nulled by errors when using errorPolicy `all`", async () => { + test("warns on no-cache queries when at least one fragment does not use `@unmask`", async () => { + using _ = spyOnConsole("warn"); type UserFieldsFragment = { age: number; - } & { " $fragmentName"?: "UserFieldsFragment" }; + } & { " $fragmentName"?: "UserFieldsFragment" } & { + " $fragmentRefs"?: { ProfileFieldsFragment: ProfileFieldsFragment }; + }; + type ProfileFieldsFragment = { + username: number; + } & { " $fragmentName"?: "ProfileFieldsFragment" }; interface Mutation { updateUser: { @@ -4689,12 +5779,17 @@ describe("client.mutate", () => { updateUser { id name - ...UserFields + ...UserFields @unmask } } fragment UserFields on User { age + ...ProfileFieldsFragment + } + + fragment ProfileFieldsFragment on User { + username } `; @@ -4707,10 +5802,10 @@ describe("client.mutate", () => { __typename: "User", id: 1, name: "Test User", - age: null, + age: 30, + username: "testuser", }, }, - errors: [{ message: "Could not determine age" }], }, }, ]; @@ -4721,20 +5816,22 @@ describe("client.mutate", () => { link: new MockLink(mocks), }); - const { data, errors } = await client.mutate({ - mutation, - errorPolicy: "all", - }); + const { data } = await client.mutate({ mutation, fetchPolicy: "no-cache" }); expect(data).toEqual({ updateUser: { __typename: "User", id: 1, name: "Test User", + age: 30, }, }); - expect(errors).toEqual([{ message: "Could not determine age" }]); + expect(console.warn).toHaveBeenCalledTimes(1); + expect(console.warn).toHaveBeenCalledWith( + NO_CACHE_WARNING, + "MaskedMutation" + ); }); }); diff --git a/src/core/ApolloClient.ts b/src/core/ApolloClient.ts index a299b56e2f3..ce30f30e5c6 100644 --- a/src/core/ApolloClient.ts +++ b/src/core/ApolloClient.ts @@ -510,6 +510,8 @@ export class ApolloClient implements DataProxy { >( options: SubscriptionOptions ): Observable>> { + const id = this.queryManager.generateQueryId(); + return this.queryManager .startGraphQLSubscription(options) .map((result) => ({ @@ -517,6 +519,8 @@ export class ApolloClient implements DataProxy { data: this.queryManager.maskOperation({ document: options.query, data: result.data, + fetchPolicy: options.fetchPolicy, + id, }), })); } diff --git a/src/core/ObservableQuery.ts b/src/core/ObservableQuery.ts index d134b1989eb..2a70e6e9097 100644 --- a/src/core/ObservableQuery.ts +++ b/src/core/ObservableQuery.ts @@ -1140,6 +1140,8 @@ Did you mean to call refetch(variables) instead of refetch({ variables })?`, data: this.queryManager.maskOperation({ document: this.query, data: result.data, + fetchPolicy: this.options.fetchPolicy, + id: this.queryId, }), } : result; diff --git a/src/core/QueryManager.ts b/src/core/QueryManager.ts index e499a3d78b7..87cea8d2b63 100644 --- a/src/core/QueryManager.ts +++ b/src/core/QueryManager.ts @@ -13,6 +13,7 @@ import { hasDirectives, isExecutionPatchIncrementalResult, isExecutionPatchResult, + isFullyUnmaskedOperation, removeDirectivesFromDocument, } from "../utilities/index.js"; import type { Cache, ApolloCache } from "../cache/index.js"; @@ -51,6 +52,7 @@ import type { MutationOptions, ErrorPolicy, MutationFetchPolicy, + WatchQueryFetchPolicy, } from "./watchQueryOptions.js"; import { ObservableQuery, logMissingFieldErrors } from "./ObservableQuery.js"; import { NetworkStatus, isNetworkRequestInFlight } from "./networkStatus.js"; @@ -118,6 +120,8 @@ interface MaskFragmentOptions { interface MaskOperationOptions { document: DocumentNode; data: TData; + id: string; + fetchPolicy?: WatchQueryFetchPolicy; } export interface QueryManagerOptions { @@ -362,6 +366,8 @@ export class QueryManager { data: self.maskOperation({ document: mutation, data: storeResult.data, + fetchPolicy, + id: mutationId, }) as any, }); } @@ -819,7 +825,12 @@ export class QueryManager { (result) => result && { ...result, - data: this.maskOperation({ document: query, data: result.data }), + data: this.maskOperation({ + document: query, + data: result.data, + fetchPolicy: options.fetchPolicy, + id: queryId, + }), } ) .finally(() => this.stopQuery(queryId)); @@ -1554,11 +1565,34 @@ export class QueryManager { return results; } + private noCacheWarningsByQueryId = new Set(); + public maskOperation( options: MaskOperationOptions ): MaybeMasked { const { document, data } = options; + if (__DEV__) { + const { fetchPolicy, id } = options; + const operationType = getOperationDefinition(document)?.operation; + const operationId = (operationType?.[0] ?? "o") + id; + + if ( + this.dataMasking && + fetchPolicy === "no-cache" && + !isFullyUnmaskedOperation(document) && + !this.noCacheWarningsByQueryId.has(operationId) + ) { + this.noCacheWarningsByQueryId.add(operationId); + + invariant.warn( + '[%s]: Fragments masked by data masking are inaccessible when using fetch policy "no-cache". Please add `@unmask` to each fragment spread to access the data.', + getOperationName(document) ?? + `Unnamed ${operationType ?? "operation"}` + ); + } + } + return ( this.dataMasking ? maskOperation(data, document, this.cache) diff --git a/src/utilities/graphql/fragments.ts b/src/utilities/graphql/fragments.ts index c4c0d721b5c..5baffedf69d 100644 --- a/src/utilities/graphql/fragments.ts +++ b/src/utilities/graphql/fragments.ts @@ -1,5 +1,6 @@ import { invariant, newInvariantError } from "../globals/index.js"; +import { BREAK, visit } from "graphql"; import type { DocumentNode, FragmentDefinitionNode, @@ -143,3 +144,21 @@ export function getFragmentFromSelection( return null; } } + +export function isFullyUnmaskedOperation(document: DocumentNode) { + let isUnmasked = true; + + visit(document, { + FragmentSpread: (node) => { + isUnmasked = + !!node.directives && + node.directives.some((directive) => directive.name.value === "unmask"); + + if (!isUnmasked) { + return BREAK; + } + }, + }); + + return isUnmasked; +} diff --git a/src/utilities/index.ts b/src/utilities/index.ts index 59f2edb054f..300cafb0d56 100644 --- a/src/utilities/index.ts +++ b/src/utilities/index.ts @@ -23,6 +23,7 @@ export { createFragmentMap, getFragmentQueryDocument, getFragmentFromSelection, + isFullyUnmaskedOperation, } from "./graphql/fragments.js"; export {