From 23031101481d65861ebccb46781299cc8587c2c0 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 28 Aug 2024 10:58:15 -0600 Subject: [PATCH] [Data masking] Add support for masking `client.query` calls (#12033) --- .size-limits.json | 4 +- src/__tests__/dataMasking.ts | 3887 ++++++++++++++++++---------------- src/core/QueryManager.ts | 13 +- 3 files changed, 2118 insertions(+), 1786 deletions(-) diff --git a/.size-limits.json b/.size-limits.json index 7eaebd5dffd..b2be55833cd 100644 --- a/.size-limits.json +++ b/.size-limits.json @@ -1,4 +1,4 @@ { - "dist/apollo-client.min.cjs": 41282, - "import { ApolloClient, InMemoryCache, HttpLink } from \"dist/index.js\" (production)": 33969 + "dist/apollo-client.min.cjs": 41301, + "import { ApolloClient, InMemoryCache, HttpLink } from \"dist/index.js\" (production)": 33987 } diff --git a/src/__tests__/dataMasking.ts b/src/__tests__/dataMasking.ts index d019ff1e74d..eeddd834379 100644 --- a/src/__tests__/dataMasking.ts +++ b/src/__tests__/dataMasking.ts @@ -2,6 +2,7 @@ import { FragmentSpreadNode, Kind, visit } from "graphql"; import { ApolloCache, ApolloClient, + ApolloError, ApolloLink, Cache, DataProxy, @@ -18,588 +19,1144 @@ import { ObservableStream, spyOnConsole } from "../testing/internal"; import { invariant } from "../utilities/globals"; import { createFragmentRegistry } from "../cache/inmemory/fragmentRegistry"; -test("masks queries when dataMasking is `true`", async () => { - interface Query { - currentUser: { - __typename: "User"; - id: number; - name: string; - }; - } +describe("client.watchQuery", () => { + test("masks queries when dataMasking is `true`", async () => { + interface Query { + currentUser: { + __typename: "User"; + id: number; + name: string; + }; + } - const query: TypedDocumentNode = gql` - query MaskedQuery { - currentUser { - id - name - ...UserFields + const query: TypedDocumentNode = 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 observable = client.watchQuery({ query }); + + const stream = new ObservableStream(observable); + + { + const { data } = await stream.takeNext(); + + expect(data).toEqual({ + currentUser: { + __typename: "User", + id: 1, + name: "Test User", + }, + }); } + }); - fragment UserFields on User { - age + test("does not mask query when dataMasking is `false`", async () => { + interface Query { + currentUser: { + __typename: "User"; + id: number; + name: string; + }; } - `; - const mocks = [ + const query: TypedDocumentNode = 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 observable = client.watchQuery({ query }); + + const stream = new ObservableStream(observable); + { - request: { query }, - result: { - data: { - currentUser: { - __typename: "User", - id: 1, - name: "Test User", - age: 30, + const { data } = await stream.takeNext(); + + expect(data).toEqual({ + currentUser: { + __typename: "User", + id: 1, + name: "Test User", + age: 30, + }, + }); + } + }); + + test("does not mask query by default", async () => { + interface Query { + currentUser: { + __typename: "User"; + id: number; + name: string; + }; + } + + const query: TypedDocumentNode = 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 client = new ApolloClient({ + cache: new InMemoryCache(), + link: new MockLink(mocks), + }); + + const observable = client.watchQuery({ query }); + + const stream = new ObservableStream(observable); + + { + const { data } = await stream.takeNext(); + + expect(data).toEqual({ + currentUser: { + __typename: "User", + id: 1, + name: "Test User", + age: 30, + }, + }); + } }); - const observable = client.watchQuery({ query }); + test("does not mask fragments marked with @unmask", async () => { + interface Query { + currentUser: { + __typename: "User"; + id: number; + name: string; + }; + } - const stream = new ObservableStream(observable); + const query: TypedDocumentNode = gql` + query UnmaskedQuery { + currentUser { + id + name + ...UserFields @unmask + } + } - { - const { data } = await stream.takeNext(); + fragment UserFields on User { + age + } + `; - expect(data).toEqual({ + const mocks = [ + { + request: { query }, + result: { + data: { + currentUser: { + __typename: "User", + id: 1, + name: "Test User", + age: 30, + }, + }, + }, + }, + ]; + + const client = new ApolloClient({ + dataMasking: true, + cache: new InMemoryCache(), + link: new MockLink(mocks), + }); + + const observable = client.watchQuery({ query }); + + const stream = new ObservableStream(observable); + + { + const { data } = await stream.takeNext(); + + expect(data).toEqual({ + currentUser: { + __typename: "User", + id: 1, + name: "Test User", + age: 30, + }, + }); + } + }); + + test("does not mask fragments marked with @unmask added by document transforms", async () => { + const documentTransform = new DocumentTransform((document) => { + return visit(document, { + FragmentSpread(node) { + return { + ...node, + directives: [ + { + kind: Kind.DIRECTIVE, + name: { kind: Kind.NAME, value: "unmask" }, + }, + ], + } satisfies FragmentSpreadNode; + }, + }); + }); + + interface Query { currentUser: { - __typename: "User", - id: 1, - name: "Test User", + __typename: "User"; + id: number; + name: string; + }; + } + + const query: TypedDocumentNode = gql` + query UnmaskedQuery { + currentUser { + id + name + ...UserFields + } + } + + fragment UserFields on User { + age + } + `; + + const mocks = [ + { + request: { query }, + result: { + data: { + currentUser: { + __typename: "User", + id: 1, + name: "Test User", + age: 30, + }, + }, + }, }, + ]; + + const client = new ApolloClient({ + dataMasking: true, + cache: new InMemoryCache(), + link: new MockLink(mocks), + documentTransform, }); - } -}); -test("does not mask query when dataMasking is `false`", async () => { - interface Query { - currentUser: { - __typename: "User"; - id: number; - name: string; - }; - } + const observable = client.watchQuery({ query }); - const query: TypedDocumentNode = gql` - query MaskedQuery { - currentUser { - id - name - ...UserFields + const stream = new ObservableStream(observable); + + { + const { data } = await stream.takeNext(); + + expect(data).toEqual({ + currentUser: { + __typename: "User", + id: 1, + name: "Test User", + age: 30, + }, + }); + } + }); + + test("does not mask query when using a cache that does not support it", async () => { + using _ = spyOnConsole("warn"); + + interface Query { + currentUser: { + __typename: "User"; + id: number; + name: string; + }; + } + + const query: TypedDocumentNode = 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 TestCache(), + link: new MockLink(mocks), + }); + + const observable = client.watchQuery({ query }); + + const stream = new ObservableStream(observable); + + { + const { data } = await stream.takeNext(); + + expect(data).toEqual({ + currentUser: { + __typename: "User", + id: 1, + name: "Test User", + age: 30, + }, + }); } - fragment UserFields on User { - age + expect(console.warn).toHaveBeenCalledTimes(1); + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining( + "The configured cache does not support data masking" + ) + ); + }); + + test("masks queries updated by the cache", async () => { + interface Query { + currentUser: { + __typename: "User"; + id: number; + name: string; + }; + } + + const query: TypedDocumentNode = 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 observable = client.watchQuery({ query }); + + const stream = new ObservableStream(observable); + + { + const { data } = await stream.takeNext(); + + expect(data).toEqual({ + currentUser: { + __typename: "User", + id: 1, + name: "Test User", + }, + }); } - `; - const mocks = [ + client.writeQuery({ + query, + data: { + currentUser: { + __typename: "User", + id: 1, + name: "Test User (updated)", + // @ts-ignore TODO: Determine how to handle cache writes with masked + // query type + age: 35, + }, + }, + }); + { - request: { query }, - result: { - data: { - currentUser: { - __typename: "User", - id: 1, - name: "Test User", - age: 30, + const { data } = await stream.takeNext(); + + expect(data).toEqual({ + currentUser: { + __typename: "User", + id: 1, + name: "Test User (updated)", + }, + }); + } + }); + + test("does not trigger update when updating field in named fragment", async () => { + interface Query { + currentUser: { + __typename: "User"; + id: number; + name: string; + }; + } + + const query: TypedDocumentNode = 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 observable = client.watchQuery({ query }); - - const stream = new ObservableStream(observable); + ]; + + const client = new ApolloClient({ + dataMasking: true, + cache: new InMemoryCache(), + link: new MockLink(mocks), + }); + + const observable = client.watchQuery({ query }); + + const stream = new ObservableStream(observable); + + { + const { data } = await stream.takeNext(); + + expect(data).toEqual({ + currentUser: { + __typename: "User", + id: 1, + name: "Test User", + }, + }); + } + + client.writeQuery({ + query, + data: { + currentUser: { + __typename: "User", + id: 1, + name: "Test User", + // @ts-ignore TODO: Determine how to handle cache writes with masked + // query type + age: 35, + }, + }, + }); - { - const { data } = await stream.takeNext(); + await expect(stream.takeNext()).rejects.toThrow( + new Error("Timeout waiting for next event") + ); - expect(data).toEqual({ + expect(client.readQuery({ query })).toEqual({ currentUser: { __typename: "User", id: 1, name: "Test User", - age: 30, + age: 35, }, }); - } -}); - -test("does not mask query by default", async () => { - interface Query { - currentUser: { - __typename: "User"; - id: number; - name: string; - }; - } + }); - const query: TypedDocumentNode = gql` - query MaskedQuery { - currentUser { - id - name - ...UserFields + it.each(["cache-first", "cache-only"] as FetchPolicy[])( + "masks result from cache when using with %s fetch policy", + async (fetchPolicy) => { + interface Query { + currentUser: { + __typename: "User"; + id: number; + name: string; + }; } - } - fragment UserFields on User { - age - } - `; + const query: TypedDocumentNode = gql` + query MaskedQuery { + currentUser { + id + name + ...UserFields + } + } - const mocks = [ - { - request: { query }, - result: { + 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), + }); + + client.writeQuery({ + query, data: { currentUser: { __typename: "User", id: 1, name: "Test User", + // @ts-expect-error TODO: Determine how to write this with masked types age: 30, }, }, - }, - }, - ]; + }); - const client = new ApolloClient({ - cache: new InMemoryCache(), - link: new MockLink(mocks), - }); + const observable = client.watchQuery({ query, fetchPolicy }); - const observable = client.watchQuery({ query }); + const stream = new ObservableStream(observable); - const stream = new ObservableStream(observable); + const { data } = await stream.takeNext(); - { - const { data } = await stream.takeNext(); + expect(data).toEqual({ + currentUser: { + __typename: "User", + id: 1, + name: "Test User", + }, + }); + } + ); - expect(data).toEqual({ + test("masks cache and network result when using cache-and-network fetch policy", async () => { + interface Query { currentUser: { - __typename: "User", - id: 1, - name: "Test User", - age: 30, + __typename: "User"; + id: number; + name: string; + }; + } + + const query: TypedDocumentNode = 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 (server)", + age: 35, + }, + }, + }, + delay: 20, + }, + ]; + + const client = new ApolloClient({ + dataMasking: true, + cache: new InMemoryCache(), + link: new MockLink(mocks), + }); + + client.writeQuery({ + query, + data: { + currentUser: { + __typename: "User", + id: 1, + name: "Test User", + // @ts-expect-error TODO: Determine how to write this with masked types + age: 34, + }, }, }); - } -}); -test("does not mask fragments marked with @unmask", async () => { - interface Query { - currentUser: { - __typename: "User"; - id: number; - name: string; - }; - } + const observable = client.watchQuery({ + query, + fetchPolicy: "cache-and-network", + }); - const query: TypedDocumentNode = gql` - query UnmaskedQuery { - currentUser { - id - name - ...UserFields @unmask - } + const stream = new ObservableStream(observable); + + { + const { data } = await stream.takeNext(); + + expect(data).toEqual({ + currentUser: { + __typename: "User", + id: 1, + name: "Test User", + }, + }); + } + + { + const { data } = await stream.takeNext(); + + expect(data).toEqual({ + currentUser: { + __typename: "User", + id: 1, + name: "Test User (server)", + }, + }); } + }); - fragment UserFields on User { - age + test("masks partial cache data when returnPartialData is `true`", async () => { + interface Query { + currentUser: { + __typename: "User"; + id: number; + name: string; + }; } - `; - const mocks = [ + const query: TypedDocumentNode = 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 (server)", + age: 35, + }, + }, + }, + delay: 20, + }, + ]; + + const client = new ApolloClient({ + dataMasking: true, + cache: new InMemoryCache(), + link: new MockLink(mocks), + }); + { - request: { query }, - result: { + // Silence warning about writing partial data + using _ = spyOnConsole("error"); + + client.writeQuery({ + query, data: { currentUser: { __typename: "User", id: 1, - name: "Test User", - age: 30, + // @ts-expect-error TODO: Determine how to write this with masked types + age: 34, }, }, - }, - }, - ]; + }); + } - const client = new ApolloClient({ - dataMasking: true, - cache: new InMemoryCache(), - link: new MockLink(mocks), - }); + const observable = client.watchQuery({ + query, + returnPartialData: true, + }); - const observable = client.watchQuery({ query }); + const stream = new ObservableStream(observable); - const stream = new ObservableStream(observable); + { + const { data } = await stream.takeNext(); - { - const { data } = await stream.takeNext(); + expect(data).toEqual({ + currentUser: { + __typename: "User", + id: 1, + }, + }); + } - expect(data).toEqual({ + { + const { data } = await stream.takeNext(); + + expect(data).toEqual({ + currentUser: { + __typename: "User", + id: 1, + name: "Test User (server)", + }, + }); + } + }); + + test("masks partial data returned from data on errors with errorPolicy `all`", async () => { + interface Query { currentUser: { - __typename: "User", - id: 1, - name: "Test User", - age: 30, - }, - }); - } -}); + __typename: "User"; + id: number; + name: string; + }; + } + + const query: TypedDocumentNode = gql` + query MaskedQuery { + currentUser { + id + name + ...UserFields + } + } + + fragment UserFields on User { + age + } + `; -test("does not mask fragments marked with @unmask added by document transforms", async () => { - const documentTransform = new DocumentTransform((document) => { - return visit(document, { - FragmentSpread(node) { - return { - ...node, - directives: [ - { - kind: Kind.DIRECTIVE, - name: { kind: Kind.NAME, value: "unmask" }, + const mocks = [ + { + request: { query }, + result: { + data: { + currentUser: { + __typename: "User", + id: 1, + name: null, + age: 34, }, - ], - } satisfies FragmentSpreadNode; + }, + errors: [{ message: "Couldn't get name" }], + }, + delay: 20, }, - }); - }); + ]; - interface Query { - currentUser: { - __typename: "User"; - id: number; - name: string; - }; - } + const client = new ApolloClient({ + dataMasking: true, + cache: new InMemoryCache(), + link: new MockLink(mocks), + }); - const query: TypedDocumentNode = gql` - query UnmaskedQuery { - currentUser { - id - name - ...UserFields - } - } + const observable = client.watchQuery({ query, errorPolicy: "all" }); - fragment UserFields on User { - age - } - `; + const stream = new ObservableStream(observable); - const mocks = [ { - request: { query }, - result: { - data: { - currentUser: { - __typename: "User", - id: 1, - name: "Test User", - age: 30, - }, + const { data, errors } = await stream.takeNext(); + + expect(data).toEqual({ + currentUser: { + __typename: "User", + id: 1, + name: null, }, - }, - }, - ]; - - const client = new ApolloClient({ - dataMasking: true, - cache: new InMemoryCache(), - link: new MockLink(mocks), - documentTransform, + }); + + expect(errors).toEqual([{ message: "Couldn't get name" }]); + } }); - const observable = client.watchQuery({ query }); + it.each([ + "cache-first", + "network-only", + "cache-and-network", + ] as FetchPolicy[])( + "masks result returned from getCurrentResult when using %s fetchPolicy", + async (fetchPolicy) => { + interface Query { + currentUser: { + __typename: "User"; + id: number; + name: string; + }; + } - const stream = new ObservableStream(observable); + const query: TypedDocumentNode = gql` + query MaskedQuery { + currentUser { + id + name + ...UserFields + } + } - { - const { data } = await stream.takeNext(); + fragment UserFields on User { + age + } + `; + + const mocks = [ + { + request: { query }, + result: { + data: { + currentUser: { + __typename: "User", + id: 1, + name: "Test User", + age: 34, + }, + }, + }, + delay: 20, + }, + ]; - expect(data).toEqual({ - currentUser: { - __typename: "User", - id: 1, - name: "Test User", - age: 30, - }, - }); - } -}); + const client = new ApolloClient({ + dataMasking: true, + cache: new InMemoryCache(), + link: new MockLink(mocks), + }); -test("does not mask query when using a cache that does not support it", async () => { - using _ = spyOnConsole("warn"); + const observable = client.watchQuery({ query, fetchPolicy }); + const stream = new ObservableStream(observable); - interface Query { - currentUser: { - __typename: "User"; - id: number; - name: string; - }; - } + { + const { data } = await stream.takeNext(); - const query: TypedDocumentNode = gql` - query MaskedQuery { - currentUser { - id - name - ...UserFields + expect(data).toEqual({ + currentUser: { + __typename: "User", + id: 1, + name: "Test User", + }, + }); } - } - fragment UserFields on User { - age - } - `; + { + const { data } = observable.getCurrentResult(false); - const mocks = [ - { - request: { query }, - result: { - data: { + expect(data).toEqual({ currentUser: { __typename: "User", id: 1, name: "Test User", - age: 30, }, - }, - }, - }, - ]; - - const client = new ApolloClient({ - dataMasking: true, - cache: new TestCache(), - link: new MockLink(mocks), - }); - - const observable = client.watchQuery({ query }); - - const stream = new ObservableStream(observable); + }); + } + } + ); - { - const { data } = await stream.takeNext(); + test("warns when accessing a unmasked field while using @unmask with mode: 'migrate'", async () => { + using consoleSpy = spyOnConsole("warn"); - expect(data).toEqual({ + interface Query { currentUser: { - __typename: "User", - id: 1, - name: "Test User", - age: 30, - }, - }); - } - - expect(console.warn).toHaveBeenCalledTimes(1); - expect(console.warn).toHaveBeenCalledWith( - expect.stringContaining( - "The configured cache does not support data masking" - ) - ); -}); + __typename: "User"; + id: number; + name: string; + age: number; + }; + } -test("masks queries updated by the cache", async () => { - interface Query { - currentUser: { - __typename: "User"; - id: number; - name: string; - }; - } + const query: TypedDocumentNode = gql` + query UnmaskedQuery { + currentUser { + id + name + ...UserFields @unmask(mode: "migrate") + } + } - const query: TypedDocumentNode = gql` - query MaskedQuery { - currentUser { - id + fragment UserFields on User { + age name - ...UserFields } - } - - fragment UserFields on User { - age - } - `; + `; - const mocks = [ - { - request: { query }, - result: { - data: { - currentUser: { - __typename: "User", - id: 1, - name: "Test User", - age: 30, + const mocks = [ + { + request: { query }, + result: { + data: { + currentUser: { + __typename: "User", + id: 1, + name: "Test User", + age: 34, + }, }, }, + delay: 20, }, - }, - ]; + ]; - const client = new ApolloClient({ - dataMasking: true, - cache: new InMemoryCache(), - link: new MockLink(mocks), - }); + const client = new ApolloClient({ + dataMasking: true, + cache: new InMemoryCache(), + link: new MockLink(mocks), + }); - const observable = client.watchQuery({ query }); + const observable = client.watchQuery({ query }); + const stream = new ObservableStream(observable); - const stream = new ObservableStream(observable); + { + const { data } = await stream.takeNext(); + data.currentUser.__typename; + data.currentUser.id; + data.currentUser.name; - { - const { data } = await stream.takeNext(); + expect(consoleSpy.warn).not.toHaveBeenCalled(); - expect(data).toEqual({ - currentUser: { - __typename: "User", - id: 1, - name: "Test User", - }, - }); - } + data.currentUser.age; - client.writeQuery({ - query, - data: { - currentUser: { - __typename: "User", - id: 1, - name: "Test User (updated)", - // @ts-ignore TODO: Determine how to handle cache writes with masked - // query type - age: 35, - }, - }, - }); + expect(consoleSpy.warn).toHaveBeenCalledTimes(1); + expect(consoleSpy.warn).toHaveBeenCalledWith( + "Accessing unmasked field on %s at path '%s'. This field will not be available when masking is enabled. Please read the field from the fragment instead.", + "query 'UnmaskedQuery'", + "currentUser.age" + ); - { - const { data } = await stream.takeNext(); + // Ensure we only warn once + data.currentUser.age; + expect(consoleSpy.warn).toHaveBeenCalledTimes(1); + } + }); - expect(data).toEqual({ + test("reads fragment by passing parent object to `from`", async () => { + interface Query { currentUser: { - __typename: "User", - id: 1, - name: "Test User (updated)", - }, - }); - } -}); + __typename: "User"; + id: number; + name: string; + }; + } -test("does not trigger update when updating field in named fragment", async () => { - interface Query { - currentUser: { - __typename: "User"; - id: number; - name: string; - }; - } + interface Fragment { + age: number; + } - const query: TypedDocumentNode = gql` - query MaskedQuery { - currentUser { - id - name - ...UserFields + const fragment: TypedDocumentNode = gql` + fragment UserFields on User { + age } - } + `; - fragment UserFields on User { - age - } - `; + const query: TypedDocumentNode = gql` + query MaskedQuery { + currentUser { + id + name + ...UserFields + } + } - const mocks = [ - { - request: { query }, - result: { - data: { - currentUser: { - __typename: "User", - id: 1, - name: "Test User", - age: 30, + ${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 observable = client.watchQuery({ query }); + ]; - const stream = new ObservableStream(observable); + const client = new ApolloClient({ + dataMasking: true, + cache: new InMemoryCache(), + link: new MockLink(mocks), + }); - { - const { data } = await stream.takeNext(); + const queryStream = new ObservableStream(client.watchQuery({ query })); - expect(data).toEqual({ - currentUser: { - __typename: "User", - id: 1, - name: "Test User", - }, + const { data } = await queryStream.takeNext(); + const fragmentObservable = client.watchFragment({ + fragment, + from: data.currentUser, }); - } - client.writeQuery({ - query, - data: { - currentUser: { - __typename: "User", - id: 1, - name: "Test User", - // @ts-ignore TODO: Determine how to handle cache writes with masked - // query type - age: 35, - }, - }, - }); + const fragmentStream = new ObservableStream(fragmentObservable); - await expect(stream.takeNext()).rejects.toThrow( - new Error("Timeout waiting for next event") - ); + { + const { data, complete } = await fragmentStream.takeNext(); - expect(client.readQuery({ query })).toEqual({ - currentUser: { - __typename: "User", - id: 1, - name: "Test User", - age: 35, - }, + expect(complete).toBe(true); + expect(data).toEqual({ __typename: "User", age: 30 }); + } }); -}); -it.each(["cache-first", "cache-only"] as FetchPolicy[])( - "masks result from cache when using with %s fetch policy", - async (fetchPolicy) => { + test("warns when passing parent object to `from` when id is masked", async () => { + using _ = spyOnConsole("warn"); + interface Query { currentUser: { __typename: "User"; @@ -608,18 +1165,26 @@ it.each(["cache-first", "cache-only"] as FetchPolicy[])( }; } + interface Fragment { + age: number; + } + + const fragment: TypedDocumentNode = gql` + fragment UserFields on User { + id + age + } + `; + const query: TypedDocumentNode = gql` query MaskedQuery { currentUser { - id name ...UserFields } } - fragment UserFields on User { - age - } + ${fragment} `; const mocks = [ @@ -644,1656 +1209,1412 @@ it.each(["cache-first", "cache-only"] as FetchPolicy[])( link: new MockLink(mocks), }); - client.writeQuery({ - query, - data: { - currentUser: { - __typename: "User", - id: 1, - name: "Test User", - // @ts-expect-error TODO: Determine how to write this with masked types - age: 30, - }, - }, + const queryStream = new ObservableStream(client.watchQuery({ query })); + + const { data } = await queryStream.takeNext(); + const fragmentObservable = client.watchFragment({ + fragment, + from: data.currentUser, }); - const observable = client.watchQuery({ query, fetchPolicy }); + 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 stream = new ObservableStream(observable); + const fragmentStream = new ObservableStream(fragmentObservable); - const { data } = await stream.takeNext(); + { + const { data, complete } = await fragmentStream.takeNext(); - expect(data).toEqual({ - currentUser: { - __typename: "User", - id: 1, - name: "Test User", - }, - }); - } -); + expect(data).toEqual({}); + // TODO: Update when https://github.com/apollographql/apollo-client/issues/12003 is fixed + expect(complete).toBe(true); + } + }); -test("masks cache and network result when using cache-and-network fetch policy", async () => { - interface Query { - currentUser: { - __typename: "User"; - id: number; - name: string; - }; - } + test("warns when passing parent object to `from` that is non-normalized", async () => { + using _ = spyOnConsole("warn"); - const query: TypedDocumentNode = gql` - query MaskedQuery { - currentUser { - id - name - ...UserFields - } + interface Query { + currentUser: { + __typename: "User"; + name: string; + }; } - fragment UserFields on User { - age + interface Fragment { + age: number; } - `; - const mocks = [ - { - request: { query }, - result: { - data: { - currentUser: { - __typename: "User", - id: 1, - name: "Test User (server)", - age: 35, + 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, + }, }, }, }, - delay: 20, - }, - ]; - - const client = new ApolloClient({ - dataMasking: true, - cache: new InMemoryCache(), - link: new MockLink(mocks), - }); - - client.writeQuery({ - query, - data: { - currentUser: { - __typename: "User", - id: 1, - name: "Test User", - // @ts-expect-error TODO: Determine how to write this with masked types - age: 34, - }, - }, - }); - - const observable = client.watchQuery({ - query, - fetchPolicy: "cache-and-network", - }); + ]; - const stream = new ObservableStream(observable); + const client = new ApolloClient({ + dataMasking: true, + cache: new InMemoryCache(), + link: new MockLink(mocks), + }); - { - const { data } = await stream.takeNext(); + const queryStream = new ObservableStream(client.watchQuery({ query })); - expect(data).toEqual({ - currentUser: { - __typename: "User", - id: 1, - name: "Test User", - }, + const { data } = await queryStream.takeNext(); + const fragmentObservable = client.watchFragment({ + fragment, + from: data.currentUser, }); - } - { - const { data } = await stream.takeNext(); + 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" + ); - expect(data).toEqual({ - currentUser: { - __typename: "User", - id: 1, - name: "Test User (server)", - }, - }); - } -}); + const fragmentStream = new ObservableStream(fragmentObservable); -test("masks partial cache data when returnPartialData is `true`", async () => { - interface Query { - currentUser: { - __typename: "User"; - id: number; - name: string; - }; - } + { + const { data, complete } = await fragmentStream.takeNext(); - const query: TypedDocumentNode = gql` - query MaskedQuery { - currentUser { - id - name - ...UserFields - } + expect(data).toEqual({}); + // TODO: Update when https://github.com/apollographql/apollo-client/issues/12003 is fixed + expect(complete).toBe(true); } + }); - fragment UserFields on User { - age + test("can lookup unmasked fragments from the fragment registry in queries", async () => { + const fragments = createFragmentRegistry(); + + interface Query { + currentUser: { + __typename: "User"; + id: number; + name: string; + age: number; + }; } - `; - const mocks = [ - { - request: { query }, - result: { - data: { - currentUser: { - __typename: "User", - id: 1, - name: "Test User (server)", - age: 35, + const query: TypedDocumentNode = gql` + query MaskedQuery { + currentUser { + id + name + ...UserFields @unmask + } + } + `; + + fragments.register(gql` + fragment UserFields on User { + age + } + `); + + const client = new ApolloClient({ + dataMasking: true, + cache: new InMemoryCache({ fragments }), + link: new ApolloLink(() => { + return Observable.of({ + data: { + currentUser: { + __typename: "User", + id: 1, + name: "Test User", + age: 30, + }, }, - }, - }, - delay: 20, - }, - ]; - - const client = new ApolloClient({ - dataMasking: true, - cache: new InMemoryCache(), - link: new MockLink(mocks), - }); + }); + }), + }); - { - // Silence warning about writing partial data - using _ = spyOnConsole("error"); + const stream = new ObservableStream(client.watchQuery({ query })); - client.writeQuery({ - query, - data: { + { + const { data } = await stream.takeNext(); + + expect(data).toEqual({ currentUser: { __typename: "User", id: 1, - // @ts-expect-error TODO: Determine how to write this with masked types - age: 34, + name: "Test User", + age: 30, }, - }, - }); - } - - const observable = client.watchQuery({ - query, - returnPartialData: true, + }); + } }); - - const stream = new ObservableStream(observable); - - { - const { data } = await stream.takeNext(); - - expect(data).toEqual({ - currentUser: { - __typename: "User", - id: 1, - }, - }); - } - - { - const { data } = await stream.takeNext(); - - expect(data).toEqual({ - currentUser: { - __typename: "User", - id: 1, - name: "Test User (server)", - }, - }); - } }); -test("masks partial data returned from data on errors with errorPolicy `all`", async () => { - interface Query { - currentUser: { +describe("client.watchFragment", () => { + test("masks watched fragments when dataMasking is `true`", async () => { + type UserFieldsFragment = { __typename: "User"; id: number; - name: string; + age: number; }; - } - - const query: TypedDocumentNode = gql` - query MaskedQuery { - currentUser { - id - name - ...UserFields - } - } - fragment UserFields on User { - age - } - `; + type NameFieldsFragment = { + __typename: "User"; + firstName: string; + lastName: string; + }; - const mocks = [ - { - request: { query }, - result: { - data: { - currentUser: { - __typename: "User", - id: 1, - name: null, - age: 34, - }, - }, - errors: [{ message: "Couldn't get name" }], - }, - delay: 20, - }, - ]; - - const client = new ApolloClient({ - dataMasking: true, - cache: new InMemoryCache(), - link: new MockLink(mocks), - }); + const nameFieldsFragment: TypedDocumentNode = gql` + fragment NameFields on User { + firstName + lastName + } + `; - const observable = client.watchQuery({ query, errorPolicy: "all" }); + const userFieldsFragment: TypedDocumentNode = gql` + fragment UserFields on User { + id + age + ...NameFields + } - const stream = new ObservableStream(observable); + ${nameFieldsFragment} + `; - { - const { data, errors } = await stream.takeNext(); + const client = new ApolloClient({ + dataMasking: true, + cache: new InMemoryCache(), + }); - expect(data).toEqual({ - currentUser: { + client.writeFragment({ + fragment: userFieldsFragment, + fragmentName: "UserFields", + data: { __typename: "User", id: 1, - name: null, + age: 30, + // @ts-expect-error Need to determine types when writing data for masked fragment types + firstName: "Test", + lastName: "User", }, }); - expect(errors).toEqual([{ message: "Couldn't get name" }]); - } -}); + const fragmentStream = new ObservableStream( + client.watchFragment({ + fragment: userFieldsFragment, + fragmentName: "UserFields", + from: { __typename: "User", id: 1 }, + }) + ); -it.each(["cache-first", "network-only", "cache-and-network"] as FetchPolicy[])( - "masks result returned from getCurrentResult when using %s fetchPolicy", - async (fetchPolicy) => { - interface Query { - currentUser: { - __typename: "User"; - id: number; - name: string; - }; + 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", + }); } + }); - const query: TypedDocumentNode = gql` - query MaskedQuery { - currentUser { - id - name - ...UserFields - } + test("does not mask watched fragments when dataMasking is disabled", async () => { + type UserFieldsFragment = { + __typename: "User"; + id: number; + age: number; + firstName: string; + lastName: string; + }; + + type NameFieldsFragment = { + __typename: "User"; + firstName: string; + lastName: string; + }; + + const nameFieldsFragment: TypedDocumentNode = gql` + fragment NameFields on User { + __typename + firstName + lastName } + `; + const userFieldsFragment: TypedDocumentNode = gql` fragment UserFields on User { + __typename + id age + ...NameFields } - `; - const mocks = [ - { - request: { query }, - result: { - data: { - currentUser: { - __typename: "User", - id: 1, - name: "Test User", - age: 34, - }, - }, - }, - delay: 20, - }, - ]; + ${nameFieldsFragment} + `; const client = new ApolloClient({ - dataMasking: true, + dataMasking: false, cache: new InMemoryCache(), - link: new MockLink(mocks), }); - const observable = client.watchQuery({ query, fetchPolicy }); - const stream = new ObservableStream(observable); + client.writeFragment({ + fragment: userFieldsFragment, + fragmentName: "UserFields", + data: { + __typename: "User", + id: 1, + age: 30, + firstName: "Test", + lastName: "User", + }, + }); - { - const { data } = await stream.takeNext(); + const fragmentStream = new ObservableStream( + client.watchFragment({ + fragment: userFieldsFragment, + fragmentName: "UserFields", + from: { __typename: "User", id: 1 }, + }) + ); - expect(data).toEqual({ - currentUser: { - __typename: "User", - id: 1, - name: "Test User", - }, - }); - } + 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 } = observable.getCurrentResult(false); + const { data, complete } = await nestedFragmentStream.takeNext(); + expect(complete).toBe(true); expect(data).toEqual({ - currentUser: { - __typename: "User", - id: 1, - name: "Test User", - }, + __typename: "User", + firstName: "Test", + lastName: "User", }); } - } -); - -test("warns when accessing a unmasked field while using @unmask with mode: 'migrate'", async () => { - using consoleSpy = spyOnConsole("warn"); + }); - interface Query { - currentUser: { + test("does not mask watched fragments by default", async () => { + type UserFieldsFragment = { __typename: "User"; id: number; - name: string; age: number; + firstName: string; + lastName: string; }; - } - const query: TypedDocumentNode = gql` - query UnmaskedQuery { - currentUser { + type NameFieldsFragment = { + __typename: "User"; + firstName: string; + lastName: string; + }; + + const nameFieldsFragment: TypedDocumentNode = gql` + fragment NameFields on User { + __typename + firstName + lastName + } + `; + + const userFieldsFragment: TypedDocumentNode = gql` + fragment UserFields on User { + __typename id - name - ...UserFields @unmask(mode: "migrate") + age + ...NameFields } - } - fragment UserFields on User { - age - name - } - `; + ${nameFieldsFragment} + `; - const mocks = [ - { - request: { query }, - result: { - data: { - currentUser: { - __typename: "User", - id: 1, - name: "Test User", - age: 34, - }, - }, - }, - delay: 20, - }, - ]; - - const client = new ApolloClient({ - dataMasking: true, - cache: new InMemoryCache(), - link: new MockLink(mocks), - }); + const client = new ApolloClient({ + cache: new InMemoryCache(), + }); - const observable = client.watchQuery({ query }); - const stream = new ObservableStream(observable); + client.writeFragment({ + fragment: userFieldsFragment, + fragmentName: "UserFields", + data: { + __typename: "User", + id: 1, + age: 30, + firstName: "Test", + lastName: "User", + }, + }); - { - const { data } = await stream.takeNext(); - data.currentUser.__typename; - data.currentUser.id; - data.currentUser.name; + const fragmentStream = new ObservableStream( + client.watchFragment({ + fragment: userFieldsFragment, + fragmentName: "UserFields", + from: { __typename: "User", id: 1 }, + }) + ); - expect(consoleSpy.warn).not.toHaveBeenCalled(); + const { data, complete } = await fragmentStream.takeNext(); - data.currentUser.age; + expect(data).toEqual({ + __typename: "User", + id: 1, + age: 30, + firstName: "Test", + lastName: "User", + }); + expect(complete).toBe(true); + invariant(complete, "Should never be incomplete"); - expect(consoleSpy.warn).toHaveBeenCalledTimes(1); - expect(consoleSpy.warn).toHaveBeenCalledWith( - "Accessing unmasked field on %s at path '%s'. This field will not be available when masking is enabled. Please read the field from the fragment instead.", - "query 'UnmaskedQuery'", - "currentUser.age" + const nestedFragmentStream = new ObservableStream( + client.watchFragment({ fragment: nameFieldsFragment, from: data }) ); - // Ensure we only warn once - data.currentUser.age; - expect(consoleSpy.warn).toHaveBeenCalledTimes(1); - } -}); + { + const { data, complete } = await nestedFragmentStream.takeNext(); + + expect(complete).toBe(true); + expect(data).toEqual({ + __typename: "User", + firstName: "Test", + lastName: "User", + }); + } + }); -test("reads fragment by passing parent object to `from`", async () => { - interface Query { - currentUser: { + test("does not mask watched fragments marked with @unmask", async () => { + interface Fragment { __typename: "User"; id: number; name: string; - }; - } - - interface Fragment { - age: number; - } - - const fragment: TypedDocumentNode = gql` - fragment UserFields on User { - age + age: number; } - `; - const query: TypedDocumentNode = gql` - query MaskedQuery { - currentUser { + const fragment: TypedDocumentNode = gql` + fragment UserFields on User { id name - ...UserFields + ...ProfileFields @unmask } - } - ${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), - }); + fragment ProfileFields on User { + age + } + `; - const queryStream = new ObservableStream(client.watchQuery({ query })); + const client = new ApolloClient({ + dataMasking: true, + cache: new InMemoryCache(), + }); - const { data } = await queryStream.takeNext(); - const fragmentObservable = client.watchFragment({ - fragment, - from: data.currentUser, - }); + client.writeFragment({ + fragment, + fragmentName: "UserFields", + data: { + __typename: "User", + id: 1, + name: "Test User", + age: 30, + }, + }); - const fragmentStream = new ObservableStream(fragmentObservable); + const observable = client.watchFragment({ + fragment, + fragmentName: "UserFields", + from: { __typename: "User", id: 1 }, + }); - { - const { data, complete } = await fragmentStream.takeNext(); + const stream = new ObservableStream(observable); - expect(complete).toBe(true); - expect(data).toEqual({ __typename: "User", age: 30 }); - } -}); + { + const { data } = await stream.takeNext(); -test("warns when passing parent object to `from` when id is masked", async () => { - using _ = spyOnConsole("warn"); + expect(data).toEqual({ + __typename: "User", + id: 1, + name: "Test User", + age: 30, + }); + } + }); - interface Query { - currentUser: { + test("masks watched fragments updated by the cache", async () => { + interface Fragment { __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 { + const fragment: TypedDocumentNode = gql` + fragment UserFields on User { + id name - ...UserFields + ...ProfileFields } - } - ${fragment} - `; + fragment ProfileFields 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(), + }); - const client = new ApolloClient({ - dataMasking: true, - cache: new InMemoryCache(), - link: new MockLink(mocks), - }); + client.writeFragment({ + fragment, + fragmentName: "UserFields", + data: { + __typename: "User", + id: 1, + name: "Test User", + // @ts-expect-error Need to determine how to handle masked fragment types with writes + age: 30, + }, + }); - const queryStream = new ObservableStream(client.watchQuery({ query })); + const observable = client.watchFragment({ + fragment, + fragmentName: "UserFields", + from: { __typename: "User", id: 1 }, + }); - const { data } = await queryStream.takeNext(); - const fragmentObservable = client.watchFragment({ - fragment, - from: data.currentUser, - }); + const stream = new ObservableStream(observable); - 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 } = await stream.takeNext(); - const fragmentStream = new ObservableStream(fragmentObservable); + expect(data).toEqual({ + __typename: "User", + id: 1, + name: "Test User", + }); + } - { - const { data, complete } = await fragmentStream.takeNext(); + client.writeFragment({ + fragment, + fragmentName: "UserFields", + data: { + __typename: "User", + id: 1, + name: "Test User (updated)", + // @ts-ignore TODO: Determine how to handle cache writes with masked + // query type + age: 35, + }, + }); - expect(data).toEqual({}); - // TODO: Update when https://github.com/apollographql/apollo-client/issues/12003 is fixed - expect(complete).toBe(true); - } -}); + { + const { data } = await stream.takeNext(); -test("warns when passing parent object to `from` that is non-normalized", async () => { - using _ = spyOnConsole("warn"); + expect(data).toEqual({ + __typename: "User", + id: 1, + name: "Test User (updated)", + }); + } + }); - interface Query { - currentUser: { + test("does not trigger update on watched fragment when updating field in named fragment", async () => { + interface Fragment { __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 { + const fragment: TypedDocumentNode = gql` + fragment UserFields on User { + id name - ...UserFields + ...ProfileFields } - } - - ${fragment} - `; - const mocks = [ - { - request: { query }, - result: { - data: { - currentUser: { - __typename: "User", - name: "Test User", - age: 30, - }, - }, - }, - }, - ]; + fragment ProfileFields on User { + age + } + `; - const client = new ApolloClient({ - dataMasking: true, - cache: new InMemoryCache(), - link: new MockLink(mocks), - }); + const client = new ApolloClient({ + dataMasking: true, + cache: new InMemoryCache(), + }); - const queryStream = new ObservableStream(client.watchQuery({ query })); + client.writeFragment({ + fragment, + fragmentName: "UserFields", + data: { + __typename: "User", + id: 1, + name: "Test User", + // @ts-ignore TODO: Determine how to handle cache writes with masking + age: 30, + }, + }); - const { data } = await queryStream.takeNext(); - const fragmentObservable = client.watchFragment({ - fragment, - from: data.currentUser, - }); + const observable = client.watchFragment({ + fragment, + fragmentName: "UserFields", + from: { __typename: "User", id: 1 }, + }); + const stream = new ObservableStream(observable); - 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 } = await stream.takeNext(); - const fragmentStream = new ObservableStream(fragmentObservable); + expect(data).toEqual({ + __typename: "User", + id: 1, + name: "Test User", + }); + } - { - const { data, complete } = await fragmentStream.takeNext(); + client.writeFragment({ + fragment, + fragmentName: "UserFields", + data: { + __typename: "User", + id: 1, + name: "Test User", + // @ts-ignore TODO: Determine how to handle cache writes with masking + age: 35, + }, + }); - expect(data).toEqual({}); - // TODO: Update when https://github.com/apollographql/apollo-client/issues/12003 is fixed - expect(complete).toBe(true); - } -}); + await expect(stream.takeNext()).rejects.toThrow( + new Error("Timeout waiting for next event") + ); -test("can lookup unmasked fragments from the fragment registry in queries", async () => { - const fragments = createFragmentRegistry(); + expect( + client.readFragment({ + fragment, + fragmentName: "UserFields", + id: "User:1", + }) + ).toEqual({ + __typename: "User", + id: 1, + name: "Test User", + age: 35, + }); + }); - interface Query { - currentUser: { + test("triggers update to child watched fragment when updating field in named fragment", async () => { + interface UserFieldsFragment { __typename: "User"; id: number; name: string; - age: number; - }; - } - - const query: TypedDocumentNode = gql` - query MaskedQuery { - currentUser { - id - name - ...UserFields @unmask - } } - `; - fragments.register(gql` - fragment UserFields on User { - age + interface ProfileFieldsFragment { + __typename: "User"; + age: number; } - `); - const client = new ApolloClient({ - dataMasking: true, - cache: new InMemoryCache({ fragments }), - link: new ApolloLink(() => { - return Observable.of({ - data: { - currentUser: { - __typename: "User", - id: 1, - name: "Test User", - age: 30, - }, - }, - }); - }), - }); + const profileFieldsFragment: TypedDocumentNode< + ProfileFieldsFragment, + never + > = gql` + fragment ProfileFields on User { + age + } + `; - const stream = new ObservableStream(client.watchQuery({ query })); + const userFieldsFragment: TypedDocumentNode = + gql` + fragment UserFields on User { + id + name + ...ProfileFields + } - { - const { data } = await stream.takeNext(); + ${profileFieldsFragment} + `; - expect(data).toEqual({ - currentUser: { + const client = new ApolloClient({ + dataMasking: true, + cache: new InMemoryCache(), + }); + + client.writeFragment({ + fragment: userFieldsFragment, + fragmentName: "UserFields", + data: { __typename: "User", id: 1, name: "Test User", + // @ts-ignore TODO: Determine how to handle cache writes with masking age: 30, }, }); - } -}); -test("masks watched fragments when dataMasking is `true`", async () => { - type UserFieldsFragment = { - __typename: "User"; - id: number; - age: number; - }; + const userFieldsObservable = client.watchFragment({ + fragment: userFieldsFragment, + fragmentName: "UserFields", + from: { __typename: "User", id: 1 }, + }); - type NameFieldsFragment = { - __typename: "User"; - firstName: string; - lastName: string; - }; + const nameFieldsObservable = client.watchFragment({ + fragment: profileFieldsFragment, + from: { __typename: "User", id: 1 }, + }); - const nameFieldsFragment: TypedDocumentNode = gql` - fragment NameFields on User { - firstName - lastName - } - `; + const userFieldsStream = new ObservableStream(userFieldsObservable); + const nameFieldsStream = new ObservableStream(nameFieldsObservable); - const userFieldsFragment: TypedDocumentNode = gql` - fragment UserFields on User { - id - age - ...NameFields - } + { + const { data } = await userFieldsStream.takeNext(); - ${nameFieldsFragment} - `; + expect(data).toEqual({ + __typename: "User", + id: 1, + name: "Test User", + }); + } - const client = new ApolloClient({ - dataMasking: true, - cache: new InMemoryCache(), - }); + { + const { data } = await nameFieldsStream.takeNext(); - client.writeFragment({ - fragment: userFieldsFragment, - fragmentName: "UserFields", - data: { - __typename: "User", - id: 1, - age: 30, - // @ts-expect-error Need to determine types when writing data for masked fragment types - firstName: "Test", - lastName: "User", - }, - }); + expect(data).toEqual({ + __typename: "User", + age: 30, + }); + } - const fragmentStream = new ObservableStream( - client.watchFragment({ + client.writeFragment({ 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 }) - ); + data: { + __typename: "User", + id: 1, + name: "Test User", + // @ts-ignore TODO: Determine how to handle cache writes with masking + age: 35, + }, + }); - { - const { data, complete } = await nestedFragmentStream.takeNext(); + { + const { data } = await nameFieldsStream.takeNext(); - expect(complete).toBe(true); - expect(data).toEqual({ - __typename: "User", - firstName: "Test", - lastName: "User", - }); - } -}); + expect(data).toEqual({ + __typename: "User", + age: 35, + }); + } -test("does not mask watched fragments when dataMasking is disabled", async () => { - type UserFieldsFragment = { - __typename: "User"; - id: number; - age: number; - firstName: string; - lastName: string; - }; - - type NameFieldsFragment = { - __typename: "User"; - firstName: string; - lastName: string; - }; - - 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(), - }); + await expect(userFieldsStream.takeNext()).rejects.toThrow( + new Error("Timeout waiting for next event") + ); - client.writeFragment({ - fragment: userFieldsFragment, - fragmentName: "UserFields", - data: { + expect( + client.readFragment({ + fragment: userFieldsFragment, + fragmentName: "UserFields", + id: "User:1", + }) + ).toEqual({ __typename: "User", id: 1, - age: 30, - firstName: "Test", - lastName: "User", - }, + name: "Test User", + age: 35, + }); }); - const fragmentStream = new ObservableStream( - client.watchFragment({ - fragment: userFieldsFragment, - fragmentName: "UserFields", - from: { __typename: "User", id: 1 }, - }) - ); + test("does not trigger update to watched fragments when updating field in named fragment with @nonreactive", async () => { + interface UserFieldsFragment { + __typename: "User"; + id: number; + lastUpdatedAt: string; + } - const { data, complete } = await fragmentStream.takeNext(); + interface ProfileFieldsFragment { + __typename: "User"; + lastUpdatedAt: string; + } - expect(data).toEqual({ - __typename: "User", - id: 1, - age: 30, - firstName: "Test", - lastName: "User", - }); - expect(complete).toBe(true); - invariant(complete, "Should never be incomplete"); + const profileFieldsFragment: TypedDocumentNode< + ProfileFieldsFragment, + never + > = gql` + fragment ProfileFields on User { + age + lastUpdatedAt @nonreactive + } + `; - const nestedFragmentStream = new ObservableStream( - client.watchFragment({ fragment: nameFieldsFragment, from: data }) - ); + const userFieldsFragment: TypedDocumentNode = + gql` + fragment UserFields on User { + id + lastUpdatedAt @nonreactive + ...ProfileFields + } - { - const { data, complete } = await nestedFragmentStream.takeNext(); + ${profileFieldsFragment} + `; - expect(complete).toBe(true); - expect(data).toEqual({ - __typename: "User", - firstName: "Test", - lastName: "User", + const client = new ApolloClient({ + dataMasking: true, + cache: new InMemoryCache(), }); - } -}); - -test("does not mask watched fragments by default", async () => { - type UserFieldsFragment = { - __typename: "User"; - id: number; - age: number; - firstName: string; - lastName: string; - }; - - type NameFieldsFragment = { - __typename: "User"; - firstName: string; - lastName: string; - }; - - 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", - }, - }); + client.writeFragment({ + fragment: userFieldsFragment, + fragmentName: "UserFields", + data: { + __typename: "User", + id: 1, + lastUpdatedAt: "2024-01-01", + // @ts-ignore TODO: Determine how to handle cache writes with masking + age: 30, + }, + }); - const fragmentStream = new ObservableStream( - client.watchFragment({ + const userFieldsObservable = 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", + const profileFieldsObservable = client.watchFragment({ + fragment: profileFieldsFragment, + from: { __typename: "User", id: 1 }, }); - } -}); -test("does not mask watched fragments marked with @unmask", async () => { - interface Fragment { - __typename: "User"; - id: number; - name: string; - age: number; - } + const userFieldsStream = new ObservableStream(userFieldsObservable); + const profileFieldsStream = new ObservableStream(profileFieldsObservable); - const fragment: TypedDocumentNode = gql` - fragment UserFields on User { - id - name - ...ProfileFields @unmask - } + { + const { data } = await userFieldsStream.takeNext(); - fragment ProfileFields on User { - age + expect(data).toEqual({ + __typename: "User", + id: 1, + lastUpdatedAt: "2024-01-01", + }); } - `; - - const client = new ApolloClient({ - dataMasking: true, - cache: new InMemoryCache(), - }); - client.writeFragment({ - fragment, - fragmentName: "UserFields", - data: { - __typename: "User", - id: 1, - name: "Test User", - age: 30, - }, - }); + { + const { data } = await profileFieldsStream.takeNext(); - const observable = client.watchFragment({ - fragment, - fragmentName: "UserFields", - from: { __typename: "User", id: 1 }, - }); + expect(data).toEqual({ + __typename: "User", + age: 30, + lastUpdatedAt: "2024-01-01", + }); + } - const stream = new ObservableStream(observable); + client.writeFragment({ + fragment: userFieldsFragment, + fragmentName: "UserFields", + data: { + __typename: "User", + id: 1, + lastUpdatedAt: "2024-01-02", + // @ts-ignore TODO: Determine how to handle cache writes with masking + age: 30, + }, + }); - { - const { data } = await stream.takeNext(); + await expect(userFieldsStream.takeNext()).rejects.toThrow( + new Error("Timeout waiting for next event") + ); + await expect(profileFieldsStream.takeNext()).rejects.toThrow( + new Error("Timeout waiting for next event") + ); - expect(data).toEqual({ + expect( + client.readFragment({ + fragment: userFieldsFragment, + fragmentName: "UserFields", + id: "User:1", + }) + ).toEqual({ __typename: "User", id: 1, - name: "Test User", + lastUpdatedAt: "2024-01-02", age: 30, }); - } -}); - -test("masks watched fragments updated by the cache", async () => { - interface Fragment { - __typename: "User"; - id: number; - name: string; - } + }); - const fragment: TypedDocumentNode = gql` - fragment UserFields on User { - id - name - ...ProfileFields + test("does not trigger update to watched fragments when updating parent field with @nonreactive and child field", async () => { + interface UserFieldsFragment { + __typename: "User"; + id: number; + lastUpdatedAt: string; } - fragment ProfileFields on User { - age + interface ProfileFieldsFragment { + __typename: "User"; + lastUpdatedAt: string; } - `; - - const client = new ApolloClient({ - dataMasking: true, - cache: new InMemoryCache(), - }); - - client.writeFragment({ - fragment, - fragmentName: "UserFields", - data: { - __typename: "User", - id: 1, - name: "Test User", - // @ts-expect-error Need to determine how to handle masked fragment types with writes - age: 30, - }, - }); - const observable = client.watchFragment({ - fragment, - fragmentName: "UserFields", - from: { __typename: "User", id: 1 }, - }); + const profileFieldsFragment: TypedDocumentNode< + ProfileFieldsFragment, + never + > = gql` + fragment ProfileFields on User { + age + lastUpdatedAt @nonreactive + } + `; - const stream = new ObservableStream(observable); + const userFieldsFragment: TypedDocumentNode = + gql` + fragment UserFields on User { + id + lastUpdatedAt @nonreactive + ...ProfileFields + } - { - const { data } = await stream.takeNext(); + ${profileFieldsFragment} + `; - expect(data).toEqual({ - __typename: "User", - id: 1, - name: "Test User", + const client = new ApolloClient({ + dataMasking: true, + cache: new InMemoryCache(), }); - } - client.writeFragment({ - fragment, - fragmentName: "UserFields", - data: { - __typename: "User", - id: 1, - name: "Test User (updated)", - // @ts-ignore TODO: Determine how to handle cache writes with masked - // query type - age: 35, - }, - }); + client.writeFragment({ + fragment: userFieldsFragment, + fragmentName: "UserFields", + data: { + __typename: "User", + id: 1, + lastUpdatedAt: "2024-01-01", + // @ts-ignore TODO: Determine how to handle cache writes with masking + age: 30, + }, + }); - { - const { data } = await stream.takeNext(); + const userFieldsObservable = client.watchFragment({ + fragment: userFieldsFragment, + fragmentName: "UserFields", + from: { __typename: "User", id: 1 }, + }); - expect(data).toEqual({ - __typename: "User", - id: 1, - name: "Test User (updated)", + const profileFieldsObservable = client.watchFragment({ + fragment: profileFieldsFragment, + from: { __typename: "User", id: 1 }, }); - } -}); -test("does not trigger update on watched fragment when updating field in named fragment", async () => { - interface Fragment { - __typename: "User"; - id: number; - name: string; - } + const userFieldsStream = new ObservableStream(userFieldsObservable); + const profileFieldsStream = new ObservableStream(profileFieldsObservable); + + { + const { data } = await userFieldsStream.takeNext(); - const fragment: TypedDocumentNode = gql` - fragment UserFields on User { - id - name - ...ProfileFields + expect(data).toEqual({ + __typename: "User", + id: 1, + lastUpdatedAt: "2024-01-01", + }); } - fragment ProfileFields on User { - age + { + const { data } = await profileFieldsStream.takeNext(); + + expect(data).toEqual({ + __typename: "User", + age: 30, + lastUpdatedAt: "2024-01-01", + }); } - `; - const client = new ApolloClient({ - dataMasking: true, - cache: new InMemoryCache(), - }); + client.writeFragment({ + fragment: userFieldsFragment, + fragmentName: "UserFields", + data: { + __typename: "User", + id: 1, + lastUpdatedAt: "2024-01-02", + // @ts-ignore TODO: Determine how to handle cache writes with masking + age: 31, + }, + }); - client.writeFragment({ - fragment, - fragmentName: "UserFields", - data: { - __typename: "User", - id: 1, - name: "Test User", - // @ts-ignore TODO: Determine how to handle cache writes with masking - age: 30, - }, - }); + { + const { data } = await profileFieldsStream.takeNext(); - const observable = client.watchFragment({ - fragment, - fragmentName: "UserFields", - from: { __typename: "User", id: 1 }, - }); - const stream = new ObservableStream(observable); + expect(data).toEqual({ + __typename: "User", + age: 31, + lastUpdatedAt: "2024-01-02", + }); + } - { - const { data } = await stream.takeNext(); + await expect(userFieldsStream.takeNext()).rejects.toThrow( + new Error("Timeout waiting for next event") + ); - expect(data).toEqual({ + expect( + client.readFragment({ + fragment: userFieldsFragment, + fragmentName: "UserFields", + id: "User:1", + }) + ).toEqual({ __typename: "User", id: 1, - name: "Test User", + lastUpdatedAt: "2024-01-02", + age: 31, }); - } - - client.writeFragment({ - fragment, - fragmentName: "UserFields", - data: { - __typename: "User", - id: 1, - name: "Test User", - // @ts-ignore TODO: Determine how to handle cache writes with masking - age: 35, - }, }); - await expect(stream.takeNext()).rejects.toThrow( - new Error("Timeout waiting for next event") - ); - - expect( - client.readFragment({ fragment, fragmentName: "UserFields", id: "User:1" }) - ).toEqual({ - __typename: "User", - id: 1, - name: "Test User", - age: 35, - }); -}); + test("warns when accessing an unmasked field on a watched fragment while using @unmask with mode: 'migrate'", async () => { + using consoleSpy = spyOnConsole("warn"); -test("triggers update to child watched fragment when updating field in named fragment", async () => { - interface UserFieldsFragment { - __typename: "User"; - id: number; - name: string; - } + interface Fragment { + __typename: "User"; + id: number; + name: string; + age: number; + } - interface ProfileFieldsFragment { - __typename: "User"; - age: number; - } + const fragment: TypedDocumentNode = gql` + fragment UserFields on User { + id + name + ...ProfileFields @unmask(mode: "migrate") + } - const profileFieldsFragment: TypedDocumentNode = - gql` fragment ProfileFields on User { age + name } `; - const userFieldsFragment: TypedDocumentNode = gql` - fragment UserFields on User { - id - name - ...ProfileFields - } + const client = new ApolloClient({ + dataMasking: true, + cache: new InMemoryCache(), + }); - ${profileFieldsFragment} - `; + const observable = client.watchFragment({ + fragment, + fragmentName: "UserFields", + from: { __typename: "User", id: 1 }, + }); + const stream = new ObservableStream(observable); - const client = new ApolloClient({ - dataMasking: true, - cache: new InMemoryCache(), - }); + { + const { data } = await stream.takeNext(); + data.__typename; + data.id; + data.name; - client.writeFragment({ - fragment: userFieldsFragment, - fragmentName: "UserFields", - data: { - __typename: "User", - id: 1, - name: "Test User", - // @ts-ignore TODO: Determine how to handle cache writes with masking - age: 30, - }, - }); + expect(consoleSpy.warn).not.toHaveBeenCalled(); - const userFieldsObservable = client.watchFragment({ - fragment: userFieldsFragment, - fragmentName: "UserFields", - from: { __typename: "User", id: 1 }, - }); + data.age; + + expect(consoleSpy.warn).toHaveBeenCalledTimes(1); + expect(consoleSpy.warn).toHaveBeenCalledWith( + "Accessing unmasked field on %s at path '%s'. This field will not be available when masking is enabled. Please read the field from the fragment instead.", + "fragment 'UserFields'", + "age" + ); - const nameFieldsObservable = client.watchFragment({ - fragment: profileFieldsFragment, - from: { __typename: "User", id: 1 }, + // Ensure we only warn once + data.age; + expect(consoleSpy.warn).toHaveBeenCalledTimes(1); + } }); - const userFieldsStream = new ObservableStream(userFieldsObservable); - const nameFieldsStream = new ObservableStream(nameFieldsObservable); + test("can lookup unmasked fragments from the fragment registry in watched fragments", async () => { + const fragments = createFragmentRegistry(); - { - const { data } = await userFieldsStream.takeNext(); + const profileFieldsFragment = gql` + fragment ProfileFields on User { + age + } + `; - expect(data).toEqual({ - __typename: "User", - id: 1, - name: "Test User", - }); - } + const userFieldsFragment = gql` + fragment UserFields on User { + id + ...ProfileFields @unmask + } + `; - { - const { data } = await nameFieldsStream.takeNext(); + fragments.register(profileFieldsFragment); - expect(data).toEqual({ - __typename: "User", - age: 30, + const client = new ApolloClient({ + dataMasking: true, + cache: new InMemoryCache({ fragments }), }); - } - - client.writeFragment({ - fragment: userFieldsFragment, - fragmentName: "UserFields", - data: { - __typename: "User", - id: 1, - name: "Test User", - // @ts-ignore TODO: Determine how to handle cache writes with masking - age: 35, - }, - }); - - { - const { data } = await nameFieldsStream.takeNext(); - expect(data).toEqual({ - __typename: "User", - age: 35, + client.writeFragment({ + fragment: userFieldsFragment, + fragmentName: "UserFields", + data: { + __typename: "User", + id: 1, + age: 30, + }, }); - } - - await expect(userFieldsStream.takeNext()).rejects.toThrow( - new Error("Timeout waiting for next event") - ); - expect( - client.readFragment({ + const observable = client.watchFragment({ fragment: userFieldsFragment, fragmentName: "UserFields", - id: "User:1", - }) - ).toEqual({ - __typename: "User", - id: 1, - name: "Test User", - age: 35, - }); -}); - -test("does not trigger update to watched fragments when updating field in named fragment with @nonreactive", async () => { - interface UserFieldsFragment { - __typename: "User"; - id: number; - lastUpdatedAt: string; - } + from: { __typename: "User", id: 1 }, + }); - interface ProfileFieldsFragment { - __typename: "User"; - lastUpdatedAt: string; - } + const stream = new ObservableStream(observable); - const profileFieldsFragment: TypedDocumentNode = - gql` - fragment ProfileFields on User { - age - lastUpdatedAt @nonreactive - } - `; + { + const result = await stream.takeNext(); - const userFieldsFragment: TypedDocumentNode = gql` - fragment UserFields on User { - id - lastUpdatedAt @nonreactive - ...ProfileFields + expect(result).toEqual({ + data: { + __typename: "User", + id: 1, + age: 30, + }, + complete: true, + }); } - - ${profileFieldsFragment} - `; - - const client = new ApolloClient({ - dataMasking: true, - cache: new InMemoryCache(), - }); - - client.writeFragment({ - fragment: userFieldsFragment, - fragmentName: "UserFields", - data: { - __typename: "User", - id: 1, - lastUpdatedAt: "2024-01-01", - // @ts-ignore TODO: Determine how to handle cache writes with masking - age: 30, - }, }); +}); - const userFieldsObservable = client.watchFragment({ - fragment: userFieldsFragment, - fragmentName: "UserFields", - from: { __typename: "User", id: 1 }, - }); +describe("client.query", () => { + test("masks data returned from client.query when dataMasking is `true`", async () => { + interface Query { + currentUser: { + __typename: "User"; + id: number; + name: string; + }; + } - const profileFieldsObservable = client.watchFragment({ - fragment: profileFieldsFragment, - from: { __typename: "User", id: 1 }, - }); + const query: TypedDocumentNode = gql` + query MaskedQuery { + currentUser { + id + name + ...UserFields + } + } - const userFieldsStream = new ObservableStream(userFieldsObservable); - const profileFieldsStream = new ObservableStream(profileFieldsObservable); + fragment UserFields on User { + age + } + `; - { - const { data } = await userFieldsStream.takeNext(); + const mocks = [ + { + request: { query }, + result: { + data: { + currentUser: { + __typename: "User", + id: 1, + name: "Test User", + age: 30, + }, + }, + }, + }, + ]; - expect(data).toEqual({ - __typename: "User", - id: 1, - lastUpdatedAt: "2024-01-01", + const client = new ApolloClient({ + dataMasking: true, + cache: new InMemoryCache(), + link: new MockLink(mocks), }); - } - { - const { data } = await profileFieldsStream.takeNext(); + const { data } = await client.query({ query }); expect(data).toEqual({ - __typename: "User", - age: 30, - lastUpdatedAt: "2024-01-01", + currentUser: { + __typename: "User", + id: 1, + name: "Test User", + }, }); - } - - client.writeFragment({ - fragment: userFieldsFragment, - fragmentName: "UserFields", - data: { - __typename: "User", - id: 1, - lastUpdatedAt: "2024-01-02", - // @ts-ignore TODO: Determine how to handle cache writes with masking - age: 30, - }, - }); - - await expect(userFieldsStream.takeNext()).rejects.toThrow( - new Error("Timeout waiting for next event") - ); - await expect(profileFieldsStream.takeNext()).rejects.toThrow( - new Error("Timeout waiting for next event") - ); - - expect( - client.readFragment({ - fragment: userFieldsFragment, - fragmentName: "UserFields", - id: "User:1", - }) - ).toEqual({ - __typename: "User", - id: 1, - lastUpdatedAt: "2024-01-02", - age: 30, }); -}); -test("does not trigger update to watched fragments when updating parent field with @nonreactive and child field", async () => { - interface UserFieldsFragment { - __typename: "User"; - id: number; - lastUpdatedAt: string; - } + test("does not mask data returned from client.query when dataMasking is `false`", async () => { + interface Query { + currentUser: { + __typename: "User"; + id: number; + name: string; + }; + } - interface ProfileFieldsFragment { - __typename: "User"; - lastUpdatedAt: string; - } + const query: TypedDocumentNode = gql` + query MaskedQuery { + currentUser { + id + name + ...UserFields + } + } - const profileFieldsFragment: TypedDocumentNode = - gql` - fragment ProfileFields on User { + fragment UserFields on User { age - lastUpdatedAt @nonreactive } `; - const userFieldsFragment: TypedDocumentNode = gql` - fragment UserFields on User { - id - lastUpdatedAt @nonreactive - ...ProfileFields - } - - ${profileFieldsFragment} - `; - - const client = new ApolloClient({ - dataMasking: true, - cache: new InMemoryCache(), - }); + const mocks = [ + { + request: { query }, + result: { + data: { + currentUser: { + __typename: "User", + id: 1, + name: "Test User", + age: 30, + }, + }, + }, + }, + ]; - client.writeFragment({ - fragment: userFieldsFragment, - fragmentName: "UserFields", - data: { - __typename: "User", - id: 1, - lastUpdatedAt: "2024-01-01", - // @ts-ignore TODO: Determine how to handle cache writes with masking - age: 30, - }, - }); + const client = new ApolloClient({ + dataMasking: false, + cache: new InMemoryCache(), + link: new MockLink(mocks), + }); - const userFieldsObservable = client.watchFragment({ - fragment: userFieldsFragment, - fragmentName: "UserFields", - from: { __typename: "User", id: 1 }, - }); + const { data } = await client.query({ query }); - const profileFieldsObservable = client.watchFragment({ - fragment: profileFieldsFragment, - from: { __typename: "User", id: 1 }, + expect(data).toEqual({ + currentUser: { + __typename: "User", + id: 1, + name: "Test User", + age: 30, + }, + }); }); - const userFieldsStream = new ObservableStream(userFieldsObservable); - const profileFieldsStream = new ObservableStream(profileFieldsObservable); + test("does not mask data returned from client.query by default", async () => { + interface Query { + currentUser: { + __typename: "User"; + id: number; + name: string; + }; + } - { - const { data } = await userFieldsStream.takeNext(); + const query: TypedDocumentNode = gql` + query MaskedQuery { + currentUser { + id + name + ...UserFields + } + } - expect(data).toEqual({ - __typename: "User", - id: 1, - lastUpdatedAt: "2024-01-01", - }); - } + fragment UserFields on User { + age + } + `; - { - const { data } = await profileFieldsStream.takeNext(); + const mocks = [ + { + request: { query }, + result: { + data: { + currentUser: { + __typename: "User", + id: 1, + name: "Test User", + age: 30, + }, + }, + }, + }, + ]; - expect(data).toEqual({ - __typename: "User", - age: 30, - lastUpdatedAt: "2024-01-01", + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: new MockLink(mocks), }); - } - client.writeFragment({ - fragment: userFieldsFragment, - fragmentName: "UserFields", - data: { - __typename: "User", - id: 1, - lastUpdatedAt: "2024-01-02", - // @ts-ignore TODO: Determine how to handle cache writes with masking - age: 31, - }, - }); - - { - const { data } = await profileFieldsStream.takeNext(); + const { data } = await client.query({ query }); expect(data).toEqual({ - __typename: "User", - age: 31, - lastUpdatedAt: "2024-01-02", + currentUser: { + __typename: "User", + id: 1, + name: "Test User", + age: 30, + }, }); - } - - await expect(userFieldsStream.takeNext()).rejects.toThrow( - new Error("Timeout waiting for next event") - ); - - expect( - client.readFragment({ - fragment: userFieldsFragment, - fragmentName: "UserFields", - id: "User:1", - }) - ).toEqual({ - __typename: "User", - id: 1, - lastUpdatedAt: "2024-01-02", - age: 31, }); -}); -test("warns when accessing an unmasked field on a watched fragment while using @unmask with mode: 'migrate'", async () => { - using consoleSpy = spyOnConsole("warn"); - - interface Fragment { - __typename: "User"; - id: number; - name: string; - age: number; - } + test("handles errors returned when using errorPolicy `none`", async () => { + const query = gql` + query MaskedQuery { + currentUser { + id + name + ...UserFields + } + } - const fragment: TypedDocumentNode = gql` - fragment UserFields on User { - id - name - ...ProfileFields @unmask(mode: "migrate") - } + fragment UserFields on User { + age + } + `; - fragment ProfileFields on User { - age - name - } - `; + const mocks = [ + { + request: { query }, + result: { + errors: [{ message: "User not logged in" }], + }, + }, + ]; - const client = new ApolloClient({ - dataMasking: true, - cache: new InMemoryCache(), - }); + const client = new ApolloClient({ + dataMasking: true, + cache: new InMemoryCache(), + link: new MockLink(mocks), + }); - const observable = client.watchFragment({ - fragment, - fragmentName: "UserFields", - from: { __typename: "User", id: 1 }, + await expect(client.query({ query, errorPolicy: "none" })).rejects.toEqual( + new ApolloError({ + graphQLErrors: [{ message: "User not logged in" }], + }) + ); }); - const stream = new ObservableStream(observable); - - { - const { data } = await stream.takeNext(); - data.__typename; - data.id; - data.name; - expect(consoleSpy.warn).not.toHaveBeenCalled(); - - data.age; - - expect(consoleSpy.warn).toHaveBeenCalledTimes(1); - expect(consoleSpy.warn).toHaveBeenCalledWith( - "Accessing unmasked field on %s at path '%s'. This field will not be available when masking is enabled. Please read the field from the fragment instead.", - "fragment 'UserFields'", - "age" - ); + test("handles errors returned when using errorPolicy `all`", async () => { + const query = gql` + query MaskedQuery { + currentUser { + id + name + ...UserFields + } + } - // Ensure we only warn once - data.age; - expect(consoleSpy.warn).toHaveBeenCalledTimes(1); - } -}); + fragment UserFields on User { + age + } + `; -test("can lookup unmasked fragments from the fragment registry in watched fragments", async () => { - const fragments = createFragmentRegistry(); + const mocks = [ + { + request: { query }, + result: { + data: { currentUser: null }, + errors: [{ message: "User not logged in" }], + }, + }, + ]; - const profileFieldsFragment = gql` - fragment ProfileFields on User { - age - } - `; + const client = new ApolloClient({ + dataMasking: true, + cache: new InMemoryCache(), + link: new MockLink(mocks), + }); - const userFieldsFragment = gql` - fragment UserFields on User { - id - ...ProfileFields @unmask - } - `; + const { data, errors } = await client.query({ query, errorPolicy: "all" }); - fragments.register(profileFieldsFragment); + expect(data).toEqual({ + currentUser: null, + }); - const client = new ApolloClient({ - dataMasking: true, - cache: new InMemoryCache({ fragments }), + expect(errors).toEqual([{ message: "User not logged in" }]); }); - client.writeFragment({ - fragment: userFieldsFragment, - fragmentName: "UserFields", - data: { - __typename: "User", - id: 1, - age: 30, - }, - }); + test("masks fragment data in fields nulled by errors when using errorPolicy `all`", async () => { + const query = gql` + query MaskedQuery { + currentUser { + id + name + ...UserFields + } + } - const observable = client.watchFragment({ - fragment: userFieldsFragment, - fragmentName: "UserFields", - from: { __typename: "User", id: 1 }, - }); + 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 stream = new ObservableStream(observable); + const client = new ApolloClient({ + dataMasking: true, + cache: new InMemoryCache(), + link: new MockLink(mocks), + }); - { - const result = await stream.takeNext(); + const { data, errors } = await client.query({ query, errorPolicy: "all" }); - expect(result).toEqual({ - data: { + expect(data).toEqual({ + currentUser: { __typename: "User", id: 1, - age: 30, + name: "Test User", }, - complete: true, }); - } + + expect(errors).toEqual([{ message: "Could not determine age" }]); + }); }); class TestCache extends ApolloCache { diff --git a/src/core/QueryManager.ts b/src/core/QueryManager.ts index 07e32e94abe..23d1ddd78c8 100644 --- a/src/core/QueryManager.ts +++ b/src/core/QueryManager.ts @@ -803,7 +803,18 @@ export class QueryManager { return this.fetchQuery(queryId, { ...options, query: this.transform(options.query), - }).finally(() => this.stopQuery(queryId)); + }) + .then((result) => { + if (result) { + result.data = this.maskOperation({ + document: options.query, + data: result.data, + }); + } + + return result; + }) + .finally(() => this.stopQuery(queryId)); } private queryIdCounter = 1;