diff --git a/.api-reports/api-report-core.api.md b/.api-reports/api-report-core.api.md index 50206fff96f..bb4ab60d18d 100644 --- a/.api-reports/api-report-core.api.md +++ b/.api-reports/api-report-core.api.md @@ -2359,7 +2359,7 @@ interface WriteContext extends ReadMergeModifyContext { // src/core/ObservableQuery.ts:116:5 - (ae-forgotten-export) The symbol "QueryManager" needs to be exported by the entry point index.d.ts // src/core/ObservableQuery.ts:117:5 - (ae-forgotten-export) The symbol "QueryInfo" needs to be exported by the entry point index.d.ts // src/core/QueryManager.ts:152:5 - (ae-forgotten-export) The symbol "MutationStoreValue" needs to be exported by the entry point index.d.ts -// src/core/QueryManager.ts:397:7 - (ae-forgotten-export) The symbol "UpdateQueries" needs to be exported by the entry point index.d.ts +// src/core/QueryManager.ts:403:7 - (ae-forgotten-export) The symbol "UpdateQueries" needs to be exported by the entry point index.d.ts // src/core/watchQueryOptions.ts:275: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 0b4805b044b..46dc4c3e6e3 100644 --- a/.api-reports/api-report-react.api.md +++ b/.api-reports/api-report-react.api.md @@ -2381,7 +2381,7 @@ interface WatchQueryOptions(inter // src/core/ObservableQuery.ts:116:5 - (ae-forgotten-export) The symbol "QueryManager" needs to be exported by the entry point index.d.ts // src/core/ObservableQuery.ts:117:5 - (ae-forgotten-export) The symbol "QueryInfo" needs to be exported by the entry point index.d.ts // src/core/QueryManager.ts:152:5 - (ae-forgotten-export) The symbol "MutationStoreValue" needs to be exported by the entry point index.d.ts -// src/core/QueryManager.ts:397:7 - (ae-forgotten-export) The symbol "UpdateQueries" needs to be exported by the entry point index.d.ts +// src/core/QueryManager.ts:403:7 - (ae-forgotten-export) The symbol "UpdateQueries" needs to be exported by the entry point index.d.ts // src/core/types.ts:174:3 - (ae-forgotten-export) The symbol "MutationQueryReducer" needs to be exported by the entry point index.d.ts // src/core/types.ts:203:5 - (ae-forgotten-export) The symbol "Resolver" needs to be exported by the entry point index.d.ts // src/core/watchQueryOptions.ts:275: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 1f470c15801..079c8fbdb25 100644 --- a/.api-reports/api-report-react_ssr.api.md +++ b/.api-reports/api-report-react_ssr.api.md @@ -1765,7 +1765,7 @@ interface WatchQueryOptions(it: (...args: TArgs // src/core/ObservableQuery.ts:116:5 - (ae-forgotten-export) The symbol "QueryManager" needs to be exported by the entry point index.d.ts // src/core/ObservableQuery.ts:117:5 - (ae-forgotten-export) The symbol "QueryInfo" needs to be exported by the entry point index.d.ts // src/core/QueryManager.ts:152:5 - (ae-forgotten-export) The symbol "MutationStoreValue" needs to be exported by the entry point index.d.ts -// src/core/QueryManager.ts:397:7 - (ae-forgotten-export) The symbol "UpdateQueries" needs to be exported by the entry point index.d.ts +// src/core/QueryManager.ts:403:7 - (ae-forgotten-export) The symbol "UpdateQueries" needs to be exported by the entry point index.d.ts // src/core/types.ts:174:3 - (ae-forgotten-export) The symbol "MutationQueryReducer" needs to be exported by the entry point index.d.ts // src/core/types.ts:203:5 - (ae-forgotten-export) The symbol "Resolver" needs to be exported by the entry point index.d.ts // src/core/watchQueryOptions.ts:275: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 98adfa454f3..993a2666395 100644 --- a/.api-reports/api-report-testing_core.api.md +++ b/.api-reports/api-report-testing_core.api.md @@ -1790,7 +1790,7 @@ export function withWarningSpy(it: (...args: TArgs // src/core/ObservableQuery.ts:116:5 - (ae-forgotten-export) The symbol "QueryManager" needs to be exported by the entry point index.d.ts // src/core/ObservableQuery.ts:117:5 - (ae-forgotten-export) The symbol "QueryInfo" needs to be exported by the entry point index.d.ts // src/core/QueryManager.ts:152:5 - (ae-forgotten-export) The symbol "MutationStoreValue" needs to be exported by the entry point index.d.ts -// src/core/QueryManager.ts:397:7 - (ae-forgotten-export) The symbol "UpdateQueries" needs to be exported by the entry point index.d.ts +// src/core/QueryManager.ts:403:7 - (ae-forgotten-export) The symbol "UpdateQueries" needs to be exported by the entry point index.d.ts // src/core/types.ts:174:3 - (ae-forgotten-export) The symbol "MutationQueryReducer" needs to be exported by the entry point index.d.ts // src/core/types.ts:203:5 - (ae-forgotten-export) The symbol "Resolver" needs to be exported by the entry point index.d.ts // src/core/watchQueryOptions.ts:275: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 30fa559078e..fd49e1e1cb0 100644 --- a/.api-reports/api-report-utilities.api.md +++ b/.api-reports/api-report-utilities.api.md @@ -2727,7 +2727,7 @@ interface WriteContext extends ReadMergeModifyContext { // src/core/ObservableQuery.ts:116:5 - (ae-forgotten-export) The symbol "QueryManager" needs to be exported by the entry point index.d.ts // src/core/ObservableQuery.ts:117:5 - (ae-forgotten-export) The symbol "QueryInfo" needs to be exported by the entry point index.d.ts // src/core/QueryManager.ts:152:5 - (ae-forgotten-export) The symbol "MutationStoreValue" needs to be exported by the entry point index.d.ts -// src/core/QueryManager.ts:397:7 - (ae-forgotten-export) The symbol "UpdateQueries" needs to be exported by the entry point index.d.ts +// src/core/QueryManager.ts:403:7 - (ae-forgotten-export) The symbol "UpdateQueries" needs to be exported by the entry point index.d.ts // src/core/types.ts:174:3 - (ae-forgotten-export) The symbol "MutationQueryReducer" needs to be exported by the entry point index.d.ts // src/core/types.ts:203:5 - (ae-forgotten-export) The symbol "Resolver" needs to be exported by the entry point index.d.ts // src/core/watchQueryOptions.ts:275: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 68cec7f45f6..26e8329d7b6 100644 --- a/.api-reports/api-report.api.md +++ b/.api-reports/api-report.api.md @@ -3074,7 +3074,7 @@ interface WriteContext extends ReadMergeModifyContext { // src/core/ObservableQuery.ts:116:5 - (ae-forgotten-export) The symbol "QueryManager" needs to be exported by the entry point index.d.ts // src/core/ObservableQuery.ts:117:5 - (ae-forgotten-export) The symbol "QueryInfo" needs to be exported by the entry point index.d.ts // src/core/QueryManager.ts:152:5 - (ae-forgotten-export) The symbol "MutationStoreValue" needs to be exported by the entry point index.d.ts -// src/core/QueryManager.ts:397:7 - (ae-forgotten-export) The symbol "UpdateQueries" needs to be exported by the entry point index.d.ts +// src/core/QueryManager.ts:403:7 - (ae-forgotten-export) The symbol "UpdateQueries" needs to be exported by the entry point index.d.ts // src/core/watchQueryOptions.ts:275: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 1b40dd3f3b0..71ec43494cc 100644 --- a/.size-limits.json +++ b/.size-limits.json @@ -1,4 +1,4 @@ { - "dist/apollo-client.min.cjs": 41335, - "import { ApolloClient, InMemoryCache, HttpLink } from \"dist/index.js\" (production)": 34036 + "dist/apollo-client.min.cjs": 41349, + "import { ApolloClient, InMemoryCache, HttpLink } from \"dist/index.js\" (production)": 34057 } diff --git a/src/__tests__/dataMasking.ts b/src/__tests__/dataMasking.ts index e09350b43f8..1dc3a466d7b 100644 --- a/src/__tests__/dataMasking.ts +++ b/src/__tests__/dataMasking.ts @@ -3298,6 +3298,400 @@ describe("observableQuery.subscribeToMore", () => { }); }); +describe("client.mutate", () => { + test("masks data returned from client.mutate when dataMasking is `true`", async () => { + interface Mutation { + updateUser: { + __typename: "User"; + id: number; + name: string; + }; + } + + 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: 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 () => { + interface Mutation { + updateUser: { + __typename: "User"; + id: number; + name: string; + }; + } + + 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 () => { + interface Mutation { + updateUser: { + __typename: "User"; + id: number; + name: string; + }; + } + + 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, + }, + }); + }); + + test("does not mask data passed to update function", async () => { + interface Mutation { + updateUser: { + __typename: "User"; + id: number; + name: string; + }; + } + + 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 cache = new InMemoryCache(); + const client = new ApolloClient({ + dataMasking: true, + cache, + link: new MockLink(mocks), + }); + + const update = jest.fn(); + await client.mutate({ mutation, update }); + + expect(update).toHaveBeenCalledTimes(1); + expect(update).toHaveBeenCalledWith( + cache, + { + data: { + updateUser: { + __typename: "User", + id: 1, + name: "Test User", + age: 30, + }, + }, + }, + { context: undefined, variables: {} } + ); + }); + + test("handles errors returned when using errorPolicy `none`", async () => { + interface Mutation { + updateUser: { + __typename: "User"; + id: number; + name: string; + }; + } + + const mutation: TypedDocumentNode = gql` + mutation MaskedMutation { + updateUser { + id + name + ...UserFields + } + } + + fragment UserFields on User { + age + } + `; + + const mocks = [ + { + request: { query: mutation }, + result: { + errors: [{ message: "User not logged in" }], + }, + }, + ]; + + const client = new ApolloClient({ + dataMasking: true, + cache: new InMemoryCache(), + link: new MockLink(mocks), + }); + + await expect( + client.mutate({ mutation, errorPolicy: "none" }) + ).rejects.toEqual( + new ApolloError({ + graphQLErrors: [{ message: "User not logged in" }], + }) + ); + }); + + test("handles errors returned when using errorPolicy `all`", async () => { + interface Mutation { + updateUser: { + __typename: "User"; + id: number; + name: string; + }; + } + + const mutation: TypedDocumentNode = 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("masks fragment data in fields nulled by errors when using errorPolicy `all`", async () => { + interface Mutation { + updateUser: { + __typename: "User"; + id: number; + name: string; + }; + } + + 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: 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.mutate({ + mutation, + errorPolicy: "all", + }); + + expect(data).toEqual({ + updateUser: { + __typename: "User", + id: 1, + name: "Test User", + }, + }); + + expect(errors).toEqual([{ message: "Could not determine age" }]); + }); +}); + class TestCache extends ApolloCache { public diff(query: Cache.DiffOptions): DataProxy.DiffResult { return {}; diff --git a/src/core/QueryManager.ts b/src/core/QueryManager.ts index 36948a4c13f..ce7703f5f82 100644 --- a/src/core/QueryManager.ts +++ b/src/core/QueryManager.ts @@ -352,7 +352,13 @@ export class QueryManager { // ExecutionPatchResult has arrived and we have assembled the // multipart response into a single result. if (!("hasNext" in storeResult) || storeResult.hasNext === false) { - resolve(storeResult); + resolve({ + ...storeResult, + data: self.maskOperation({ + document: mutation, + data: storeResult.data, + }), + } as FetchResult); } }, diff --git a/src/react/hooks/__tests__/useMutation.test.tsx b/src/react/hooks/__tests__/useMutation.test.tsx index da26fd2c87d..304c5302fcb 100644 --- a/src/react/hooks/__tests__/useMutation.test.tsx +++ b/src/react/hooks/__tests__/useMutation.test.tsx @@ -25,6 +25,7 @@ import { mockSingleLink, subscribeAndCount, MockedResponse, + MockLink, } from "../../../testing"; import { ApolloProvider } from "../../context"; import { useQuery } from "../useQuery"; @@ -2826,6 +2827,308 @@ describe("useMutation Hook", () => { }); }); +describe("data masking", () => { + test("masks data returned from useMutation when dataMasking is `true`", async () => { + interface Mutation { + updateUser: { + __typename: "User"; + id: number; + name: string; + }; + } + + 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, + }, + }, + }, + delay: 10, + }, + ]; + + const client = new ApolloClient({ + dataMasking: true, + cache: new InMemoryCache(), + link: new MockLink(mocks), + }); + + const ProfiledHook = profileHook(() => useMutation(mutation)); + + render(, { + wrapper: ({ children }) => ( + {children} + ), + }); + + const [mutate, result] = await ProfiledHook.takeSnapshot(); + + expect(result.loading).toBe(false); + expect(result.data).toBeUndefined(); + expect(result.error).toBeUndefined(); + + let promise!: Promise>; + act(() => { + promise = mutate(); + }); + + { + const [, result] = await ProfiledHook.takeSnapshot(); + + expect(result.loading).toBe(true); + expect(result.data).toBeUndefined(); + expect(result.error).toBeUndefined(); + } + + { + const [, result] = await ProfiledHook.takeSnapshot(); + + expect(result.loading).toBe(false); + expect(result.data).toEqual({ + updateUser: { + __typename: "User", + id: 1, + name: "Test User", + }, + }); + expect(result.error).toBeUndefined(); + } + + { + const { data, errors } = await promise; + + expect(data).toEqual({ + updateUser: { + __typename: "User", + id: 1, + name: "Test User", + }, + }); + expect(errors).toBeUndefined(); + } + + await expect(ProfiledHook).not.toRerender(); + }); + + test("does not mask data returned from useMutation when dataMasking is `false`", async () => { + interface Mutation { + updateUser: { + __typename: "User"; + id: number; + name: string; + }; + } + + 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, + }, + }, + }, + delay: 10, + }, + ]; + + const client = new ApolloClient({ + dataMasking: false, + cache: new InMemoryCache(), + link: new MockLink(mocks), + }); + + const ProfiledHook = profileHook(() => useMutation(mutation)); + + render(, { + wrapper: ({ children }) => ( + {children} + ), + }); + + const [mutate, result] = await ProfiledHook.takeSnapshot(); + + expect(result.loading).toBe(false); + expect(result.data).toBeUndefined(); + expect(result.error).toBeUndefined(); + + let promise!: Promise>; + act(() => { + promise = mutate(); + }); + + { + const [, result] = await ProfiledHook.takeSnapshot(); + + expect(result.loading).toBe(true); + expect(result.data).toBeUndefined(); + expect(result.error).toBeUndefined(); + } + + { + const [, result] = await ProfiledHook.takeSnapshot(); + + expect(result.loading).toBe(false); + expect(result.data).toEqual({ + updateUser: { + __typename: "User", + id: 1, + name: "Test User", + age: 30, + }, + }); + expect(result.error).toBeUndefined(); + } + + { + const { data, errors } = await promise; + + expect(data).toEqual({ + updateUser: { + __typename: "User", + id: 1, + name: "Test User", + age: 30, + }, + }); + expect(errors).toBeUndefined(); + } + + await expect(ProfiledHook).not.toRerender(); + }); + + test("passes masked data to onCompleted, does not pass masked data to update", async () => { + interface Mutation { + updateUser: { + __typename: "User"; + id: number; + name: string; + }; + } + + 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, + }, + }, + }, + delay: 10, + }, + ]; + + const cache = new InMemoryCache(); + const client = new ApolloClient({ + dataMasking: true, + cache, + link: new MockLink(mocks), + }); + + const update = jest.fn(); + const onCompleted = jest.fn(); + const ProfiledHook = profileHook(() => + useMutation(mutation, { onCompleted, update }) + ); + + render(, { + wrapper: ({ children }) => ( + {children} + ), + }); + + const [mutate] = await ProfiledHook.takeSnapshot(); + + await act(() => mutate()); + + expect(onCompleted).toHaveBeenCalledTimes(1); + expect(onCompleted).toHaveBeenCalledWith( + { + updateUser: { + __typename: "User", + id: 1, + name: "Test User", + }, + }, + expect.anything() + ); + + expect(update).toHaveBeenCalledTimes(1); + expect(update).toHaveBeenCalledWith( + cache, + { + data: { + updateUser: { + __typename: "User", + id: 1, + name: "Test User", + age: 30, + }, + }, + }, + { context: undefined, variables: {} } + ); + }); +}); + describe.skip("Type Tests", () => { test("NoInfer prevents adding arbitrary additional variables", () => { const typedNode = {} as TypedDocumentNode<{ foo: string }, { bar: number }>;