From 4bb8bfc45c2f2eb4b986d432e73da6051cbd335e Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Fri, 16 Aug 2024 08:16:40 -0600 Subject: [PATCH] [Data masking] Warn when passing object to `useFragment`/`watchFragment` `from` that is not identifiable (#12004) --- src/__tests__/dataMasking.ts | 228 ++++++++++++++++++ src/cache/core/cache.ts | 16 +- .../hooks/__tests__/useFragment.test.tsx | 40 +++ 3 files changed, 283 insertions(+), 1 deletion(-) diff --git a/src/__tests__/dataMasking.ts b/src/__tests__/dataMasking.ts index 94351af36e4..0349fc975b8 100644 --- a/src/__tests__/dataMasking.ts +++ b/src/__tests__/dataMasking.ts @@ -1076,6 +1076,234 @@ test("warns when accessing a unmasked field while using @unmask with mode: 'migr } }); +test("reads fragment by passing parent object to `from`", async () => { + interface Query { + currentUser: { + __typename: "User"; + id: number; + name: string; + }; + } + + interface Fragment { + age: number; + } + + const fragment: TypedDocumentNode = gql` + fragment UserFields on User { + age + } + `; + + const query: TypedDocumentNode = gql` + query MaskedQuery { + currentUser { + id + name + ...UserFields + } + } + + ${fragment} + `; + + 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 queryStream = new ObservableStream(client.watchQuery({ query })); + + const { data } = await queryStream.takeNext(); + const fragmentObservable = client.watchFragment({ + fragment, + from: data.currentUser, + }); + + const fragmentStream = new ObservableStream(fragmentObservable); + + { + const { data, complete } = await fragmentStream.takeNext(); + + expect(complete).toBe(true); + expect(data).toEqual({ __typename: "User", age: 30 }); + } +}); + +test("warns when passing parent object to `from` when id is masked", async () => { + using _ = spyOnConsole("warn"); + + interface Query { + currentUser: { + __typename: "User"; + id: number; + name: string; + }; + } + + interface Fragment { + age: number; + } + + const fragment: TypedDocumentNode = gql` + fragment UserFields on User { + id + age + } + `; + + const query: TypedDocumentNode = gql` + query MaskedQuery { + currentUser { + name + ...UserFields + } + } + + ${fragment} + `; + + 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 queryStream = new ObservableStream(client.watchQuery({ query })); + + const { data } = await queryStream.takeNext(); + const fragmentObservable = client.watchFragment({ + fragment, + from: data.currentUser, + }); + + expect(console.warn).toHaveBeenCalledTimes(1); + expect(console.warn).toHaveBeenCalledWith( + "Could not identify object passed to `from` for '%s' fragment, either because the object is non-normalized or the key fields are missing. If you are masking this object, please ensure the key fields are requested by the parent object.", + "UserFields" + ); + + const fragmentStream = new ObservableStream(fragmentObservable); + + { + const { data, complete } = await fragmentStream.takeNext(); + + expect(data).toEqual({}); + // TODO: Update when https://github.com/apollographql/apollo-client/issues/12003 is fixed + expect(complete).toBe(true); + } +}); + +test("warns when passing parent object to `from` that is non-normalized", async () => { + using _ = spyOnConsole("warn"); + + interface Query { + currentUser: { + __typename: "User"; + name: string; + }; + } + + interface Fragment { + age: number; + } + + const fragment: TypedDocumentNode = gql` + fragment UserFields on User { + age + } + `; + + const query: TypedDocumentNode = gql` + query MaskedQuery { + currentUser { + name + ...UserFields + } + } + + ${fragment} + `; + + const mocks = [ + { + request: { query }, + result: { + data: { + currentUser: { + __typename: "User", + name: "Test User", + age: 30, + }, + }, + }, + }, + ]; + + const client = new ApolloClient({ + dataMasking: true, + cache: new InMemoryCache(), + link: new MockLink(mocks), + }); + + const queryStream = new ObservableStream(client.watchQuery({ query })); + + const { data } = await queryStream.takeNext(); + const fragmentObservable = client.watchFragment({ + fragment, + from: data.currentUser, + }); + + expect(console.warn).toHaveBeenCalledTimes(1); + expect(console.warn).toHaveBeenCalledWith( + "Could not identify object passed to `from` for '%s' fragment, either because the object is non-normalized or the key fields are missing. If you are masking this object, please ensure the key fields are requested by the parent object.", + "UserFields" + ); + + const fragmentStream = new ObservableStream(fragmentObservable); + + { + const { data, complete } = await fragmentStream.takeNext(); + + expect(data).toEqual({}); + // TODO: Update when https://github.com/apollographql/apollo-client/issues/12003 is fixed + expect(complete).toBe(true); + } +}); + class TestCache extends ApolloCache { public diff(query: Cache.DiffOptions): DataProxy.DiffResult { return {}; diff --git a/src/cache/core/cache.ts b/src/cache/core/cache.ts index 598346a0b7a..a5090cc8e83 100644 --- a/src/cache/core/cache.ts +++ b/src/cache/core/cache.ts @@ -10,6 +10,7 @@ import { Observable, cacheSizes, defaultCacheSizes, + getFragmentDefinition, getFragmentQueryDocument, mergeDeepArray, } from "../../utilities/index.js"; @@ -240,11 +241,24 @@ export abstract class ApolloCache implements DataProxy { ...otherOptions } = options; const query = this.getFragmentDoc(fragment, fragmentName); + const id = typeof from === "string" ? from : this.identify(from); + + if (__DEV__) { + const actualFragmentName = + fragmentName || getFragmentDefinition(fragment).name.value; + + if (!id) { + invariant.warn( + "Could not identify object passed to `from` for '%s' fragment, either because the object is non-normalized or the key fields are missing. If you are masking this object, please ensure the key fields are requested by the parent object.", + actualFragmentName + ); + } + } const diffOptions: Cache.DiffOptions = { ...otherOptions, returnPartialData: true, - id: typeof from === "string" ? from : this.identify(from), + id, query, optimistic, }; diff --git a/src/react/hooks/__tests__/useFragment.test.tsx b/src/react/hooks/__tests__/useFragment.test.tsx index f58ef9aaa6d..90698950144 100644 --- a/src/react/hooks/__tests__/useFragment.test.tsx +++ b/src/react/hooks/__tests__/useFragment.test.tsx @@ -1558,6 +1558,46 @@ describe("useFragment", () => { await expect(ProfiledHook).not.toRerender(); }); + it("warns when passing parent object to `from` when key fields are missing", async () => { + using _ = spyOnConsole("warn"); + + interface Fragment { + age: number; + } + + const fragment: TypedDocumentNode = gql` + fragment UserFields on User { + age + } + `; + + const client = new ApolloClient({ cache: new InMemoryCache() }); + + const ProfiledHook = profileHook(() => + useFragment({ fragment, from: { __typename: "User" } }) + ); + + render(, { + wrapper: ({ children }) => ( + {children} + ), + }); + + expect(console.warn).toHaveBeenCalledTimes(1); + expect(console.warn).toHaveBeenCalledWith( + "Could not identify object passed to `from` for '%s' fragment, either because the object is non-normalized or the key fields are missing. If you are masking this object, please ensure the key fields are requested by the parent object.", + "UserFields" + ); + + { + const { data, complete } = await ProfiledHook.takeSnapshot(); + + expect(data).toEqual({}); + // TODO: Update when https://github.com/apollographql/apollo-client/issues/12003 is fixed + expect(complete).toBe(true); + } + }); + describe("tests with incomplete data", () => { let cache: InMemoryCache, wrapper: React.FunctionComponent; const ItemFragment = gql`