From 784106410f955744e72d2ddc99877cde82644ebd Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Fri, 28 Jun 2024 11:26:24 -0600 Subject: [PATCH 001/103] Add failing test for unmasked directive --- src/__tests__/client.ts | 63 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/src/__tests__/client.ts b/src/__tests__/client.ts index a8d943c9262..b8965b62f95 100644 --- a/src/__tests__/client.ts +++ b/src/__tests__/client.ts @@ -6599,6 +6599,69 @@ describe("data masking", () => { } }); + it("does not mask queries marked with @unmasked", async () => { + interface Query { + currentUser: { + __typename: "User"; + id: number; + name: string; + }; + } + + const query: TypedDocumentNode = gql` + query UnmaskedQuery @unmask { + 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", + age: 30, + }, + }); + } + }); + it("does not mask query when using a cache that does not support it", async () => { using _ = spyOnConsole("warn"); From 0105531ea3fbe3b062394bc8d49933513931fb56 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Fri, 28 Jun 2024 11:27:24 -0600 Subject: [PATCH 002/103] Don't send unmasked directive to server --- src/core/QueryManager.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/core/QueryManager.ts b/src/core/QueryManager.ts index 78ba9084778..cb80e2bf49f 100644 --- a/src/core/QueryManager.ts +++ b/src/core/QueryManager.ts @@ -694,6 +694,7 @@ export class QueryManager { { name: "client", remove: true }, { name: "connection" }, { name: "nonreactive" }, + { name: "unmasked" }, ], document ), From b76b4acc2c000f5e69223a86528a4a2b20e2fcd1 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Fri, 28 Jun 2024 11:29:26 -0600 Subject: [PATCH 003/103] Stub isUnmaskedDocument function --- src/utilities/graphql/directives.ts | 4 ++++ src/utilities/index.ts | 1 + 2 files changed, 5 insertions(+) diff --git a/src/utilities/graphql/directives.ts b/src/utilities/graphql/directives.ts index 797823f00f1..bb871da2815 100644 --- a/src/utilities/graphql/directives.ts +++ b/src/utilities/graphql/directives.ts @@ -133,3 +133,7 @@ export function getInclusionDirectives( return result; } + +export function isUnmaskedDocument(document: DocumentNode) { + return false; +} diff --git a/src/utilities/index.ts b/src/utilities/index.ts index 01530ca7881..c3c62e8d292 100644 --- a/src/utilities/index.ts +++ b/src/utilities/index.ts @@ -12,6 +12,7 @@ export { hasClientExports, getDirectiveNames, getInclusionDirectives, + isUnmaskedDocument, } from "./graphql/directives.js"; export type { DocumentTransformCacheKey } from "./graphql/DocumentTransform.js"; From 7640ffa364d605b78c92e9fbedfa05754742fda3 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Fri, 28 Jun 2024 11:33:39 -0600 Subject: [PATCH 004/103] Don't mask when unmasked directive is present --- src/core/ObservableQuery.ts | 5 ++++- src/core/QueryManager.ts | 3 +++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/core/ObservableQuery.ts b/src/core/ObservableQuery.ts index d63f47b724a..b05aeb3ba1b 100644 --- a/src/core/ObservableQuery.ts +++ b/src/core/ObservableQuery.ts @@ -1066,8 +1066,11 @@ Did you mean to call refetch(variables) instead of refetch({ variables })?`, private maskQuery(data: TData) { const { queryManager } = this; + const shouldMask = + queryManager.dataMasking && + !queryManager.getDocumentInfo(this.query).isUnmasked; - return queryManager.dataMasking ? + return shouldMask ? queryManager.cache.maskDocument(this.query, data) : data; } diff --git a/src/core/QueryManager.ts b/src/core/QueryManager.ts index cb80e2bf49f..8ddbe269c7a 100644 --- a/src/core/QueryManager.ts +++ b/src/core/QueryManager.ts @@ -26,6 +26,7 @@ import { getOperationDefinition, getOperationName, hasClientExports, + isUnmaskedDocument, graphQLResultHasError, getGraphQLErrorsFromResult, Observable, @@ -95,6 +96,7 @@ interface TransformCacheEntry { hasClientExports: boolean; hasForcedResolvers: boolean; hasNonreactiveDirective: boolean; + isUnmasked: boolean; clientQuery: DocumentNode | null; serverQuery: DocumentNode | null; defaultVars: OperationVariables; @@ -688,6 +690,7 @@ export class QueryManager { hasClientExports: hasClientExports(document), hasForcedResolvers: this.localState.shouldForceResolvers(document), hasNonreactiveDirective: hasDirectives(["nonreactive"], document), + isUnmasked: isUnmaskedDocument(document), clientQuery: this.localState.clientQuery(document), serverQuery: removeDirectivesFromDocument( [ From 0706cd2906fbd41b32b29639acb0ae6471fd749e Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Fri, 28 Jun 2024 11:48:59 -0600 Subject: [PATCH 005/103] Add failing tests for checking unmasked directive --- src/utilities/graphql/__tests__/directives.ts | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) diff --git a/src/utilities/graphql/__tests__/directives.ts b/src/utilities/graphql/__tests__/directives.ts index 2e1d891754c..2b5291b48db 100644 --- a/src/utilities/graphql/__tests__/directives.ts +++ b/src/utilities/graphql/__tests__/directives.ts @@ -7,7 +7,9 @@ import { hasDirectives, hasAnyDirectives, hasAllDirectives, + isUnmaskedDocument, } from "../directives"; +import { spyOnConsole } from "../../../testing/internal"; describe("hasDirectives", () => { it("should allow searching the ast for a directive", () => { @@ -512,3 +514,91 @@ describe("shouldInclude", () => { }).toThrow(); }); }); + +describe("isUnmaskedDocument", () => { + it("returns true when @unmasked used on document", () => { + const query = gql` + query MyQuery @unmasked { + myField + } + `; + + expect(isUnmaskedDocument(query)).toBe(true); + }); + + it("returns false when @unmasked is not used", () => { + const query = gql` + query MyQuery { + myField + } + `; + + expect(isUnmaskedDocument(query)).toBe(false); + }); + + it("returns false when @unmasked is used in a location other than the document root", () => { + using _ = spyOnConsole("warn"); + + const query = gql` + query MyQuery($id: ID! @unmasked) { + foo @unmasked + bar(arg: true) { + ... @unmasked { + baz + } + ...MyFragment @unmasked + } + } + `; + + expect(isUnmaskedDocument(query)).toBe(false); + }); + + it("warns when using @unmasked directive in a location other than the document root", () => { + using consoleSpy = spyOnConsole("warn"); + + const query = gql` + query MyQuery($id: ID! @unmasked) { + foo @unmasked + bar(arg: true) { + ... @unmasked { + baz + } + ...MyFragment @unmasked + } + } + `; + + const result = isUnmaskedDocument(query); + + expect(result).toBe(false); + expect(consoleSpy.warn).toHaveBeenCalledTimes(1); + expect(consoleSpy.warn).toHaveBeenCalledWith( + "@unmasked directive is used in a location other than the document root which has no effect." + ); + }); + + it("warns when using @unmasked directive a location other than the document root while also using @unmasked at the root", () => { + using consoleSpy = spyOnConsole("warn"); + + const query = gql` + query MyQuery($id: ID! @unmasked) @unmasked { + foo @unmasked + bar(arg: true) { + ... @unmasked { + baz + } + ...MyFragment @unmasked + } + } + `; + + const result = isUnmaskedDocument(query); + + expect(result).toBe(true); + expect(consoleSpy.warn).toHaveBeenCalledTimes(1); + expect(consoleSpy.warn).toHaveBeenCalledWith( + "@unmasked directive is used in a location other than the document root." + ); + }); +}); From 27b79f52c4fa075739ae089cad2648844e349f69 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Fri, 28 Jun 2024 12:21:35 -0600 Subject: [PATCH 006/103] Implement check for unmasked directive --- src/utilities/graphql/directives.ts | 46 ++++++++++++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/src/utilities/graphql/directives.ts b/src/utilities/graphql/directives.ts index bb871da2815..e3c7f263545 100644 --- a/src/utilities/graphql/directives.ts +++ b/src/utilities/graphql/directives.ts @@ -135,5 +135,49 @@ export function getInclusionDirectives( } export function isUnmaskedDocument(document: DocumentNode) { - return false; + let unmasked = false; + + visit(document, { + OperationDefinition(node) { + if (node.directives) { + unmasked = node.directives.some( + (directive) => directive.name.value === "unmasked" + ); + } + + if (__DEV__) { + // Allow us to continue traversal in development to warn if we detect + // the unmasked directive anywhere else in the document. + return; + } + + return BREAK; + }, + Directive(node, _, __, ___, ancestors) { + if (__DEV__) { + if (node.name.value !== "unmasked") { + return; + } + + const parent = ancestors[ancestors.length - 1]; + + // Make sure we aren't checking the `unmasked` directive defined on + // the operation, which we don't want to warn on. + if ( + Array.isArray(parent) || + (parent as ASTNode).kind !== "OperationDefinition" + ) { + invariant.warn( + unmasked ? + "@unmasked directive is used in a location other than the document root." + : "@unmasked directive is used in a location other than the document root which has no effect." + ); + + return BREAK; + } + } + }, + }); + + return unmasked; } From 8c5165fc65ea54bbcb5f9495bd008df28b7e7492 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Fri, 28 Jun 2024 12:38:07 -0600 Subject: [PATCH 007/103] Show query name in warning --- src/utilities/graphql/__tests__/directives.ts | 6 ++++-- src/utilities/graphql/directives.ts | 8 ++++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/utilities/graphql/__tests__/directives.ts b/src/utilities/graphql/__tests__/directives.ts index 2b5291b48db..9912f051c2b 100644 --- a/src/utilities/graphql/__tests__/directives.ts +++ b/src/utilities/graphql/__tests__/directives.ts @@ -574,7 +574,8 @@ describe("isUnmaskedDocument", () => { expect(result).toBe(false); expect(consoleSpy.warn).toHaveBeenCalledTimes(1); expect(consoleSpy.warn).toHaveBeenCalledWith( - "@unmasked directive is used in a location other than the document root which has no effect." + "%s@unmasked directive is used in a location other than the document root which has no effect.", + "'MyQuery': " ); }); @@ -598,7 +599,8 @@ describe("isUnmaskedDocument", () => { expect(result).toBe(true); expect(consoleSpy.warn).toHaveBeenCalledTimes(1); expect(consoleSpy.warn).toHaveBeenCalledWith( - "@unmasked directive is used in a location other than the document root." + "%s@unmasked directive is used in a location other than the document root.", + "'MyQuery': " ); }); }); diff --git a/src/utilities/graphql/directives.ts b/src/utilities/graphql/directives.ts index e3c7f263545..ccbd944bfe3 100644 --- a/src/utilities/graphql/directives.ts +++ b/src/utilities/graphql/directives.ts @@ -136,9 +136,12 @@ export function getInclusionDirectives( export function isUnmaskedDocument(document: DocumentNode) { let unmasked = false; + let operationName: string | undefined; visit(document, { OperationDefinition(node) { + operationName = node.name?.value; + if (node.directives) { unmasked = node.directives.some( (directive) => directive.name.value === "unmasked" @@ -169,8 +172,9 @@ export function isUnmaskedDocument(document: DocumentNode) { ) { invariant.warn( unmasked ? - "@unmasked directive is used in a location other than the document root." - : "@unmasked directive is used in a location other than the document root which has no effect." + "%s@unmasked directive is used in a location other than the document root." + : "%s@unmasked directive is used in a location other than the document root which has no effect.", + operationName ? `'${operationName}': ` : "" ); return BREAK; From 9c3ff6b9e6fb7b909d0d8b0e6c0db606ecbb02b4 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Fri, 28 Jun 2024 12:45:10 -0600 Subject: [PATCH 008/103] Tweak warning message --- src/utilities/graphql/__tests__/directives.ts | 4 ++-- src/utilities/graphql/directives.ts | 6 ++---- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/utilities/graphql/__tests__/directives.ts b/src/utilities/graphql/__tests__/directives.ts index 9912f051c2b..0e89dd31673 100644 --- a/src/utilities/graphql/__tests__/directives.ts +++ b/src/utilities/graphql/__tests__/directives.ts @@ -574,7 +574,7 @@ describe("isUnmaskedDocument", () => { expect(result).toBe(false); expect(consoleSpy.warn).toHaveBeenCalledTimes(1); expect(consoleSpy.warn).toHaveBeenCalledWith( - "%s@unmasked directive is used in a location other than the document root which has no effect.", + "@unmasked directive used in %s is provided in a location other than the document root which is ignored.", "'MyQuery': " ); }); @@ -599,7 +599,7 @@ describe("isUnmaskedDocument", () => { expect(result).toBe(true); expect(consoleSpy.warn).toHaveBeenCalledTimes(1); expect(consoleSpy.warn).toHaveBeenCalledWith( - "%s@unmasked directive is used in a location other than the document root.", + "@unmasked directive used in %s is provided in a location other than the document root which is ignored.", "'MyQuery': " ); }); diff --git a/src/utilities/graphql/directives.ts b/src/utilities/graphql/directives.ts index ccbd944bfe3..b2cc6dcebf9 100644 --- a/src/utilities/graphql/directives.ts +++ b/src/utilities/graphql/directives.ts @@ -171,10 +171,8 @@ export function isUnmaskedDocument(document: DocumentNode) { (parent as ASTNode).kind !== "OperationDefinition" ) { invariant.warn( - unmasked ? - "%s@unmasked directive is used in a location other than the document root." - : "%s@unmasked directive is used in a location other than the document root which has no effect.", - operationName ? `'${operationName}': ` : "" + "@unmasked directive used in %s is provided in a location other than the document root which is ignored.", + operationName ? `'${operationName}': ` : "anonymous operation" ); return BREAK; From ea6147bb72023858703e69c501f78020f1bcd283 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Fri, 28 Jun 2024 12:46:35 -0600 Subject: [PATCH 009/103] Add additional comment --- src/utilities/graphql/directives.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/utilities/graphql/directives.ts b/src/utilities/graphql/directives.ts index b2cc6dcebf9..bf629fcc5d7 100644 --- a/src/utilities/graphql/directives.ts +++ b/src/utilities/graphql/directives.ts @@ -175,6 +175,8 @@ export function isUnmaskedDocument(document: DocumentNode) { operationName ? `'${operationName}': ` : "anonymous operation" ); + // We only want to warn once if we detect misused of @unmasked so we + // immediately stop traversal. return BREAK; } } From 7b0bc63e7bfe02e84d98e28a42c58f9e686e46d1 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Fri, 28 Jun 2024 12:51:09 -0600 Subject: [PATCH 010/103] Remove unmasked directive in mock link --- src/testing/core/mocking/mockLink.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/testing/core/mocking/mockLink.ts b/src/testing/core/mocking/mockLink.ts index f38474e1c20..159b38b2ec5 100644 --- a/src/testing/core/mocking/mockLink.ts +++ b/src/testing/core/mocking/mockLink.ts @@ -211,7 +211,7 @@ ${unmatchedVars.map((d) => ` ${stringifyForDisplay(d)}`).join("\n")} ): MockedResponse { const newMockedResponse = cloneDeep(mockedResponse); const queryWithoutClientOnlyDirectives = removeDirectivesFromDocument( - [{ name: "connection" }, { name: "nonreactive" }], + [{ name: "connection" }, { name: "nonreactive" }, { name: "unmasked" }], checkDocument(newMockedResponse.request.query) ); invariant(queryWithoutClientOnlyDirectives, "query is required"); From 7d22dbee184efb039ebe6b6d06f926f8db9aa03a Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Fri, 28 Jun 2024 12:51:34 -0600 Subject: [PATCH 011/103] Fix reference to wrong directive name --- src/__tests__/client.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/__tests__/client.ts b/src/__tests__/client.ts index b8965b62f95..3436b6d8b7b 100644 --- a/src/__tests__/client.ts +++ b/src/__tests__/client.ts @@ -6609,7 +6609,7 @@ describe("data masking", () => { } const query: TypedDocumentNode = gql` - query UnmaskedQuery @unmask { + query UnmaskedQuery @unmasked { currentUser { id name From 862d1addd6d7dbc41df7248c05f6cc6f55b5afa2 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Fri, 28 Jun 2024 12:55:14 -0600 Subject: [PATCH 012/103] Add test demonstrating document transforms with unmasked work as expected --- src/__tests__/client.ts | 81 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/src/__tests__/client.ts b/src/__tests__/client.ts index 3436b6d8b7b..a8c056deca5 100644 --- a/src/__tests__/client.ts +++ b/src/__tests__/client.ts @@ -6,6 +6,7 @@ import { Kind, print, visit, + OperationDefinitionNode, } from "graphql"; import gql from "graphql-tag"; @@ -6662,6 +6663,86 @@ describe("data masking", () => { } }); + it("does not mask queries marked with @unmasked added by document transforms", async () => { + const documentTransform = new DocumentTransform((document) => { + return visit(document, { + OperationDefinition(node) { + return { + ...node, + directives: [ + { + kind: Kind.DIRECTIVE, + name: { kind: Kind.NAME, value: "unmasked" }, + }, + ], + } satisfies OperationDefinitionNode; + }, + }); + }); + + interface Query { + currentUser: { + __typename: "User"; + id: number; + name: string; + }; + } + + const query: TypedDocumentNode = gql` + query UnmaskedQuery { + currentUser { + id + name + ...UserFields + } + } + + fragment UserFields on User { + age + } + `; + + const mocks = [ + { + request: { query }, + result: { + data: { + currentUser: { + __typename: "User", + id: 1, + name: "Test User", + age: 30, + }, + }, + }, + }, + ]; + + const client = new ApolloClient({ + dataMasking: true, + cache: new InMemoryCache(), + link: new MockLink(mocks), + documentTransform, + }); + + const observable = client.watchQuery({ query }); + + const stream = new ObservableStream(observable); + + { + const { data } = await stream.takeNext(); + + expect(data).toEqual({ + currentUser: { + __typename: "User", + id: 1, + name: "Test User", + age: 30, + }, + }); + } + }); + it("does not mask query when using a cache that does not support it", async () => { using _ = spyOnConsole("warn"); From f6d37108fc08ced957835335a0c7f20879f2a7da Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Fri, 28 Jun 2024 13:08:18 -0600 Subject: [PATCH 013/103] Rename @unmasked to @unmask --- src/__tests__/client.ts | 8 ++-- src/core/QueryManager.ts | 2 +- src/testing/core/mocking/mockLink.ts | 2 +- src/utilities/graphql/__tests__/directives.ts | 40 +++++++++---------- src/utilities/graphql/directives.ts | 12 +++--- 5 files changed, 32 insertions(+), 32 deletions(-) diff --git a/src/__tests__/client.ts b/src/__tests__/client.ts index a8c056deca5..5020ed243e5 100644 --- a/src/__tests__/client.ts +++ b/src/__tests__/client.ts @@ -6600,7 +6600,7 @@ describe("data masking", () => { } }); - it("does not mask queries marked with @unmasked", async () => { + it("does not mask queries marked with @unmask", async () => { interface Query { currentUser: { __typename: "User"; @@ -6610,7 +6610,7 @@ describe("data masking", () => { } const query: TypedDocumentNode = gql` - query UnmaskedQuery @unmasked { + query UnmaskedQuery @unmask { currentUser { id name @@ -6663,7 +6663,7 @@ describe("data masking", () => { } }); - it("does not mask queries marked with @unmasked added by document transforms", async () => { + it("does not mask queries marked with @unmask added by document transforms", async () => { const documentTransform = new DocumentTransform((document) => { return visit(document, { OperationDefinition(node) { @@ -6672,7 +6672,7 @@ describe("data masking", () => { directives: [ { kind: Kind.DIRECTIVE, - name: { kind: Kind.NAME, value: "unmasked" }, + name: { kind: Kind.NAME, value: "unmask" }, }, ], } satisfies OperationDefinitionNode; diff --git a/src/core/QueryManager.ts b/src/core/QueryManager.ts index 8ddbe269c7a..7ed73b6bed0 100644 --- a/src/core/QueryManager.ts +++ b/src/core/QueryManager.ts @@ -697,7 +697,7 @@ export class QueryManager { { name: "client", remove: true }, { name: "connection" }, { name: "nonreactive" }, - { name: "unmasked" }, + { name: "unmask" }, ], document ), diff --git a/src/testing/core/mocking/mockLink.ts b/src/testing/core/mocking/mockLink.ts index 159b38b2ec5..81430a66306 100644 --- a/src/testing/core/mocking/mockLink.ts +++ b/src/testing/core/mocking/mockLink.ts @@ -211,7 +211,7 @@ ${unmatchedVars.map((d) => ` ${stringifyForDisplay(d)}`).join("\n")} ): MockedResponse { const newMockedResponse = cloneDeep(mockedResponse); const queryWithoutClientOnlyDirectives = removeDirectivesFromDocument( - [{ name: "connection" }, { name: "nonreactive" }, { name: "unmasked" }], + [{ name: "connection" }, { name: "nonreactive" }, { name: "unmask" }], checkDocument(newMockedResponse.request.query) ); invariant(queryWithoutClientOnlyDirectives, "query is required"); diff --git a/src/utilities/graphql/__tests__/directives.ts b/src/utilities/graphql/__tests__/directives.ts index 0e89dd31673..0718f87c7f0 100644 --- a/src/utilities/graphql/__tests__/directives.ts +++ b/src/utilities/graphql/__tests__/directives.ts @@ -516,9 +516,9 @@ describe("shouldInclude", () => { }); describe("isUnmaskedDocument", () => { - it("returns true when @unmasked used on document", () => { + it("returns true when @unmask used on document", () => { const query = gql` - query MyQuery @unmasked { + query MyQuery @unmask { myField } `; @@ -526,7 +526,7 @@ describe("isUnmaskedDocument", () => { expect(isUnmaskedDocument(query)).toBe(true); }); - it("returns false when @unmasked is not used", () => { + it("returns false when @unmask is not used", () => { const query = gql` query MyQuery { myField @@ -536,17 +536,17 @@ describe("isUnmaskedDocument", () => { expect(isUnmaskedDocument(query)).toBe(false); }); - it("returns false when @unmasked is used in a location other than the document root", () => { + it("returns false when @unmask is used in a location other than the document root", () => { using _ = spyOnConsole("warn"); const query = gql` - query MyQuery($id: ID! @unmasked) { - foo @unmasked + query MyQuery($id: ID! @unmask) { + foo @unmask bar(arg: true) { - ... @unmasked { + ... @unmask { baz } - ...MyFragment @unmasked + ...MyFragment @unmask } } `; @@ -554,17 +554,17 @@ describe("isUnmaskedDocument", () => { expect(isUnmaskedDocument(query)).toBe(false); }); - it("warns when using @unmasked directive in a location other than the document root", () => { + it("warns when using @unmask directive in a location other than the document root", () => { using consoleSpy = spyOnConsole("warn"); const query = gql` - query MyQuery($id: ID! @unmasked) { - foo @unmasked + query MyQuery($id: ID! @unmask) { + foo @unmask bar(arg: true) { - ... @unmasked { + ... @unmask { baz } - ...MyFragment @unmasked + ...MyFragment @unmask } } `; @@ -574,22 +574,22 @@ describe("isUnmaskedDocument", () => { expect(result).toBe(false); expect(consoleSpy.warn).toHaveBeenCalledTimes(1); expect(consoleSpy.warn).toHaveBeenCalledWith( - "@unmasked directive used in %s is provided in a location other than the document root which is ignored.", + "@unmask directive used in %s is provided in a location other than the document root which is ignored.", "'MyQuery': " ); }); - it("warns when using @unmasked directive a location other than the document root while also using @unmasked at the root", () => { + it("warns when using @unmask directive a location other than the document root while also using @unmask at the root", () => { using consoleSpy = spyOnConsole("warn"); const query = gql` - query MyQuery($id: ID! @unmasked) @unmasked { - foo @unmasked + query MyQuery($id: ID! @unmask) @unmask { + foo @unmask bar(arg: true) { - ... @unmasked { + ... @unmask { baz } - ...MyFragment @unmasked + ...MyFragment @unmask } } `; @@ -599,7 +599,7 @@ describe("isUnmaskedDocument", () => { expect(result).toBe(true); expect(consoleSpy.warn).toHaveBeenCalledTimes(1); expect(consoleSpy.warn).toHaveBeenCalledWith( - "@unmasked directive used in %s is provided in a location other than the document root which is ignored.", + "@unmask directive used in %s is provided in a location other than the document root which is ignored.", "'MyQuery': " ); }); diff --git a/src/utilities/graphql/directives.ts b/src/utilities/graphql/directives.ts index bf629fcc5d7..b749a2949e8 100644 --- a/src/utilities/graphql/directives.ts +++ b/src/utilities/graphql/directives.ts @@ -144,13 +144,13 @@ export function isUnmaskedDocument(document: DocumentNode) { if (node.directives) { unmasked = node.directives.some( - (directive) => directive.name.value === "unmasked" + (directive) => directive.name.value === "unmask" ); } if (__DEV__) { // Allow us to continue traversal in development to warn if we detect - // the unmasked directive anywhere else in the document. + // the unmask directive anywhere else in the document. return; } @@ -158,24 +158,24 @@ export function isUnmaskedDocument(document: DocumentNode) { }, Directive(node, _, __, ___, ancestors) { if (__DEV__) { - if (node.name.value !== "unmasked") { + if (node.name.value !== "unmask") { return; } const parent = ancestors[ancestors.length - 1]; - // Make sure we aren't checking the `unmasked` directive defined on + // Make sure we aren't checking the `unmask` directive defined on // the operation, which we don't want to warn on. if ( Array.isArray(parent) || (parent as ASTNode).kind !== "OperationDefinition" ) { invariant.warn( - "@unmasked directive used in %s is provided in a location other than the document root which is ignored.", + "@unmask directive used in %s is provided in a location other than the document root which is ignored.", operationName ? `'${operationName}': ` : "anonymous operation" ); - // We only want to warn once if we detect misused of @unmasked so we + // We only want to warn once if we detect misused of @unmask so we // immediately stop traversal. return BREAK; } From bead73fa4439b91e56051b537c9ad33086f6b9bb Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Fri, 28 Jun 2024 16:35:20 -0600 Subject: [PATCH 014/103] Update exports snapshot --- src/__tests__/__snapshots__/exports.ts.snap | 1 + 1 file changed, 1 insertion(+) diff --git a/src/__tests__/__snapshots__/exports.ts.snap b/src/__tests__/__snapshots__/exports.ts.snap index d3ce1568654..033ec9a7fc0 100644 --- a/src/__tests__/__snapshots__/exports.ts.snap +++ b/src/__tests__/__snapshots__/exports.ts.snap @@ -470,6 +470,7 @@ Array [ "isReference", "isStatefulPromise", "isSubscriptionOperation", + "isUnmaskedDocument", "iterateObserversSafely", "makeReference", "makeUniqueId", From 406ba077cdc5f01342eeeaf7f24e58ecb8deb02f Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Mon, 1 Jul 2024 17:36:03 -0600 Subject: [PATCH 015/103] Add test for checking warnings on property access for masking --- src/__tests__/client.ts | 76 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/src/__tests__/client.ts b/src/__tests__/client.ts index 5020ed243e5..3776a21804e 100644 --- a/src/__tests__/client.ts +++ b/src/__tests__/client.ts @@ -7396,6 +7396,82 @@ describe("data masking", () => { } } ); + + it("warns when accessing a fragmented field while using @unmask", async () => { + using consoleSpy = spyOnConsole("warn"); + + interface Query { + currentUser: { + __typename: "User"; + id: number; + name: string; + age: number; + }; + } + + const query: TypedDocumentNode = gql` + query UnmaskedQuery @unmask { + currentUser { + id + name + ...UserFields + } + } + + fragment UserFields on User { + age + name + } + `; + + const mocks = [ + { + request: { query }, + result: { + data: { + currentUser: { + __typename: "User", + id: 1, + name: "Test User", + age: 34, + }, + }, + }, + delay: 20, + }, + ]; + + const client = new ApolloClient({ + dataMasking: true, + cache: new InMemoryCache(), + link: new MockLink(mocks), + }); + + const observable = client.watchQuery({ query }); + const stream = new ObservableStream(observable); + + { + const { data } = await stream.takeNext(); + data.currentUser.__typename; + data.currentUser.id; + data.currentUser.name; + + expect(consoleSpy.warn).not.toHaveBeenCalled(); + + data.currentUser.age; + + expect(consoleSpy.warn).toHaveBeenCalledTimes(1); + expect(consoleSpy.warn).toHaveBeenCalledWith( + "Field '%s' will not be available when masking query %s. Please read the field from the fragment instead.", + "age" + // "'UnmaskedQuery'" + ); + + // Ensure we only warn once + data.currentUser.age; + expect(consoleSpy.warn).toHaveBeenCalledTimes(1); + } + }); }); function clientRoundtrip( From 75c5e22688b3b9cdbe4f6e232b9c46da473eab4f Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Mon, 1 Jul 2024 17:38:56 -0600 Subject: [PATCH 016/103] Add implementation to warn when accessing unmasked field --- src/__tests__/client.ts | 2 +- src/core/ObservableQuery.ts | 5 +- src/core/masking.ts | 101 +++++++++++++++++++++++++++++++++--- 3 files changed, 95 insertions(+), 13 deletions(-) diff --git a/src/__tests__/client.ts b/src/__tests__/client.ts index 3776a21804e..06a38ebee95 100644 --- a/src/__tests__/client.ts +++ b/src/__tests__/client.ts @@ -7462,7 +7462,7 @@ describe("data masking", () => { expect(consoleSpy.warn).toHaveBeenCalledTimes(1); expect(consoleSpy.warn).toHaveBeenCalledWith( - "Field '%s' will not be available when masking query %s. Please read the field from the fragment instead.", + "Accessing unmasked field '%s' on query %s. This field will not be available when masking is enabled. Please read the field from the fragment instead.", "age" // "'UnmaskedQuery'" ); diff --git a/src/core/ObservableQuery.ts b/src/core/ObservableQuery.ts index b05aeb3ba1b..d63f47b724a 100644 --- a/src/core/ObservableQuery.ts +++ b/src/core/ObservableQuery.ts @@ -1066,11 +1066,8 @@ Did you mean to call refetch(variables) instead of refetch({ variables })?`, private maskQuery(data: TData) { const { queryManager } = this; - const shouldMask = - queryManager.dataMasking && - !queryManager.getDocumentInfo(this.query).isUnmasked; - return shouldMask ? + return queryManager.dataMasking ? queryManager.cache.maskDocument(this.query, data) : data; } diff --git a/src/core/masking.ts b/src/core/masking.ts index 4c77d94a675..7afbeaad245 100644 --- a/src/core/masking.ts +++ b/src/core/masking.ts @@ -5,9 +5,13 @@ import type { SelectionSetNode, } from "graphql"; import { + createFragmentMap, getMainDefinition, resultKeyNameFromField, + isUnmaskedDocument, + getFragmentDefinitions, } from "../utilities/index.js"; +import type { FragmentMap } from "../utilities/index.js"; import type { DocumentNode, TypedDocumentNode } from "./index.js"; import { invariant } from "../utilities/globals/index.js"; @@ -22,10 +26,13 @@ export function maskQuery( matchesFragment: MatchesFragmentFn ): TData { const definition = getMainDefinition(document); + const fragmentMap = createFragmentMap(getFragmentDefinitions(document)); const [masked, changed] = maskSelectionSet( data, definition.selectionSet, - matchesFragment + matchesFragment, + fragmentMap, + isUnmaskedDocument(document) ); return changed ? masked : data; @@ -42,6 +49,8 @@ export function maskFragment( node.kind === Kind.FRAGMENT_DEFINITION ); + const fragmentMap = createFragmentMap(getFragmentDefinitions(document)); + if (typeof fragmentName === "undefined") { invariant( fragments.length === 1, @@ -64,7 +73,9 @@ export function maskFragment( const [masked, changed] = maskSelectionSet( data, fragment.selectionSet, - matchesFragment + matchesFragment, + fragmentMap, + false ); return changed ? masked : data; @@ -73,7 +84,9 @@ export function maskFragment( function maskSelectionSet( data: any, selectionSet: SelectionSetNode, - matchesFragment: MatchesFragmentFn + matchesFragment: MatchesFragmentFn, + fragmentMap: FragmentMap, + isUnmasked: boolean ): [data: any, changed: boolean] { if (Array.isArray(data)) { let changed = false; @@ -82,7 +95,9 @@ function maskSelectionSet( const [masked, itemChanged] = maskSelectionSet( item, selectionSet, - matchesFragment + matchesFragment, + fragmentMap, + isUnmasked ); changed ||= itemChanged; @@ -97,15 +112,26 @@ function maskSelectionSet( switch (selection.kind) { case Kind.FIELD: { const keyName = resultKeyNameFromField(selection); + const descriptor = Object.getOwnPropertyDescriptor(memo, keyName); const childSelectionSet = selection.selectionSet; - memo[keyName] = data[keyName]; + // If we've set a descriptor on the object by adding warnings to field + // access, overwrite the descriptor because we're adding a field that + // is accessible when masked. This checks avoids the need for us to + // maintain which fields are masked/unmasked and better handle + if (descriptor) { + Object.defineProperty(memo, keyName, { value: data[keyName] }); + } else { + memo[keyName] = data[keyName]; + } if (childSelectionSet) { const [masked, childChanged] = maskSelectionSet( data[keyName], childSelectionSet, - matchesFragment + matchesFragment, + fragmentMap, + isUnmasked ); if (childChanged) { @@ -127,7 +153,9 @@ function maskSelectionSet( const [fragmentData, childChanged] = maskSelectionSet( data, selection.selectionSet, - matchesFragment + matchesFragment, + fragmentMap, + isUnmasked ); return [ @@ -139,9 +167,66 @@ function maskSelectionSet( ]; } default: - return [memo, true]; + const fragment = fragmentMap[selection.name.value]; + + return [ + isUnmasked ? + addAccessorWarnings(memo, data, fragment.selectionSet) + : memo, + true, + ]; } }, [Object.create(null), false] ); } + +function addAccessorWarnings( + memo: Record, + parent: Record, + selectionSetNode: SelectionSetNode +) { + selectionSetNode.selections.forEach((selection) => { + switch (selection.kind) { + case Kind.FIELD: { + const keyName = resultKeyNameFromField(selection); + + if (keyName in memo) { + return; + } + + return addAccessorWarning(memo, parent[keyName], keyName); + } + } + }); + + return memo; +} + +function addAccessorWarning( + data: Record, + value: any, + fieldName: string +) { + let currentValue = value; + let warned = false; + + Object.defineProperty(data, fieldName, { + get() { + if (!warned) { + invariant.warn( + "Accessing unmasked field '%s' on query %s. This field will not be available when masking is enabled. Please read the field from the fragment instead.", + fieldName + ); + warned = true; + } + + return currentValue; + }, + set(value) { + currentValue = value; + }, + enumerable: true, + configurable: true, + }); +} From 52aae2cfeba434c415d67bea0d3d34d1c2891df4 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Mon, 1 Jul 2024 17:44:02 -0600 Subject: [PATCH 017/103] Hide unmasked field warning in other tests --- src/__tests__/client.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/__tests__/client.ts b/src/__tests__/client.ts index 06a38ebee95..766da6bb53a 100644 --- a/src/__tests__/client.ts +++ b/src/__tests__/client.ts @@ -6650,6 +6650,9 @@ describe("data masking", () => { const stream = new ObservableStream(observable); { + // Hide unmasked field warning + using _ = spyOnConsole("warn"); + const { data } = await stream.takeNext(); expect(data).toEqual({ @@ -6730,6 +6733,9 @@ describe("data masking", () => { const stream = new ObservableStream(observable); { + // Hide unmasked field warning + using _ = spyOnConsole("warn"); + const { data } = await stream.takeNext(); expect(data).toEqual({ From f39bc40d8ff24185af4b208c9d9e633c0f295b02 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Mon, 1 Jul 2024 17:47:46 -0600 Subject: [PATCH 018/103] Remove isUnmasked from getDocumentInfo --- src/core/QueryManager.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/core/QueryManager.ts b/src/core/QueryManager.ts index 7ed73b6bed0..921c99ba114 100644 --- a/src/core/QueryManager.ts +++ b/src/core/QueryManager.ts @@ -26,7 +26,6 @@ import { getOperationDefinition, getOperationName, hasClientExports, - isUnmaskedDocument, graphQLResultHasError, getGraphQLErrorsFromResult, Observable, @@ -96,7 +95,6 @@ interface TransformCacheEntry { hasClientExports: boolean; hasForcedResolvers: boolean; hasNonreactiveDirective: boolean; - isUnmasked: boolean; clientQuery: DocumentNode | null; serverQuery: DocumentNode | null; defaultVars: OperationVariables; @@ -690,7 +688,6 @@ export class QueryManager { hasClientExports: hasClientExports(document), hasForcedResolvers: this.localState.shouldForceResolvers(document), hasNonreactiveDirective: hasDirectives(["nonreactive"], document), - isUnmasked: isUnmaskedDocument(document), clientQuery: this.localState.clientQuery(document), serverQuery: removeDirectivesFromDocument( [ From c83d8c0ba2ed71820a14f7c11c8d404569476ffb Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Mon, 1 Jul 2024 17:51:34 -0600 Subject: [PATCH 019/103] Update comment --- src/core/masking.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/core/masking.ts b/src/core/masking.ts index 7afbeaad245..d7511b625af 100644 --- a/src/core/masking.ts +++ b/src/core/masking.ts @@ -117,8 +117,9 @@ function maskSelectionSet( // If we've set a descriptor on the object by adding warnings to field // access, overwrite the descriptor because we're adding a field that - // is accessible when masked. This checks avoids the need for us to - // maintain which fields are masked/unmasked and better handle + // is accessible when masked. This avoids the need for us to maintain + // which fields are masked/unmasked and avoids dependence on field + // order. if (descriptor) { Object.defineProperty(memo, keyName, { value: data[keyName] }); } else { From 499723257df3bf9644a43a3c4c54b34ceb83348e Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Mon, 1 Jul 2024 18:25:36 -0600 Subject: [PATCH 020/103] Allow argument to unmask directive --- src/utilities/graphql/__tests__/directives.ts | 38 +++++++------------ src/utilities/graphql/directives.ts | 33 +++++++++++++--- 2 files changed, 42 insertions(+), 29 deletions(-) diff --git a/src/utilities/graphql/__tests__/directives.ts b/src/utilities/graphql/__tests__/directives.ts index 0718f87c7f0..726fef1e7e8 100644 --- a/src/utilities/graphql/__tests__/directives.ts +++ b/src/utilities/graphql/__tests__/directives.ts @@ -523,7 +523,10 @@ describe("isUnmaskedDocument", () => { } `; - expect(isUnmaskedDocument(query)).toBe(true); + const [isUnmasked, { warnOnFieldAccess }] = isUnmaskedDocument(query); + + expect(isUnmasked).toBe(true); + expect(warnOnFieldAccess).toBe(true); }); it("returns false when @unmask is not used", () => { @@ -533,28 +536,13 @@ describe("isUnmaskedDocument", () => { } `; - expect(isUnmaskedDocument(query)).toBe(false); - }); + const [isUnmasked, { warnOnFieldAccess }] = isUnmaskedDocument(query); - it("returns false when @unmask is used in a location other than the document root", () => { - using _ = spyOnConsole("warn"); - - const query = gql` - query MyQuery($id: ID! @unmask) { - foo @unmask - bar(arg: true) { - ... @unmask { - baz - } - ...MyFragment @unmask - } - } - `; - - expect(isUnmaskedDocument(query)).toBe(false); + expect(isUnmasked).toBe(false); + expect(warnOnFieldAccess).toBe(true); }); - it("warns when using @unmask directive in a location other than the document root", () => { + it("returns false when @unmask is used in a location other than the document root", () => { using consoleSpy = spyOnConsole("warn"); const query = gql` @@ -569,9 +557,10 @@ describe("isUnmaskedDocument", () => { } `; - const result = isUnmaskedDocument(query); + const [isUnmasked, { warnOnFieldAccess }] = isUnmaskedDocument(query); - expect(result).toBe(false); + expect(isUnmasked).toBe(false); + expect(warnOnFieldAccess).toBe(true); expect(consoleSpy.warn).toHaveBeenCalledTimes(1); expect(consoleSpy.warn).toHaveBeenCalledWith( "@unmask directive used in %s is provided in a location other than the document root which is ignored.", @@ -594,9 +583,10 @@ describe("isUnmaskedDocument", () => { } `; - const result = isUnmaskedDocument(query); + const [isUnmasked, { warnOnFieldAccess }] = isUnmaskedDocument(query); - expect(result).toBe(true); + expect(isUnmasked).toBe(true); + expect(warnOnFieldAccess).toBe(true); expect(consoleSpy.warn).toHaveBeenCalledTimes(1); expect(consoleSpy.warn).toHaveBeenCalledWith( "@unmask directive used in %s is provided in a location other than the document root which is ignored.", diff --git a/src/utilities/graphql/directives.ts b/src/utilities/graphql/directives.ts index b749a2949e8..ab9bfe6b40e 100644 --- a/src/utilities/graphql/directives.ts +++ b/src/utilities/graphql/directives.ts @@ -12,7 +12,7 @@ import type { ValueNode, ASTNode, } from "graphql"; -import { visit, BREAK } from "graphql"; +import { visit, BREAK, Kind } from "graphql"; export type DirectiveInfo = { [fieldName: string]: { [argName: string]: any }; @@ -134,8 +134,11 @@ export function getInclusionDirectives( return result; } -export function isUnmaskedDocument(document: DocumentNode) { - let unmasked = false; +export function isUnmaskedDocument( + document: DocumentNode +): [isUnmasked: boolean, options: { warnOnFieldAccess: boolean }] { + let masked = false; + let warnOnFieldAccess = true; let operationName: string | undefined; visit(document, { @@ -143,9 +146,29 @@ export function isUnmaskedDocument(document: DocumentNode) { operationName = node.name?.value; if (node.directives) { - unmasked = node.directives.some( + const directive = node.directives.find( (directive) => directive.name.value === "unmask" ); + + masked = !!directive; + + const warnsArg = directive?.arguments?.find( + (arg) => arg.name.value === "warnOnFieldAccess" + ); + + if (__DEV__) { + if (warnsArg && warnsArg.value.kind !== Kind.BOOLEAN) { + invariant.warn( + warnsArg.value.kind === Kind.VARIABLE ? + "@unmask 'warnOnFieldAccess' argument does not support variables." + : "@unmask 'warnOnFieldAccess' argument must be of type boolean." + ); + } + } + + if (warnsArg && "value" in warnsArg.value) { + warnOnFieldAccess = warnsArg.value.value !== false; + } } if (__DEV__) { @@ -183,5 +206,5 @@ export function isUnmaskedDocument(document: DocumentNode) { }, }); - return unmasked; + return [masked, { warnOnFieldAccess }]; } From 7c8e99be07c1395df090d85a90bda1dc62a2b2b2 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Mon, 1 Jul 2024 18:28:01 -0600 Subject: [PATCH 021/103] Add test that checks warnOnFieldAccess arg --- src/utilities/graphql/__tests__/directives.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/utilities/graphql/__tests__/directives.ts b/src/utilities/graphql/__tests__/directives.ts index 726fef1e7e8..fc2fcc88de5 100644 --- a/src/utilities/graphql/__tests__/directives.ts +++ b/src/utilities/graphql/__tests__/directives.ts @@ -529,6 +529,19 @@ describe("isUnmaskedDocument", () => { expect(warnOnFieldAccess).toBe(true); }); + it("allows disabling unmask warnings with argument", () => { + const query = gql` + query MyQuery @unmask(warnOnFieldAccess: false) { + myField + } + `; + + const [isUnmasked, { warnOnFieldAccess }] = isUnmaskedDocument(query); + + expect(isUnmasked).toBe(true); + expect(warnOnFieldAccess).toBe(false); + }); + it("returns false when @unmask is not used", () => { const query = gql` query MyQuery { From eec30a363417a146e2bb7439bb171a25e01813ad Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Mon, 1 Jul 2024 18:29:56 -0600 Subject: [PATCH 022/103] Add test checking variables on warnOnFieldAccess --- src/utilities/graphql/__tests__/directives.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/utilities/graphql/__tests__/directives.ts b/src/utilities/graphql/__tests__/directives.ts index fc2fcc88de5..6b645172efd 100644 --- a/src/utilities/graphql/__tests__/directives.ts +++ b/src/utilities/graphql/__tests__/directives.ts @@ -542,6 +542,25 @@ describe("isUnmaskedDocument", () => { expect(warnOnFieldAccess).toBe(false); }); + it("warns when passing variable to warnOnFieldAccess", () => { + using consoleSpy = spyOnConsole("warn"); + const query = gql` + query MyQuery($warnOnFieldAccess: Boolean!) + @unmask(warnOnFieldAccess: $warnOnFieldAccess) { + myField + } + `; + + const [isUnmasked, { warnOnFieldAccess }] = isUnmaskedDocument(query); + + expect(isUnmasked).toBe(true); + expect(warnOnFieldAccess).toBe(true); + expect(consoleSpy.warn).toHaveBeenCalledTimes(1); + expect(consoleSpy.warn).toHaveBeenCalledWith( + "@unmask 'warnOnFieldAccess' argument does not support variables." + ); + }); + it("returns false when @unmask is not used", () => { const query = gql` query MyQuery { From 470e32a8c0dc0520384ad3dea232c2015d261c94 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Mon, 1 Jul 2024 18:31:31 -0600 Subject: [PATCH 023/103] Add test checking non-boolean arg to warnOnFieldAccess --- src/utilities/graphql/__tests__/directives.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/utilities/graphql/__tests__/directives.ts b/src/utilities/graphql/__tests__/directives.ts index 6b645172efd..bbb9d047be5 100644 --- a/src/utilities/graphql/__tests__/directives.ts +++ b/src/utilities/graphql/__tests__/directives.ts @@ -561,6 +561,24 @@ describe("isUnmaskedDocument", () => { ); }); + it("warns when passing non-boolean to warnOnFieldAccess", () => { + using consoleSpy = spyOnConsole("warn"); + const query = gql` + query MyQuery @unmask(warnOnFieldAccess: "") { + myField + } + `; + + const [isUnmasked, { warnOnFieldAccess }] = isUnmaskedDocument(query); + + expect(isUnmasked).toBe(true); + expect(warnOnFieldAccess).toBe(true); + expect(consoleSpy.warn).toHaveBeenCalledTimes(1); + expect(consoleSpy.warn).toHaveBeenCalledWith( + "@unmask 'warnOnFieldAccess' argument must be of type boolean." + ); + }); + it("returns false when @unmask is not used", () => { const query = gql` query MyQuery { From 5d0f2adba2e1a177cef34d2428c44c073f32bef8 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Mon, 1 Jul 2024 18:40:59 -0600 Subject: [PATCH 024/103] Allow disabling warnings for masked field access with @unmask --- src/__tests__/client.ts | 64 +++++++++++++++++++++++++++++++++++++++++ src/core/masking.ts | 35 ++++++++++++++++------ 2 files changed, 90 insertions(+), 9 deletions(-) diff --git a/src/__tests__/client.ts b/src/__tests__/client.ts index 766da6bb53a..c61ca16580b 100644 --- a/src/__tests__/client.ts +++ b/src/__tests__/client.ts @@ -7478,6 +7478,70 @@ describe("data masking", () => { expect(consoleSpy.warn).toHaveBeenCalledTimes(1); } }); + + it("allows disabling warnings when accessing a fragmented field while using @unmask", async () => { + using consoleSpy = spyOnConsole("warn"); + + interface Query { + currentUser: { + __typename: "User"; + id: number; + name: string; + age: number; + }; + } + + const query: TypedDocumentNode = gql` + query UnmaskedQuery @unmask(warnOnFieldAccess: false) { + currentUser { + id + name + ...UserFields + } + } + + fragment UserFields on User { + age + name + } + `; + + const mocks = [ + { + request: { query }, + result: { + data: { + currentUser: { + __typename: "User", + id: 1, + name: "Test User", + age: 34, + }, + }, + }, + delay: 20, + }, + ]; + + const client = new ApolloClient({ + dataMasking: true, + cache: new InMemoryCache(), + link: new MockLink(mocks), + }); + + const observable = client.watchQuery({ query }); + const stream = new ObservableStream(observable); + + { + const { data } = await stream.takeNext(); + data.currentUser.__typename; + data.currentUser.id; + data.currentUser.name; + data.currentUser.age; + + expect(consoleSpy.warn).not.toHaveBeenCalled(); + } + }); }); function clientRoundtrip( diff --git a/src/core/masking.ts b/src/core/masking.ts index d7511b625af..803c7297f9a 100644 --- a/src/core/masking.ts +++ b/src/core/masking.ts @@ -27,12 +27,14 @@ export function maskQuery( ): TData { const definition = getMainDefinition(document); const fragmentMap = createFragmentMap(getFragmentDefinitions(document)); + const [isUnmasked, { warnOnFieldAccess }] = isUnmaskedDocument(document); const [masked, changed] = maskSelectionSet( data, definition.selectionSet, matchesFragment, fragmentMap, - isUnmaskedDocument(document) + isUnmasked, + warnOnFieldAccess ); return changed ? masked : data; @@ -75,7 +77,8 @@ export function maskFragment( fragment.selectionSet, matchesFragment, fragmentMap, - false + false, + true ); return changed ? masked : data; @@ -86,7 +89,8 @@ function maskSelectionSet( selectionSet: SelectionSetNode, matchesFragment: MatchesFragmentFn, fragmentMap: FragmentMap, - isUnmasked: boolean + isUnmasked: boolean, + warnOnFieldAccess: boolean ): [data: any, changed: boolean] { if (Array.isArray(data)) { let changed = false; @@ -97,7 +101,8 @@ function maskSelectionSet( selectionSet, matchesFragment, fragmentMap, - isUnmasked + isUnmasked, + warnOnFieldAccess ); changed ||= itemChanged; @@ -132,7 +137,8 @@ function maskSelectionSet( childSelectionSet, matchesFragment, fragmentMap, - isUnmasked + isUnmasked, + warnOnFieldAccess ); if (childChanged) { @@ -156,7 +162,8 @@ function maskSelectionSet( selection.selectionSet, matchesFragment, fragmentMap, - isUnmasked + isUnmasked, + warnOnFieldAccess ); return [ @@ -172,7 +179,12 @@ function maskSelectionSet( return [ isUnmasked ? - addAccessorWarnings(memo, data, fragment.selectionSet) + addAccessorWarnings( + memo, + data, + fragment.selectionSet, + warnOnFieldAccess + ) : memo, true, ]; @@ -185,7 +197,8 @@ function maskSelectionSet( function addAccessorWarnings( memo: Record, parent: Record, - selectionSetNode: SelectionSetNode + selectionSetNode: SelectionSetNode, + warnOnFieldAccess: boolean ) { selectionSetNode.selections.forEach((selection) => { switch (selection.kind) { @@ -196,7 +209,11 @@ function addAccessorWarnings( return; } - return addAccessorWarning(memo, parent[keyName], keyName); + if (warnOnFieldAccess) { + return addAccessorWarning(memo, parent[keyName], keyName); + } else { + memo[keyName] = parent[keyName]; + } } } }); From 19a72e3715e131ee16fb1a2e53f22b812ad4377d Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Tue, 16 Jul 2024 12:31:23 -0600 Subject: [PATCH 025/103] Delete the property with descriptor then set value --- src/core/masking.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/core/masking.ts b/src/core/masking.ts index 803c7297f9a..7d48da84bd6 100644 --- a/src/core/masking.ts +++ b/src/core/masking.ts @@ -126,11 +126,11 @@ function maskSelectionSet( // which fields are masked/unmasked and avoids dependence on field // order. if (descriptor) { - Object.defineProperty(memo, keyName, { value: data[keyName] }); - } else { - memo[keyName] = data[keyName]; + delete memo[keyName]; } + memo[keyName] = data[keyName]; + if (childSelectionSet) { const [masked, childChanged] = maskSelectionSet( data[keyName], From a3963e996ac516e87ef0b35d1493d283bf71b2ef Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Tue, 16 Jul 2024 12:52:43 -0600 Subject: [PATCH 026/103] Add test for @unmask in maskQuery --- src/core/__tests__/masking.test.ts | 43 ++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/src/core/__tests__/masking.test.ts b/src/core/__tests__/masking.test.ts index 27054380a8c..caa7dd032d6 100644 --- a/src/core/__tests__/masking.test.ts +++ b/src/core/__tests__/masking.test.ts @@ -3,6 +3,7 @@ import { InMemoryCache, gql } from "../index.js"; import { InlineFragmentNode } from "graphql"; import { deepFreeze } from "../../utilities/common/maybeDeepFreeze.js"; import { InvariantError } from "../../utilities/globals/index.js"; +import { spyOnConsole } from "../../testing/internal/index.js"; test("strips top-level fragment data from query", () => { const query = gql` @@ -721,6 +722,48 @@ test("maintains referential equality the entire result if there are no fragments expect(data).toBe(originalData); }); +test("does not mask fields when using `@unmask` directive", () => { + // Silence masked field access warning + using _ = spyOnConsole("warn"); + + const query = gql` + query UnmaskedQuery @unmask { + currentUser { + __typename + id + name + ...UserFields + } + } + + fragment UserFields on User { + age + } + `; + + const data = maskQuery( + { + currentUser: { + __typename: "User", + id: 1, + name: "Test User", + age: 30, + }, + }, + query, + createFragmentMatcher(new InMemoryCache()) + ); + + expect(data).toEqual({ + currentUser: { + __typename: "User", + id: 1, + name: "Test User", + age: 30, + }, + }); +}); + test("masks named fragments in fragment documents", () => { const fragment = gql` fragment UserFields on User { From 362b4857e3d7c2511bac638e7ce4178bc47055ea Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Tue, 16 Jul 2024 16:11:25 -0600 Subject: [PATCH 027/103] Refactor some common data into context object --- src/core/masking.ts | 48 +++++++++++++++++++++++---------------------- 1 file changed, 25 insertions(+), 23 deletions(-) diff --git a/src/core/masking.ts b/src/core/masking.ts index 7d48da84bd6..3f99ba8efbb 100644 --- a/src/core/masking.ts +++ b/src/core/masking.ts @@ -20,21 +20,30 @@ type MatchesFragmentFn = ( typename: string ) => boolean; +interface MaskingContext { + fragmentMap: FragmentMap; + warnOnFieldAccess: boolean; +} + export function maskQuery( data: TData, document: TypedDocumentNode | DocumentNode, matchesFragment: MatchesFragmentFn ): TData { const definition = getMainDefinition(document); - const fragmentMap = createFragmentMap(getFragmentDefinitions(document)); const [isUnmasked, { warnOnFieldAccess }] = isUnmaskedDocument(document); + + const context: MaskingContext = { + fragmentMap: createFragmentMap(getFragmentDefinitions(document)), + warnOnFieldAccess, + }; + const [masked, changed] = maskSelectionSet( data, definition.selectionSet, matchesFragment, - fragmentMap, isUnmasked, - warnOnFieldAccess + context ); return changed ? masked : data; @@ -51,7 +60,10 @@ export function maskFragment( node.kind === Kind.FRAGMENT_DEFINITION ); - const fragmentMap = createFragmentMap(getFragmentDefinitions(document)); + const context: MaskingContext = { + fragmentMap: createFragmentMap(getFragmentDefinitions(document)), + warnOnFieldAccess: true, + }; if (typeof fragmentName === "undefined") { invariant( @@ -76,9 +88,8 @@ export function maskFragment( data, fragment.selectionSet, matchesFragment, - fragmentMap, false, - true + context ); return changed ? masked : data; @@ -88,9 +99,8 @@ function maskSelectionSet( data: any, selectionSet: SelectionSetNode, matchesFragment: MatchesFragmentFn, - fragmentMap: FragmentMap, isUnmasked: boolean, - warnOnFieldAccess: boolean + context: MaskingContext ): [data: any, changed: boolean] { if (Array.isArray(data)) { let changed = false; @@ -100,9 +110,8 @@ function maskSelectionSet( item, selectionSet, matchesFragment, - fragmentMap, isUnmasked, - warnOnFieldAccess + context ); changed ||= itemChanged; @@ -136,9 +145,8 @@ function maskSelectionSet( data[keyName], childSelectionSet, matchesFragment, - fragmentMap, isUnmasked, - warnOnFieldAccess + context ); if (childChanged) { @@ -161,9 +169,8 @@ function maskSelectionSet( data, selection.selectionSet, matchesFragment, - fragmentMap, isUnmasked, - warnOnFieldAccess + context ); return [ @@ -175,16 +182,11 @@ function maskSelectionSet( ]; } default: - const fragment = fragmentMap[selection.name.value]; + const fragment = context.fragmentMap[selection.name.value]; return [ isUnmasked ? - addAccessorWarnings( - memo, - data, - fragment.selectionSet, - warnOnFieldAccess - ) + addAccessorWarnings(memo, data, fragment.selectionSet, context) : memo, true, ]; @@ -198,7 +200,7 @@ function addAccessorWarnings( memo: Record, parent: Record, selectionSetNode: SelectionSetNode, - warnOnFieldAccess: boolean + context: MaskingContext ) { selectionSetNode.selections.forEach((selection) => { switch (selection.kind) { @@ -209,7 +211,7 @@ function addAccessorWarnings( return; } - if (warnOnFieldAccess) { + if (context.warnOnFieldAccess) { return addAccessorWarning(memo, parent[keyName], keyName); } else { memo[keyName] = parent[keyName]; From 81371e43cf50f05e544d5275320c4f864e47db4e Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Tue, 16 Jul 2024 16:11:41 -0600 Subject: [PATCH 028/103] Add operation name to warning --- src/core/__tests__/masking.test.ts | 40 ++++++++++++++++++++++++++++++ src/core/masking.ts | 17 ++++++++++--- 2 files changed, 54 insertions(+), 3 deletions(-) diff --git a/src/core/__tests__/masking.test.ts b/src/core/__tests__/masking.test.ts index caa7dd032d6..e75a30b8ac5 100644 --- a/src/core/__tests__/masking.test.ts +++ b/src/core/__tests__/masking.test.ts @@ -764,6 +764,46 @@ test("does not mask fields when using `@unmask` directive", () => { }); }); +test("warns when accessing would-be masked fields when using `@unmask` directive", () => { + using consoleSpy = spyOnConsole("warn"); + const query = gql` + query UnmaskedQuery @unmask { + currentUser { + __typename + id + name + ...UserFields + } + } + + fragment UserFields on User { + age + } + `; + + const data = maskQuery( + { + currentUser: { + __typename: "User", + id: 1, + name: "Test User", + age: 30, + }, + }, + query, + createFragmentMatcher(new InMemoryCache()) + ); + + data.currentUser.age; + + expect(consoleSpy.warn).toHaveBeenCalledTimes(1); + expect(consoleSpy.warn).toHaveBeenCalledWith( + "Accessing unmasked field '%s' on query %s. This field will not be available when masking is enabled. Please read the field from the fragment instead.", + "age", + "UnmaskedQuery" + ); +}); + test("masks named fragments in fragment documents", () => { const fragment = gql` fragment UserFields on User { diff --git a/src/core/masking.ts b/src/core/masking.ts index 3f99ba8efbb..ffba25bf889 100644 --- a/src/core/masking.ts +++ b/src/core/masking.ts @@ -10,6 +10,7 @@ import { resultKeyNameFromField, isUnmaskedDocument, getFragmentDefinitions, + getOperationName, } from "../utilities/index.js"; import type { FragmentMap } from "../utilities/index.js"; import type { DocumentNode, TypedDocumentNode } from "./index.js"; @@ -21,6 +22,7 @@ type MatchesFragmentFn = ( ) => boolean; interface MaskingContext { + operationName: string | null; fragmentMap: FragmentMap; warnOnFieldAccess: boolean; } @@ -34,6 +36,7 @@ export function maskQuery( const [isUnmasked, { warnOnFieldAccess }] = isUnmaskedDocument(document); const context: MaskingContext = { + operationName: getOperationName(document), fragmentMap: createFragmentMap(getFragmentDefinitions(document)), warnOnFieldAccess, }; @@ -61,6 +64,7 @@ export function maskFragment( ); const context: MaskingContext = { + operationName: null, fragmentMap: createFragmentMap(getFragmentDefinitions(document)), warnOnFieldAccess: true, }; @@ -212,7 +216,12 @@ function addAccessorWarnings( } if (context.warnOnFieldAccess) { - return addAccessorWarning(memo, parent[keyName], keyName); + return addAccessorWarning( + memo, + parent[keyName], + keyName, + context.operationName + ); } else { memo[keyName] = parent[keyName]; } @@ -226,7 +235,8 @@ function addAccessorWarnings( function addAccessorWarning( data: Record, value: any, - fieldName: string + fieldName: string, + operationName: string | null ) { let currentValue = value; let warned = false; @@ -236,7 +246,8 @@ function addAccessorWarning( if (!warned) { invariant.warn( "Accessing unmasked field '%s' on query %s. This field will not be available when masking is enabled. Please read the field from the fragment instead.", - fieldName + fieldName, + operationName ); warned = true; } From 23d036c875e72b9e40d3d84552c7979801a4aa44 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Tue, 16 Jul 2024 16:19:53 -0600 Subject: [PATCH 029/103] Add query name to warning --- src/core/__tests__/masking.test.ts | 54 +++++++++++++++++++++++------- src/core/masking.ts | 4 +-- 2 files changed, 43 insertions(+), 15 deletions(-) diff --git a/src/core/__tests__/masking.test.ts b/src/core/__tests__/masking.test.ts index e75a30b8ac5..4691d56309a 100644 --- a/src/core/__tests__/masking.test.ts +++ b/src/core/__tests__/masking.test.ts @@ -781,26 +781,54 @@ test("warns when accessing would-be masked fields when using `@unmask` directive } `; - const data = maskQuery( - { - currentUser: { - __typename: "User", - id: 1, - name: "Test User", - age: 30, - }, - }, - query, - createFragmentMatcher(new InMemoryCache()) + const anonymousQuery = gql` + query @unmask { + currentUser { + __typename + id + name + ...UserFields + } + } + + fragment UserFields on User { + age + } + `; + + const currentUser = { + __typename: "User", + id: 1, + name: "Test User", + age: 30, + }; + + const fragmentMatcher = createFragmentMatcher(new InMemoryCache()); + + const data = maskQuery({ currentUser }, query, fragmentMatcher); + + const dataFromAnonymous = maskQuery( + { currentUser }, + anonymousQuery, + fragmentMatcher ); data.currentUser.age; expect(consoleSpy.warn).toHaveBeenCalledTimes(1); expect(consoleSpy.warn).toHaveBeenCalledWith( - "Accessing unmasked field '%s' on query %s. This field will not be available when masking is enabled. Please read the field from the fragment instead.", + "Accessing unmasked field '%s' on %s. This field will not be available when masking is enabled. Please read the field from the fragment instead.", + "age", + "query 'UnmaskedQuery'" + ); + + dataFromAnonymous.currentUser.age; + + expect(consoleSpy.warn).toHaveBeenCalledTimes(2); + expect(consoleSpy.warn).toHaveBeenCalledWith( + "Accessing unmasked field '%s' on %s. This field will not be available when masking is enabled. Please read the field from the fragment instead.", "age", - "UnmaskedQuery" + "anonymous query" ); }); diff --git a/src/core/masking.ts b/src/core/masking.ts index ffba25bf889..ca799dd59bb 100644 --- a/src/core/masking.ts +++ b/src/core/masking.ts @@ -245,9 +245,9 @@ function addAccessorWarning( get() { if (!warned) { invariant.warn( - "Accessing unmasked field '%s' on query %s. This field will not be available when masking is enabled. Please read the field from the fragment instead.", + "Accessing unmasked field '%s' on %s. This field will not be available when masking is enabled. Please read the field from the fragment instead.", fieldName, - operationName + operationName ? `query '${operationName}'` : "anonymous query" ); warned = true; } From 8c03e9089630d97ecdd610d63a2309bf5bbe67c5 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Tue, 16 Jul 2024 16:25:01 -0600 Subject: [PATCH 030/103] Add test for accessing shared field --- src/core/__tests__/masking.test.ts | 42 ++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/src/core/__tests__/masking.test.ts b/src/core/__tests__/masking.test.ts index 4691d56309a..64476f213d2 100644 --- a/src/core/__tests__/masking.test.ts +++ b/src/core/__tests__/masking.test.ts @@ -832,6 +832,48 @@ test("warns when accessing would-be masked fields when using `@unmask` directive ); }); +test("does not warn when accessing fields shared between the query and fragment", () => { + using consoleSpy = spyOnConsole("warn"); + const query = gql` + query UnmaskedQuery @unmask { + currentUser { + __typename + id + name + age + ...UserFields + email + } + } + + fragment UserFields on User { + age + email + } + `; + + const fragmentMatcher = createFragmentMatcher(new InMemoryCache()); + + const data = maskQuery( + { + currentUser: { + __typename: "User", + id: 1, + name: "Test User", + age: 30, + email: "testuser@example.com", + }, + }, + query, + fragmentMatcher + ); + + data.currentUser.age; + data.currentUser.email; + + expect(consoleSpy.warn).not.toHaveBeenCalled(); +}); + test("masks named fragments in fragment documents", () => { const fragment = gql` fragment UserFields on User { From 1fe70af0c8382abfe937b95eb7a60122120ed25d Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Tue, 16 Jul 2024 16:47:28 -0600 Subject: [PATCH 031/103] Minor tweak to test name --- src/__tests__/client.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/__tests__/client.ts b/src/__tests__/client.ts index c61ca16580b..99867c73a97 100644 --- a/src/__tests__/client.ts +++ b/src/__tests__/client.ts @@ -7403,7 +7403,7 @@ describe("data masking", () => { } ); - it("warns when accessing a fragmented field while using @unmask", async () => { + it("warns when accessing a would-be masked field while using @unmask", async () => { using consoleSpy = spyOnConsole("warn"); interface Query { From b0a8c151c0cacf55f47e92dfa1dba9561331d958 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Tue, 16 Jul 2024 16:48:24 -0600 Subject: [PATCH 032/103] Fix failing test due to change in mock call --- src/__tests__/client.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/__tests__/client.ts b/src/__tests__/client.ts index 99867c73a97..6db3c8f0e5a 100644 --- a/src/__tests__/client.ts +++ b/src/__tests__/client.ts @@ -7468,9 +7468,9 @@ describe("data masking", () => { expect(consoleSpy.warn).toHaveBeenCalledTimes(1); expect(consoleSpy.warn).toHaveBeenCalledWith( - "Accessing unmasked field '%s' on query %s. This field will not be available when masking is enabled. Please read the field from the fragment instead.", - "age" - // "'UnmaskedQuery'" + "Accessing unmasked field '%s' on %s. This field will not be available when masking is enabled. Please read the field from the fragment instead.", + "age", + "query 'UnmaskedQuery'" ); // Ensure we only warn once From 3db7c6d080772617f9cdd3e0e3104a7a185e130b Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Tue, 16 Jul 2024 16:50:14 -0600 Subject: [PATCH 033/103] Add additional check for number of warnings emitted --- src/core/__tests__/masking.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/core/__tests__/masking.test.ts b/src/core/__tests__/masking.test.ts index 64476f213d2..58739e27676 100644 --- a/src/core/__tests__/masking.test.ts +++ b/src/core/__tests__/masking.test.ts @@ -830,6 +830,12 @@ test("warns when accessing would-be masked fields when using `@unmask` directive "age", "anonymous query" ); + + data.currentUser.age; + dataFromAnonymous.currentUser.age; + + // Ensure we only warn once for each masked field + expect(consoleSpy.warn).toHaveBeenCalledTimes(2); }); test("does not warn when accessing fields shared between the query and fragment", () => { From 6de39dd70bc4fb1b7b01cd1a189833b6f7bc30ae Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Tue, 16 Jul 2024 16:51:32 -0600 Subject: [PATCH 034/103] Add test to check warnOnFieldAccess --- src/core/__tests__/masking.test.ts | 37 ++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/src/core/__tests__/masking.test.ts b/src/core/__tests__/masking.test.ts index 58739e27676..57cacbffc1e 100644 --- a/src/core/__tests__/masking.test.ts +++ b/src/core/__tests__/masking.test.ts @@ -880,6 +880,43 @@ test("does not warn when accessing fields shared between the query and fragment" expect(consoleSpy.warn).not.toHaveBeenCalled(); }); +test("disables warnings when setting warnOnFieldAccess to false", () => { + using consoleSpy = spyOnConsole("warn"); + const query = gql` + query UnmaskedQuery @unmask(warnOnFieldAccess: false) { + currentUser { + __typename + id + name + ...UserFields + } + } + + fragment UserFields on User { + age + } + `; + + const fragmentMatcher = createFragmentMatcher(new InMemoryCache()); + + const data = maskQuery( + { + currentUser: { + __typename: "User", + id: 1, + name: "Test User", + age: 30, + }, + }, + query, + fragmentMatcher + ); + + data.currentUser.age; + + expect(consoleSpy.warn).not.toHaveBeenCalled(); +}); + test("masks named fragments in fragment documents", () => { const fragment = gql` fragment UserFields on User { From abdec8e3ac18f4cb377fd053b30b8eb2ced49279 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Tue, 16 Jul 2024 16:54:22 -0600 Subject: [PATCH 035/103] Move unmasked to context --- src/core/masking.ts | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/src/core/masking.ts b/src/core/masking.ts index ca799dd59bb..d76f36a3539 100644 --- a/src/core/masking.ts +++ b/src/core/masking.ts @@ -25,6 +25,7 @@ interface MaskingContext { operationName: string | null; fragmentMap: FragmentMap; warnOnFieldAccess: boolean; + unmasked: boolean; } export function maskQuery( @@ -33,19 +34,19 @@ export function maskQuery( matchesFragment: MatchesFragmentFn ): TData { const definition = getMainDefinition(document); - const [isUnmasked, { warnOnFieldAccess }] = isUnmaskedDocument(document); + const [unmasked, { warnOnFieldAccess }] = isUnmaskedDocument(document); const context: MaskingContext = { operationName: getOperationName(document), fragmentMap: createFragmentMap(getFragmentDefinitions(document)), warnOnFieldAccess, + unmasked, }; const [masked, changed] = maskSelectionSet( data, definition.selectionSet, matchesFragment, - isUnmasked, context ); @@ -67,6 +68,7 @@ export function maskFragment( operationName: null, fragmentMap: createFragmentMap(getFragmentDefinitions(document)), warnOnFieldAccess: true, + unmasked: false, }; if (typeof fragmentName === "undefined") { @@ -92,7 +94,6 @@ export function maskFragment( data, fragment.selectionSet, matchesFragment, - false, context ); @@ -103,7 +104,6 @@ function maskSelectionSet( data: any, selectionSet: SelectionSetNode, matchesFragment: MatchesFragmentFn, - isUnmasked: boolean, context: MaskingContext ): [data: any, changed: boolean] { if (Array.isArray(data)) { @@ -114,7 +114,6 @@ function maskSelectionSet( item, selectionSet, matchesFragment, - isUnmasked, context ); changed ||= itemChanged; @@ -149,7 +148,6 @@ function maskSelectionSet( data[keyName], childSelectionSet, matchesFragment, - isUnmasked, context ); @@ -173,7 +171,6 @@ function maskSelectionSet( data, selection.selectionSet, matchesFragment, - isUnmasked, context ); @@ -189,7 +186,7 @@ function maskSelectionSet( const fragment = context.fragmentMap[selection.name.value]; return [ - isUnmasked ? + context.unmasked ? addAccessorWarnings(memo, data, fragment.selectionSet, context) : memo, true, From f178ea343df6d2aba5f9b5a6df141a5e217b8321 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Tue, 16 Jul 2024 16:55:41 -0600 Subject: [PATCH 036/103] Move matchesFragment to context --- src/core/masking.ts | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/core/masking.ts b/src/core/masking.ts index d76f36a3539..9bd4f0e3aea 100644 --- a/src/core/masking.ts +++ b/src/core/masking.ts @@ -26,6 +26,7 @@ interface MaskingContext { fragmentMap: FragmentMap; warnOnFieldAccess: boolean; unmasked: boolean; + matchesFragment: MatchesFragmentFn; } export function maskQuery( @@ -41,12 +42,12 @@ export function maskQuery( fragmentMap: createFragmentMap(getFragmentDefinitions(document)), warnOnFieldAccess, unmasked, + matchesFragment, }; const [masked, changed] = maskSelectionSet( data, definition.selectionSet, - matchesFragment, context ); @@ -69,6 +70,7 @@ export function maskFragment( fragmentMap: createFragmentMap(getFragmentDefinitions(document)), warnOnFieldAccess: true, unmasked: false, + matchesFragment, }; if (typeof fragmentName === "undefined") { @@ -93,7 +95,6 @@ export function maskFragment( const [masked, changed] = maskSelectionSet( data, fragment.selectionSet, - matchesFragment, context ); @@ -103,7 +104,6 @@ export function maskFragment( function maskSelectionSet( data: any, selectionSet: SelectionSetNode, - matchesFragment: MatchesFragmentFn, context: MaskingContext ): [data: any, changed: boolean] { if (Array.isArray(data)) { @@ -113,7 +113,6 @@ function maskSelectionSet( const [masked, itemChanged] = maskSelectionSet( item, selectionSet, - matchesFragment, context ); changed ||= itemChanged; @@ -147,7 +146,6 @@ function maskSelectionSet( const [masked, childChanged] = maskSelectionSet( data[keyName], childSelectionSet, - matchesFragment, context ); @@ -162,7 +160,7 @@ function maskSelectionSet( case Kind.INLINE_FRAGMENT: { if ( selection.typeCondition && - !matchesFragment(selection, data.__typename) + !context.matchesFragment(selection, data.__typename) ) { return [memo, changed]; } @@ -170,7 +168,6 @@ function maskSelectionSet( const [fragmentData, childChanged] = maskSelectionSet( data, selection.selectionSet, - matchesFragment, context ); From 361ee8b71fbc54cd000a6d24d9eb6b929eb243e5 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Tue, 16 Jul 2024 16:58:06 -0600 Subject: [PATCH 037/103] Use case instead of default in switch --- src/core/masking.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/masking.ts b/src/core/masking.ts index 9bd4f0e3aea..74f706fba4b 100644 --- a/src/core/masking.ts +++ b/src/core/masking.ts @@ -179,7 +179,7 @@ function maskSelectionSet( changed || childChanged, ]; } - default: + case Kind.FRAGMENT_SPREAD: const fragment = context.fragmentMap[selection.name.value]; return [ From e12cd3f6fd80b0f3a810b63dc7b05399c3402212 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Tue, 16 Jul 2024 16:59:05 -0600 Subject: [PATCH 038/103] Rename helper function --- src/core/masking.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/core/masking.ts b/src/core/masking.ts index 74f706fba4b..dbb49ac7673 100644 --- a/src/core/masking.ts +++ b/src/core/masking.ts @@ -184,7 +184,7 @@ function maskSelectionSet( return [ context.unmasked ? - addAccessorWarnings(memo, data, fragment.selectionSet, context) + unmaskFragmentFields(memo, data, fragment.selectionSet, context) : memo, true, ]; @@ -194,7 +194,7 @@ function maskSelectionSet( ); } -function addAccessorWarnings( +function unmaskFragmentFields( memo: Record, parent: Record, selectionSetNode: SelectionSetNode, From 9e4e80a2dfddd9f30bf576d23e0696a34e421628 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Tue, 16 Jul 2024 17:09:06 -0600 Subject: [PATCH 039/103] Make test for unmask a bit more complex --- src/core/__tests__/masking.test.ts | 33 ++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/src/core/__tests__/masking.test.ts b/src/core/__tests__/masking.test.ts index 57cacbffc1e..bdd1f644a72 100644 --- a/src/core/__tests__/masking.test.ts +++ b/src/core/__tests__/masking.test.ts @@ -738,6 +738,21 @@ test("does not mask fields when using `@unmask` directive", () => { fragment UserFields on User { age + profile { + __typename + email + ... @defer { + username + } + ...ProfileFields + } + } + + fragment ProfileFields on Profile { + settings { + __typename + darkMode + } } `; @@ -748,6 +763,15 @@ test("does not mask fields when using `@unmask` directive", () => { id: 1, name: "Test User", age: 30, + profile: { + __typename: "Profile", + email: "testuser@example.com", + username: "testuser", + settings: { + __typename: "Settings", + darkMode: true, + }, + }, }, }, query, @@ -760,6 +784,15 @@ test("does not mask fields when using `@unmask` directive", () => { id: 1, name: "Test User", age: 30, + profile: { + __typename: "Profile", + email: "testuser@example.com", + username: "testuser", + settings: { + __typename: "Settings", + darkMode: true, + }, + }, }, }); }); From 46195fe322a8ff41be6ab927bedb56aa2cda3a63 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Tue, 16 Jul 2024 17:29:52 -0600 Subject: [PATCH 040/103] Handle adding field accessor warnings to inline fragments and fragment spreads --- src/core/masking.ts | 37 +++++++++++++++++++++++++++++++------ 1 file changed, 31 insertions(+), 6 deletions(-) diff --git a/src/core/masking.ts b/src/core/masking.ts index dbb49ac7673..d0148a1311e 100644 --- a/src/core/masking.ts +++ b/src/core/masking.ts @@ -204,21 +204,46 @@ function unmaskFragmentFields( switch (selection.kind) { case Kind.FIELD: { const keyName = resultKeyNameFromField(selection); + const childSelectionSet = selection.selectionSet; if (keyName in memo) { return; } if (context.warnOnFieldAccess) { - return addAccessorWarning( - memo, - parent[keyName], - keyName, - context.operationName - ); + let value = parent[keyName]; + + if (childSelectionSet) { + value = unmaskFragmentFields( + memo[keyName] ?? Object.create(null), + parent[keyName] as Record, + childSelectionSet, + context + ); + } + + addAccessorWarning(memo, value, keyName, context.operationName); } else { memo[keyName] = parent[keyName]; } + + return; + } + case Kind.INLINE_FRAGMENT: { + return unmaskFragmentFields( + memo, + parent, + selection.selectionSet, + context + ); + } + case Kind.FRAGMENT_SPREAD: { + return unmaskFragmentFields( + memo, + parent, + context.fragmentMap[selection.name.value].selectionSet, + context + ); } } }); From ac1a89ea1d94ec002fa03942293afe7043c84ebe Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Tue, 16 Jul 2024 17:49:45 -0600 Subject: [PATCH 041/103] Print field path when accessing masked field --- src/core/__tests__/masking.test.ts | 97 ++++++++++++++++++++++++++++++ src/core/masking.ts | 39 ++++++++++-- 2 files changed, 132 insertions(+), 4 deletions(-) diff --git a/src/core/__tests__/masking.test.ts b/src/core/__tests__/masking.test.ts index bdd1f644a72..754f34703ea 100644 --- a/src/core/__tests__/masking.test.ts +++ b/src/core/__tests__/masking.test.ts @@ -871,6 +871,103 @@ test("warns when accessing would-be masked fields when using `@unmask` directive expect(consoleSpy.warn).toHaveBeenCalledTimes(2); }); +test("warns when accessing would-be masked fields with complex selections", () => { + using consoleSpy = spyOnConsole("warn"); + const query = gql` + query UnmaskedQuery @unmask { + currentUser { + __typename + id + name + ...UserFields + } + } + + fragment UserFields on User { + age + profile { + __typename + email + ... @defer { + username + } + ...ProfileFields + } + } + + fragment ProfileFields on Profile { + settings { + __typename + dark: darkMode + } + } + `; + + const currentUser = { + __typename: "User", + id: 1, + name: "Test User", + age: 30, + profile: { + __typename: "Profile", + email: "testuser@example.com", + username: "testuser", + settings: { + __typename: "Settings", + dark: true, + }, + }, + }; + + const fragmentMatcher = createFragmentMatcher(new InMemoryCache()); + + const data = maskQuery({ currentUser }, query, fragmentMatcher); + + data.currentUser.age; + data.currentUser.profile; + data.currentUser.profile.email; + data.currentUser.profile.username; + data.currentUser.profile.settings; + data.currentUser.profile.settings.dark; + + expect(consoleSpy.warn).toHaveBeenCalledTimes(6); + expect(consoleSpy.warn).toHaveBeenCalledWith( + "Accessing unmasked field '%s' on %s. This field will not be available when masking is enabled. Please read the field from the fragment instead.", + "currentUser.age", + "query 'UnmaskedQuery'" + ); + + expect(consoleSpy.warn).toHaveBeenCalledWith( + "Accessing unmasked field '%s' on %s. This field will not be available when masking is enabled. Please read the field from the fragment instead.", + "currentUser.profile", + "query 'UnmaskedQuery'" + ); + + expect(consoleSpy.warn).toHaveBeenCalledWith( + "Accessing unmasked field '%s' on %s. This field will not be available when masking is enabled. Please read the field from the fragment instead.", + "currentUser.profile.email", + "query 'UnmaskedQuery'" + ); + + expect(consoleSpy.warn).toHaveBeenCalledWith( + "Accessing unmasked field '%s' on %s. This field will not be available when masking is enabled. Please read the field from the fragment instead.", + "currentUser.profile.username", + "query 'UnmaskedQuery'" + ); + + expect(consoleSpy.warn).toHaveBeenCalledWith( + "Accessing unmasked field '%s' on %s. This field will not be available when masking is enabled. Please read the field from the fragment instead.", + "currentUser.profile.settings", + "query 'UnmaskedQuery'" + ); + + expect(consoleSpy.warn).toHaveBeenCalledWith( + "Accessing unmasked field '%s' on %s. This field will not be available when masking is enabled. Please read the field from the fragment instead.", + "currentUser.profile.settings.dark", + "query 'UnmaskedQuery'" + ); +}); + test("does not warn when accessing fields shared between the query and fragment", () => { using consoleSpy = spyOnConsole("warn"); const query = gql` diff --git a/src/core/masking.ts b/src/core/masking.ts index d0148a1311e..b51bd3c3da7 100644 --- a/src/core/masking.ts +++ b/src/core/masking.ts @@ -29,6 +29,8 @@ interface MaskingContext { matchesFragment: MatchesFragmentFn; } +type PathSelection = Array; + export function maskQuery( data: TData, document: TypedDocumentNode | DocumentNode, @@ -48,6 +50,7 @@ export function maskQuery( const [masked, changed] = maskSelectionSet( data, definition.selectionSet, + [], context ); @@ -95,6 +98,7 @@ export function maskFragment( const [masked, changed] = maskSelectionSet( data, fragment.selectionSet, + [], context ); @@ -104,15 +108,17 @@ export function maskFragment( function maskSelectionSet( data: any, selectionSet: SelectionSetNode, + path: PathSelection, context: MaskingContext ): [data: any, changed: boolean] { if (Array.isArray(data)) { let changed = false; - const masked = data.map((item) => { + const masked = data.map((item, index) => { const [masked, itemChanged] = maskSelectionSet( item, selectionSet, + [...path, index], context ); changed ||= itemChanged; @@ -146,6 +152,7 @@ function maskSelectionSet( const [masked, childChanged] = maskSelectionSet( data[keyName], childSelectionSet, + [...path, keyName], context ); @@ -168,6 +175,7 @@ function maskSelectionSet( const [fragmentData, childChanged] = maskSelectionSet( data, selection.selectionSet, + path, context ); @@ -184,7 +192,13 @@ function maskSelectionSet( return [ context.unmasked ? - unmaskFragmentFields(memo, data, fragment.selectionSet, context) + unmaskFragmentFields( + memo, + data, + fragment.selectionSet, + path, + context + ) : memo, true, ]; @@ -198,6 +212,7 @@ function unmaskFragmentFields( memo: Record, parent: Record, selectionSetNode: SelectionSetNode, + path: PathSelection, context: MaskingContext ) { selectionSetNode.selections.forEach((selection) => { @@ -218,11 +233,12 @@ function unmaskFragmentFields( memo[keyName] ?? Object.create(null), parent[keyName] as Record, childSelectionSet, + [...path, keyName], context ); } - addAccessorWarning(memo, value, keyName, context.operationName); + addAccessorWarning(memo, value, keyName, path, context.operationName); } else { memo[keyName] = parent[keyName]; } @@ -234,6 +250,7 @@ function unmaskFragmentFields( memo, parent, selection.selectionSet, + path, context ); } @@ -242,6 +259,7 @@ function unmaskFragmentFields( memo, parent, context.fragmentMap[selection.name.value].selectionSet, + path, context ); } @@ -255,6 +273,7 @@ function addAccessorWarning( data: Record, value: any, fieldName: string, + path: PathSelection, operationName: string | null ) { let currentValue = value; @@ -265,7 +284,7 @@ function addAccessorWarning( if (!warned) { invariant.warn( "Accessing unmasked field '%s' on %s. This field will not be available when masking is enabled. Please read the field from the fragment instead.", - fieldName, + getPathString([...path, fieldName]), operationName ? `query '${operationName}'` : "anonymous query" ); warned = true; @@ -280,3 +299,15 @@ function addAccessorWarning( configurable: true, }); } + +function getPathString(path: PathSelection) { + return path.reduce((memo, segment, index) => { + if (typeof segment === "number") { + return `${memo}[${segment}]`; + } + + return index === 0 || memo.at(-1) === "]" ? + memo + segment + : `${memo}.${segment}`; + }, ""); +} From 0a9959a6518df73f3b546580c74e2b7dd019d151 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Tue, 16 Jul 2024 17:50:57 -0600 Subject: [PATCH 042/103] Adjust test based on change to message change --- src/core/__tests__/masking.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/core/__tests__/masking.test.ts b/src/core/__tests__/masking.test.ts index 754f34703ea..dc419208b71 100644 --- a/src/core/__tests__/masking.test.ts +++ b/src/core/__tests__/masking.test.ts @@ -851,7 +851,7 @@ test("warns when accessing would-be masked fields when using `@unmask` directive expect(consoleSpy.warn).toHaveBeenCalledTimes(1); expect(consoleSpy.warn).toHaveBeenCalledWith( "Accessing unmasked field '%s' on %s. This field will not be available when masking is enabled. Please read the field from the fragment instead.", - "age", + "currentUser.age", "query 'UnmaskedQuery'" ); @@ -860,7 +860,7 @@ test("warns when accessing would-be masked fields when using `@unmask` directive expect(consoleSpy.warn).toHaveBeenCalledTimes(2); expect(consoleSpy.warn).toHaveBeenCalledWith( "Accessing unmasked field '%s' on %s. This field will not be available when masking is enabled. Please read the field from the fragment instead.", - "age", + "currentUser.age", "anonymous query" ); From b5a3b55bacd65d00af2fe3cadc238fcf698442e3 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Tue, 16 Jul 2024 17:56:20 -0600 Subject: [PATCH 043/103] Update warning message --- src/__tests__/client.ts | 6 ++-- src/core/__tests__/masking.test.ts | 48 +++++++++++++++--------------- src/core/masking.ts | 6 ++-- 3 files changed, 30 insertions(+), 30 deletions(-) diff --git a/src/__tests__/client.ts b/src/__tests__/client.ts index 6db3c8f0e5a..25e99d8433a 100644 --- a/src/__tests__/client.ts +++ b/src/__tests__/client.ts @@ -7468,9 +7468,9 @@ describe("data masking", () => { expect(consoleSpy.warn).toHaveBeenCalledTimes(1); expect(consoleSpy.warn).toHaveBeenCalledWith( - "Accessing unmasked field '%s' on %s. This field will not be available when masking is enabled. Please read the field from the fragment instead.", - "age", - "query 'UnmaskedQuery'" + "Accessing unmasked field on %s at path '%s'. This field will not be available when masking is enabled. Please read the field from the fragment instead.", + "query 'UnmaskedQuery'", + "currentUser.age" ); // Ensure we only warn once diff --git a/src/core/__tests__/masking.test.ts b/src/core/__tests__/masking.test.ts index dc419208b71..db9c8cbc78e 100644 --- a/src/core/__tests__/masking.test.ts +++ b/src/core/__tests__/masking.test.ts @@ -850,18 +850,18 @@ test("warns when accessing would-be masked fields when using `@unmask` directive expect(consoleSpy.warn).toHaveBeenCalledTimes(1); expect(consoleSpy.warn).toHaveBeenCalledWith( - "Accessing unmasked field '%s' on %s. This field will not be available when masking is enabled. Please read the field from the fragment instead.", - "currentUser.age", - "query 'UnmaskedQuery'" + "Accessing unmasked field on %s at path '%s'. This field will not be available when masking is enabled. Please read the field from the fragment instead.", + "query 'UnmaskedQuery'", + "currentUser.age" ); dataFromAnonymous.currentUser.age; expect(consoleSpy.warn).toHaveBeenCalledTimes(2); expect(consoleSpy.warn).toHaveBeenCalledWith( - "Accessing unmasked field '%s' on %s. This field will not be available when masking is enabled. Please read the field from the fragment instead.", - "currentUser.age", - "anonymous query" + "Accessing unmasked field on %s at path '%s'. This field will not be available when masking is enabled. Please read the field from the fragment instead.", + "anonymous query", + "currentUser.age" ); data.currentUser.age; @@ -932,39 +932,39 @@ test("warns when accessing would-be masked fields with complex selections", () = expect(consoleSpy.warn).toHaveBeenCalledTimes(6); expect(consoleSpy.warn).toHaveBeenCalledWith( - "Accessing unmasked field '%s' on %s. This field will not be available when masking is enabled. Please read the field from the fragment instead.", - "currentUser.age", - "query 'UnmaskedQuery'" + "Accessing unmasked field on %s at path '%s'. This field will not be available when masking is enabled. Please read the field from the fragment instead.", + "query 'UnmaskedQuery'", + "currentUser.age" ); expect(consoleSpy.warn).toHaveBeenCalledWith( - "Accessing unmasked field '%s' on %s. This field will not be available when masking is enabled. Please read the field from the fragment instead.", - "currentUser.profile", - "query 'UnmaskedQuery'" + "Accessing unmasked field on %s at path '%s'. This field will not be available when masking is enabled. Please read the field from the fragment instead.", + "query 'UnmaskedQuery'", + "currentUser.profile" ); expect(consoleSpy.warn).toHaveBeenCalledWith( - "Accessing unmasked field '%s' on %s. This field will not be available when masking is enabled. Please read the field from the fragment instead.", - "currentUser.profile.email", - "query 'UnmaskedQuery'" + "Accessing unmasked field on %s at path '%s'. This field will not be available when masking is enabled. Please read the field from the fragment instead.", + "query 'UnmaskedQuery'", + "currentUser.profile.email" ); expect(consoleSpy.warn).toHaveBeenCalledWith( - "Accessing unmasked field '%s' on %s. This field will not be available when masking is enabled. Please read the field from the fragment instead.", - "currentUser.profile.username", - "query 'UnmaskedQuery'" + "Accessing unmasked field on %s at path '%s'. This field will not be available when masking is enabled. Please read the field from the fragment instead.", + "query 'UnmaskedQuery'", + "currentUser.profile.username" ); expect(consoleSpy.warn).toHaveBeenCalledWith( - "Accessing unmasked field '%s' on %s. This field will not be available when masking is enabled. Please read the field from the fragment instead.", - "currentUser.profile.settings", - "query 'UnmaskedQuery'" + "Accessing unmasked field on %s at path '%s'. This field will not be available when masking is enabled. Please read the field from the fragment instead.", + "query 'UnmaskedQuery'", + "currentUser.profile.settings" ); expect(consoleSpy.warn).toHaveBeenCalledWith( - "Accessing unmasked field '%s' on %s. This field will not be available when masking is enabled. Please read the field from the fragment instead.", - "currentUser.profile.settings.dark", - "query 'UnmaskedQuery'" + "Accessing unmasked field on %s at path '%s'. This field will not be available when masking is enabled. Please read the field from the fragment instead.", + "query 'UnmaskedQuery'", + "currentUser.profile.settings.dark" ); }); diff --git a/src/core/masking.ts b/src/core/masking.ts index b51bd3c3da7..f25f30048ea 100644 --- a/src/core/masking.ts +++ b/src/core/masking.ts @@ -283,9 +283,9 @@ function addAccessorWarning( get() { if (!warned) { invariant.warn( - "Accessing unmasked field '%s' on %s. This field will not be available when masking is enabled. Please read the field from the fragment instead.", - getPathString([...path, fieldName]), - operationName ? `query '${operationName}'` : "anonymous query" + "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.", + operationName ? `query '${operationName}'` : "anonymous query", + getPathString([...path, fieldName]) ); warned = true; } From 92b85a2f3c2646ccaeeae721312df4dfca364631 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Tue, 16 Jul 2024 18:00:46 -0600 Subject: [PATCH 044/103] Ensure warnings for unmasked fields on arrays --- src/core/__tests__/masking.test.ts | 47 ++++++++++++++++++++++++++++++ src/core/masking.ts | 4 +-- 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/src/core/__tests__/masking.test.ts b/src/core/__tests__/masking.test.ts index db9c8cbc78e..299609500f0 100644 --- a/src/core/__tests__/masking.test.ts +++ b/src/core/__tests__/masking.test.ts @@ -871,6 +871,53 @@ test("warns when accessing would-be masked fields when using `@unmask` directive expect(consoleSpy.warn).toHaveBeenCalledTimes(2); }); +test("warns when accessing would-be masked fields with in arrays", () => { + using consoleSpy = spyOnConsole("warn"); + const query = gql` + query UnmaskedQuery @unmask { + users { + __typename + id + name + ...UserFields + } + } + + fragment UserFields on User { + age + } + `; + + const fragmentMatcher = createFragmentMatcher(new InMemoryCache()); + + const data = maskQuery( + { + users: [ + { __typename: "User", id: 1, name: "John Doe", age: 30 }, + { __typename: "User", id: 2, name: "Jane Doe", age: 30 }, + ], + }, + query, + fragmentMatcher + ); + + data.users[0].age; + data.users[1].age; + + expect(consoleSpy.warn).toHaveBeenCalledTimes(2); + 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'", + "users[0].age" + ); + + 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'", + "users[1].age" + ); +}); + test("warns when accessing would-be masked fields with complex selections", () => { using consoleSpy = spyOnConsole("warn"); const query = gql` diff --git a/src/core/masking.ts b/src/core/masking.ts index f25f30048ea..faf39b0f058 100644 --- a/src/core/masking.ts +++ b/src/core/masking.ts @@ -306,8 +306,6 @@ function getPathString(path: PathSelection) { return `${memo}[${segment}]`; } - return index === 0 || memo.at(-1) === "]" ? - memo + segment - : `${memo}.${segment}`; + return index === 0 ? memo + segment : `${memo}.${segment}`; }, ""); } From d6672bd1a611e9d7d3fc1dc759f7f99b0edd1748 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Tue, 16 Jul 2024 18:01:53 -0600 Subject: [PATCH 045/103] Tweak test descriptions --- src/__tests__/client.ts | 2 +- src/core/__tests__/masking.test.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/__tests__/client.ts b/src/__tests__/client.ts index 25e99d8433a..ccd7502bec5 100644 --- a/src/__tests__/client.ts +++ b/src/__tests__/client.ts @@ -7403,7 +7403,7 @@ describe("data masking", () => { } ); - it("warns when accessing a would-be masked field while using @unmask", async () => { + it("warns when accessing a unmasked field while using @unmask", async () => { using consoleSpy = spyOnConsole("warn"); interface Query { diff --git a/src/core/__tests__/masking.test.ts b/src/core/__tests__/masking.test.ts index 299609500f0..0b7a1ed546a 100644 --- a/src/core/__tests__/masking.test.ts +++ b/src/core/__tests__/masking.test.ts @@ -797,7 +797,7 @@ test("does not mask fields when using `@unmask` directive", () => { }); }); -test("warns when accessing would-be masked fields when using `@unmask` directive", () => { +test("warns when accessing unmasked fields when using `@unmask` directive", () => { using consoleSpy = spyOnConsole("warn"); const query = gql` query UnmaskedQuery @unmask { @@ -871,7 +871,7 @@ test("warns when accessing would-be masked fields when using `@unmask` directive expect(consoleSpy.warn).toHaveBeenCalledTimes(2); }); -test("warns when accessing would-be masked fields with in arrays", () => { +test("warns when accessing unmasked fields in arrays", () => { using consoleSpy = spyOnConsole("warn"); const query = gql` query UnmaskedQuery @unmask { @@ -918,7 +918,7 @@ test("warns when accessing would-be masked fields with in arrays", () => { ); }); -test("warns when accessing would-be masked fields with complex selections", () => { +test("warns when accessing unmasked fields with complex selections", () => { using consoleSpy = spyOnConsole("warn"); const query = gql` query UnmaskedQuery @unmask { From 1bdc0a1241bae8186fc7bfba064223ec90eca72d Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Tue, 16 Jul 2024 18:12:01 -0600 Subject: [PATCH 046/103] Handle unmasking and warnings on arrays --- src/core/__tests__/masking.test.ts | 77 +++++++++++++++++++++++++++++- src/core/masking.ts | 12 +++++ 2 files changed, 87 insertions(+), 2 deletions(-) diff --git a/src/core/__tests__/masking.test.ts b/src/core/__tests__/masking.test.ts index 0b7a1ed546a..ca6522106bc 100644 --- a/src/core/__tests__/masking.test.ts +++ b/src/core/__tests__/masking.test.ts @@ -746,6 +746,11 @@ test("does not mask fields when using `@unmask` directive", () => { } ...ProfileFields } + skills { + __typename + name + ...SkillFields + } } fragment ProfileFields on Profile { @@ -754,6 +759,10 @@ test("does not mask fields when using `@unmask` directive", () => { darkMode } } + + fragment SkillFields on Skill { + description + } `; const data = maskQuery( @@ -772,6 +781,18 @@ test("does not mask fields when using `@unmask` directive", () => { darkMode: true, }, }, + skills: [ + { + __typename: "Skill", + name: "Skill 1", + description: "Skill 1 description", + }, + { + __typename: "Skill", + name: "Skill 2", + description: "Skill 2 description", + }, + ], }, }, query, @@ -793,6 +814,18 @@ test("does not mask fields when using `@unmask` directive", () => { darkMode: true, }, }, + skills: [ + { + __typename: "Skill", + name: "Skill 1", + description: "Skill 1 description", + }, + { + __typename: "Skill", + name: "Skill 2", + description: "Skill 2 description", + }, + ], }, }); }); @@ -940,6 +973,11 @@ test("warns when accessing unmasked fields with complex selections", () => { } ...ProfileFields } + skills { + __typename + name + ...SkillFields + } } fragment ProfileFields on Profile { @@ -948,6 +986,10 @@ test("warns when accessing unmasked fields with complex selections", () => { dark: darkMode } } + + fragment SkillFields on Skill { + description + } `; const currentUser = { @@ -964,6 +1006,18 @@ test("warns when accessing unmasked fields with complex selections", () => { dark: true, }, }, + skills: [ + { + __typename: "Skill", + name: "Skill 1", + description: "Skill 1 description", + }, + { + __typename: "Skill", + name: "Skill 2", + description: "Skill 2 description", + }, + ], }; const fragmentMatcher = createFragmentMatcher(new InMemoryCache()); @@ -971,13 +1025,14 @@ test("warns when accessing unmasked fields with complex selections", () => { const data = maskQuery({ currentUser }, query, fragmentMatcher); data.currentUser.age; - data.currentUser.profile; data.currentUser.profile.email; data.currentUser.profile.username; data.currentUser.profile.settings; data.currentUser.profile.settings.dark; + data.currentUser.skills[0].description; + data.currentUser.skills[1].description; - expect(consoleSpy.warn).toHaveBeenCalledTimes(6); + expect(consoleSpy.warn).toHaveBeenCalledTimes(9); 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'", @@ -1013,6 +1068,24 @@ test("warns when accessing unmasked fields with complex selections", () => { "query 'UnmaskedQuery'", "currentUser.profile.settings.dark" ); + + 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.skills" + ); + + 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.skills[0].description" + ); + + 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.skills[1].description" + ); }); test("does not warn when accessing fields shared between the query and fragment", () => { diff --git a/src/core/masking.ts b/src/core/masking.ts index faf39b0f058..861bcf28c55 100644 --- a/src/core/masking.ts +++ b/src/core/masking.ts @@ -215,6 +215,18 @@ function unmaskFragmentFields( path: PathSelection, context: MaskingContext ) { + if (Array.isArray(parent)) { + return parent.map((item, index): unknown => { + return unmaskFragmentFields( + memo[index] ?? Object.create(null), + item, + selectionSetNode, + [...path, index], + context + ); + }); + } + selectionSetNode.selections.forEach((selection) => { switch (selection.kind) { case Kind.FIELD: { From bd873cbb8596d307d1d999a14c78200b158792e3 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Tue, 16 Jul 2024 22:00:10 -0600 Subject: [PATCH 047/103] Update api report and size limits --- .api-reports/api-report-utilities.api.md | 5 +++++ .size-limits.json | 4 ++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/.api-reports/api-report-utilities.api.md b/.api-reports/api-report-utilities.api.md index 001b32b49a4..4b8c9657b24 100644 --- a/.api-reports/api-report-utilities.api.md +++ b/.api-reports/api-report-utilities.api.md @@ -1469,6 +1469,11 @@ export type IsStrictlyAny = UnionToIntersection> extends never // @public (undocumented) export function isSubscriptionOperation(document: DocumentNode): boolean; +// @public (undocumented) +export function isUnmaskedDocument(document: DocumentNode): [isUnmasked: boolean, options: { + warnOnFieldAccess: boolean; +}]; + // @public (undocumented) export function iterateObserversSafely(observers: Set>, method: keyof Observer, argument?: A): void; diff --git a/.size-limits.json b/.size-limits.json index 7aac2be3e4d..229413783ce 100644 --- a/.size-limits.json +++ b/.size-limits.json @@ -1,4 +1,4 @@ { - "dist/apollo-client.min.cjs": 39923, - "import { ApolloClient, InMemoryCache, HttpLink } from \"dist/index.js\" (production)": 33211 + "dist/apollo-client.min.cjs": 40443, + "import { ApolloClient, InMemoryCache, HttpLink } from \"dist/index.js\" (production)": 33696 } From 961daf687f233b4dc9370f28ce1503de897e8dd6 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Tue, 16 Jul 2024 22:02:56 -0600 Subject: [PATCH 048/103] Fix reference to undefined tag --- src/core/ApolloClient.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/ApolloClient.ts b/src/core/ApolloClient.ts index 49b68bf1139..af8863c9e96 100644 --- a/src/core/ApolloClient.ts +++ b/src/core/ApolloClient.ts @@ -124,7 +124,7 @@ export interface ApolloClientOptions { /** * Determines if data masking is enabled for the client. * - * @default false + * @defaultValue false */ dataMasking?: boolean; } From dfefaac7ebbb575bebc9912747dd8e24f2bc4db5 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Tue, 16 Jul 2024 22:05:36 -0600 Subject: [PATCH 049/103] Remove unneeded concat --- src/core/masking.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/masking.ts b/src/core/masking.ts index 861bcf28c55..8e3ce7c0368 100644 --- a/src/core/masking.ts +++ b/src/core/masking.ts @@ -318,6 +318,6 @@ function getPathString(path: PathSelection) { return `${memo}[${segment}]`; } - return index === 0 ? memo + segment : `${memo}.${segment}`; + return index === 0 ? segment : `${memo}.${segment}`; }, ""); } From 32f94e4906bc69d7b054e869f9364bbdbeb0da56 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Thu, 18 Jul 2024 10:12:29 -0600 Subject: [PATCH 050/103] Add helper for determining if fragment is masked --- src/utilities/graphql/__tests__/directives.ts | 68 +++++++++++++++++++ src/utilities/graphql/directives.ts | 9 +++ src/utilities/index.ts | 1 + 3 files changed, 78 insertions(+) diff --git a/src/utilities/graphql/__tests__/directives.ts b/src/utilities/graphql/__tests__/directives.ts index bbb9d047be5..974ad71eef5 100644 --- a/src/utilities/graphql/__tests__/directives.ts +++ b/src/utilities/graphql/__tests__/directives.ts @@ -8,8 +8,11 @@ import { hasAnyDirectives, hasAllDirectives, isUnmaskedDocument, + isUnmaskedFragment, } from "../directives"; import { spyOnConsole } from "../../../testing/internal"; +import { Kind } from "graphql"; +import type { FragmentSpreadNode } from "graphql"; describe("hasDirectives", () => { it("should allow searching the ast for a directive", () => { @@ -644,3 +647,68 @@ describe("isUnmaskedDocument", () => { ); }); }); + +describe("isUnmaskedFragment", () => { + it("returns true when @unmask used on fragment node", () => { + const fragmentNode: FragmentSpreadNode = { + kind: Kind.FRAGMENT_SPREAD, + name: { kind: Kind.NAME, value: "MyFragment" }, + directives: [ + { kind: Kind.DIRECTIVE, name: { kind: Kind.NAME, value: "unmask" } }, + ], + }; + + const isUnmasked = isUnmaskedFragment(fragmentNode); + + expect(isUnmasked).toBe(true); + }); + + it("returns false when no directives are present", () => { + const fragmentNode: FragmentSpreadNode = { + kind: Kind.FRAGMENT_SPREAD, + name: { kind: Kind.NAME, value: "MyFragment" }, + }; + + const isUnmasked = isUnmaskedFragment(fragmentNode); + + expect(isUnmasked).toBe(false); + }); + + it("returns false when a different directive is used", () => { + const fragmentNode: FragmentSpreadNode = { + kind: Kind.FRAGMENT_SPREAD, + name: { kind: Kind.NAME, value: "MyFragment" }, + directives: [ + { + kind: Kind.DIRECTIVE, + name: { kind: Kind.NAME, value: "myDirective" }, + }, + ], + }; + + const isUnmasked = isUnmaskedFragment(fragmentNode); + + expect(isUnmasked).toBe(false); + }); + + it("returns true when used with other directives", () => { + const fragmentNode: FragmentSpreadNode = { + kind: Kind.FRAGMENT_SPREAD, + name: { kind: Kind.NAME, value: "MyFragment" }, + directives: [ + { + kind: Kind.DIRECTIVE, + name: { kind: Kind.NAME, value: "myDirective" }, + }, + { + kind: Kind.DIRECTIVE, + name: { kind: Kind.NAME, value: "unmask" }, + }, + ], + }; + + const isUnmasked = isUnmaskedFragment(fragmentNode); + + expect(isUnmasked).toBe(true); + }); +}); diff --git a/src/utilities/graphql/directives.ts b/src/utilities/graphql/directives.ts index ab9bfe6b40e..ef0b1ae38e6 100644 --- a/src/utilities/graphql/directives.ts +++ b/src/utilities/graphql/directives.ts @@ -11,6 +11,7 @@ import type { ArgumentNode, ValueNode, ASTNode, + FragmentSpreadNode, } from "graphql"; import { visit, BREAK, Kind } from "graphql"; @@ -208,3 +209,11 @@ export function isUnmaskedDocument( return [masked, { warnOnFieldAccess }]; } + +export function isUnmaskedFragment(fragment: FragmentSpreadNode) { + if (!fragment.directives) { + return false; + } + + return fragment.directives.some(({ name }) => name.value === "unmask"); +} diff --git a/src/utilities/index.ts b/src/utilities/index.ts index c3c62e8d292..2eab09a0c73 100644 --- a/src/utilities/index.ts +++ b/src/utilities/index.ts @@ -13,6 +13,7 @@ export { getDirectiveNames, getInclusionDirectives, isUnmaskedDocument, + isUnmaskedFragment, } from "./graphql/directives.js"; export type { DocumentTransformCacheKey } from "./graphql/DocumentTransform.js"; From 00dc2d9e0d78034baaeb7f6ea5b547924fe75042 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Thu, 18 Jul 2024 10:20:24 -0600 Subject: [PATCH 051/103] Pivot to unmask at fragment level --- src/core/__tests__/masking.test.ts | 40 +++++++++++++++--------------- src/core/masking.ts | 11 +++----- 2 files changed, 24 insertions(+), 27 deletions(-) diff --git a/src/core/__tests__/masking.test.ts b/src/core/__tests__/masking.test.ts index ca6522106bc..05039c62ad0 100644 --- a/src/core/__tests__/masking.test.ts +++ b/src/core/__tests__/masking.test.ts @@ -722,17 +722,17 @@ test("maintains referential equality the entire result if there are no fragments expect(data).toBe(originalData); }); -test("does not mask fields when using `@unmask` directive", () => { +test("does not mask named fragment fields when using `@unmask` directive", () => { // Silence masked field access warning using _ = spyOnConsole("warn"); const query = gql` - query UnmaskedQuery @unmask { + query UnmaskedQuery { currentUser { __typename id name - ...UserFields + ...UserFields @unmask } } @@ -744,12 +744,12 @@ test("does not mask fields when using `@unmask` directive", () => { ... @defer { username } - ...ProfileFields + ...ProfileFields @unmask } skills { __typename name - ...SkillFields + ...SkillFields @unmask } } @@ -833,12 +833,12 @@ test("does not mask fields when using `@unmask` directive", () => { test("warns when accessing unmasked fields when using `@unmask` directive", () => { using consoleSpy = spyOnConsole("warn"); const query = gql` - query UnmaskedQuery @unmask { + query UnmaskedQuery { currentUser { __typename id name - ...UserFields + ...UserFields @unmask } } @@ -848,12 +848,12 @@ test("warns when accessing unmasked fields when using `@unmask` directive", () = `; const anonymousQuery = gql` - query @unmask { + query { currentUser { __typename id name - ...UserFields + ...UserFields @unmask } } @@ -907,12 +907,12 @@ test("warns when accessing unmasked fields when using `@unmask` directive", () = test("warns when accessing unmasked fields in arrays", () => { using consoleSpy = spyOnConsole("warn"); const query = gql` - query UnmaskedQuery @unmask { + query UnmaskedQuery { users { __typename id name - ...UserFields + ...UserFields @unmask } } @@ -954,12 +954,12 @@ test("warns when accessing unmasked fields in arrays", () => { test("warns when accessing unmasked fields with complex selections", () => { using consoleSpy = spyOnConsole("warn"); const query = gql` - query UnmaskedQuery @unmask { + query UnmaskedQuery { currentUser { __typename id name - ...UserFields + ...UserFields @unmask } } @@ -971,12 +971,12 @@ test("warns when accessing unmasked fields with complex selections", () => { ... @defer { username } - ...ProfileFields + ...ProfileFields @unmask } skills { __typename name - ...SkillFields + ...SkillFields @unmask } } @@ -1091,13 +1091,13 @@ test("warns when accessing unmasked fields with complex selections", () => { test("does not warn when accessing fields shared between the query and fragment", () => { using consoleSpy = spyOnConsole("warn"); const query = gql` - query UnmaskedQuery @unmask { + query UnmaskedQuery { currentUser { __typename id name age - ...UserFields + ...UserFields @unmask email } } @@ -1130,15 +1130,15 @@ test("does not warn when accessing fields shared between the query and fragment" expect(consoleSpy.warn).not.toHaveBeenCalled(); }); -test("disables warnings when setting warnOnFieldAccess to false", () => { +test.skip("disables warnings when setting warnOnFieldAccess to false", () => { using consoleSpy = spyOnConsole("warn"); const query = gql` - query UnmaskedQuery @unmask(warnOnFieldAccess: false) { + query UnmaskedQuery { currentUser { __typename id name - ...UserFields + ...UserFields @unmask(warnOnFieldAccess: false) } } diff --git a/src/core/masking.ts b/src/core/masking.ts index 8e3ce7c0368..f7a1b8e27e7 100644 --- a/src/core/masking.ts +++ b/src/core/masking.ts @@ -8,9 +8,9 @@ import { createFragmentMap, getMainDefinition, resultKeyNameFromField, - isUnmaskedDocument, getFragmentDefinitions, getOperationName, + isUnmaskedFragment, } from "../utilities/index.js"; import type { FragmentMap } from "../utilities/index.js"; import type { DocumentNode, TypedDocumentNode } from "./index.js"; @@ -25,7 +25,6 @@ interface MaskingContext { operationName: string | null; fragmentMap: FragmentMap; warnOnFieldAccess: boolean; - unmasked: boolean; matchesFragment: MatchesFragmentFn; } @@ -37,13 +36,11 @@ export function maskQuery( matchesFragment: MatchesFragmentFn ): TData { const definition = getMainDefinition(document); - const [unmasked, { warnOnFieldAccess }] = isUnmaskedDocument(document); const context: MaskingContext = { operationName: getOperationName(document), fragmentMap: createFragmentMap(getFragmentDefinitions(document)), - warnOnFieldAccess, - unmasked, + warnOnFieldAccess: true, matchesFragment, }; @@ -72,7 +69,6 @@ export function maskFragment( operationName: null, fragmentMap: createFragmentMap(getFragmentDefinitions(document)), warnOnFieldAccess: true, - unmasked: false, matchesFragment, }; @@ -189,9 +185,10 @@ function maskSelectionSet( } case Kind.FRAGMENT_SPREAD: const fragment = context.fragmentMap[selection.name.value]; + const unmasked = isUnmaskedFragment(selection); return [ - context.unmasked ? + unmasked ? unmaskFragmentFields( memo, data, From e5ed0ae2e3b8482aa9f93626a0c7911ac3f92cd2 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Thu, 18 Jul 2024 11:17:47 -0600 Subject: [PATCH 052/103] Return a masking mode for named fragment instead --- src/utilities/graphql/__tests__/directives.ts | 168 ++++++++++++------ src/utilities/graphql/directives.ts | 41 ++++- src/utilities/index.ts | 2 +- 3 files changed, 155 insertions(+), 56 deletions(-) diff --git a/src/utilities/graphql/__tests__/directives.ts b/src/utilities/graphql/__tests__/directives.ts index 974ad71eef5..ef39d532efa 100644 --- a/src/utilities/graphql/__tests__/directives.ts +++ b/src/utilities/graphql/__tests__/directives.ts @@ -8,11 +8,11 @@ import { hasAnyDirectives, hasAllDirectives, isUnmaskedDocument, - isUnmaskedFragment, + getFragmentMaskMode, } from "../directives"; import { spyOnConsole } from "../../../testing/internal"; -import { Kind } from "graphql"; -import type { FragmentSpreadNode } from "graphql"; +import { BREAK, visit } from "graphql"; +import type { DocumentNode, FragmentSpreadNode } from "graphql"; describe("hasDirectives", () => { it("should allow searching the ast for a directive", () => { @@ -648,67 +648,133 @@ describe("isUnmaskedDocument", () => { }); }); -describe("isUnmaskedFragment", () => { - it("returns true when @unmask used on fragment node", () => { - const fragmentNode: FragmentSpreadNode = { - kind: Kind.FRAGMENT_SPREAD, - name: { kind: Kind.NAME, value: "MyFragment" }, - directives: [ - { kind: Kind.DIRECTIVE, name: { kind: Kind.NAME, value: "unmask" } }, - ], - }; +describe("getFragmentMaskMode", () => { + it("returns 'unmask' when @unmask used on fragment node", () => { + const fragmentNode = getFragmentSpreadNode(gql` + query { + ...MyFragment @unmask + } + `); - const isUnmasked = isUnmaskedFragment(fragmentNode); + const mode = getFragmentMaskMode(fragmentNode); - expect(isUnmasked).toBe(true); + expect(mode).toBe("unmask"); }); - it("returns false when no directives are present", () => { - const fragmentNode: FragmentSpreadNode = { - kind: Kind.FRAGMENT_SPREAD, - name: { kind: Kind.NAME, value: "MyFragment" }, - }; + it("returns 'mask' when no directives are present", () => { + const fragmentNode = getFragmentSpreadNode(gql` + query { + ...MyFragment + } + `); - const isUnmasked = isUnmaskedFragment(fragmentNode); + const mode = getFragmentMaskMode(fragmentNode); - expect(isUnmasked).toBe(false); + expect(mode).toBe("mask"); }); - it("returns false when a different directive is used", () => { - const fragmentNode: FragmentSpreadNode = { - kind: Kind.FRAGMENT_SPREAD, - name: { kind: Kind.NAME, value: "MyFragment" }, - directives: [ - { - kind: Kind.DIRECTIVE, - name: { kind: Kind.NAME, value: "myDirective" }, - }, - ], - }; + it("returns 'mask' when a different directive is used", () => { + const fragmentNode = getFragmentSpreadNode(gql` + query { + ...MyFragment @myDirective + } + `); - const isUnmasked = isUnmaskedFragment(fragmentNode); + const mode = getFragmentMaskMode(fragmentNode); - expect(isUnmasked).toBe(false); + expect(mode).toBe("mask"); }); - it("returns true when used with other directives", () => { - const fragmentNode: FragmentSpreadNode = { - kind: Kind.FRAGMENT_SPREAD, - name: { kind: Kind.NAME, value: "MyFragment" }, - directives: [ - { - kind: Kind.DIRECTIVE, - name: { kind: Kind.NAME, value: "myDirective" }, - }, - { - kind: Kind.DIRECTIVE, - name: { kind: Kind.NAME, value: "unmask" }, - }, - ], - }; + it("returns 'unmask' when used with other directives", () => { + const fragmentNode = getFragmentSpreadNode(gql` + query { + ...MyFragment @myDirective @unmask + } + `); - const isUnmasked = isUnmaskedFragment(fragmentNode); + const mode = getFragmentMaskMode(fragmentNode); - expect(isUnmasked).toBe(true); + expect(mode).toBe("unmask"); + }); + + it("returns 'migrate' when passing mode: 'migrate' as argument", () => { + const fragmentNode = getFragmentSpreadNode(gql` + query { + ...MyFragment @unmask(mode: "migrate") + } + `); + + const mode = getFragmentMaskMode(fragmentNode); + + expect(mode).toBe("migrate"); + }); + + it("warns and returns 'unmask' when using variable for mode argument", () => { + using _ = spyOnConsole("warn"); + const fragmentNode = getFragmentSpreadNode(gql` + query ($mode: String!) { + ...MyFragment @unmask(mode: $mode) + } + `); + + const mode = getFragmentMaskMode(fragmentNode); + + expect(console.warn).toHaveBeenCalledTimes(1); + expect(console.warn).toHaveBeenCalledWith( + "@unmask 'mode' argument does not support variables." + ); + expect(mode).toBe("unmask"); + }); + + it("warns and returns 'unmask' when passing a non-string argument to mode", () => { + using _ = spyOnConsole("warn"); + const fragmentNode = getFragmentSpreadNode(gql` + query { + ...MyFragment @unmask(mode: true) + } + `); + + const mode = getFragmentMaskMode(fragmentNode); + + expect(console.warn).toHaveBeenCalledTimes(1); + expect(console.warn).toHaveBeenCalledWith( + "@unmask 'mode' argument must be of type string." + ); + expect(mode).toBe("unmask"); + }); + + it("warns and returns 'unmask' when passing a value other than 'migrate' to mode", () => { + using _ = spyOnConsole("warn"); + const fragmentNode = getFragmentSpreadNode(gql` + query { + ...MyFragment @unmask(mode: "invalid") + } + `); + + const mode = getFragmentMaskMode(fragmentNode); + + expect(console.warn).toHaveBeenCalledTimes(1); + expect(console.warn).toHaveBeenCalledWith( + "@unmask 'mode' argument does not recognize value '%s'.", + "invalid" + ); + expect(mode).toBe("unmask"); }); }); + +function getFragmentSpreadNode(document: DocumentNode): FragmentSpreadNode { + let fragmentSpreadNode: FragmentSpreadNode | undefined = undefined; + + visit(document, { + FragmentSpread: (node) => { + fragmentSpreadNode = node; + return BREAK; + }, + }); + + if (!fragmentSpreadNode) { + throw new Error("Must give a document with a fragment spread"); + } + + return fragmentSpreadNode; +} diff --git a/src/utilities/graphql/directives.ts b/src/utilities/graphql/directives.ts index ef0b1ae38e6..211fc614402 100644 --- a/src/utilities/graphql/directives.ts +++ b/src/utilities/graphql/directives.ts @@ -210,10 +210,43 @@ export function isUnmaskedDocument( return [masked, { warnOnFieldAccess }]; } -export function isUnmaskedFragment(fragment: FragmentSpreadNode) { - if (!fragment.directives) { - return false; +export function getFragmentMaskMode( + fragment: FragmentSpreadNode +): "mask" | "migrate" | "unmask" { + const directive = fragment.directives?.find( + ({ name }) => name.value === "unmask" + ); + + if (!directive) { + return "mask"; + } + + const modeArg = directive.arguments?.find( + ({ name }) => name.value === "mode" + ); + + if (__DEV__) { + if (modeArg) { + if (modeArg.value.kind === Kind.VARIABLE) { + invariant.warn("@unmask 'mode' argument does not support variables."); + } else if (modeArg.value.kind !== Kind.STRING) { + invariant.warn("@unmask 'mode' argument must be of type string."); + } else if (modeArg.value.value !== "migrate") { + invariant.warn( + "@unmask 'mode' argument does not recognize value '%s'.", + modeArg.value.value + ); + } + } + } + + if ( + modeArg && + "value" in modeArg.value && + modeArg.value.value === "migrate" + ) { + return "migrate"; } - return fragment.directives.some(({ name }) => name.value === "unmask"); + return "unmask"; } diff --git a/src/utilities/index.ts b/src/utilities/index.ts index 2eab09a0c73..4d4a8094fc3 100644 --- a/src/utilities/index.ts +++ b/src/utilities/index.ts @@ -13,7 +13,7 @@ export { getDirectiveNames, getInclusionDirectives, isUnmaskedDocument, - isUnmaskedFragment, + getFragmentMaskMode, } from "./graphql/directives.js"; export type { DocumentTransformCacheKey } from "./graphql/DocumentTransform.js"; From 004909c76b04cb7369f0692f61dd99e9c50de330 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Thu, 18 Jul 2024 11:19:01 -0600 Subject: [PATCH 053/103] Remove isUnmaskedDocument utility --- src/utilities/graphql/__tests__/directives.ts | 131 ------------------ src/utilities/graphql/directives.ts | 75 ---------- src/utilities/index.ts | 1 - 3 files changed, 207 deletions(-) diff --git a/src/utilities/graphql/__tests__/directives.ts b/src/utilities/graphql/__tests__/directives.ts index ef39d532efa..a46581c3b00 100644 --- a/src/utilities/graphql/__tests__/directives.ts +++ b/src/utilities/graphql/__tests__/directives.ts @@ -7,7 +7,6 @@ import { hasDirectives, hasAnyDirectives, hasAllDirectives, - isUnmaskedDocument, getFragmentMaskMode, } from "../directives"; import { spyOnConsole } from "../../../testing/internal"; @@ -518,136 +517,6 @@ describe("shouldInclude", () => { }); }); -describe("isUnmaskedDocument", () => { - it("returns true when @unmask used on document", () => { - const query = gql` - query MyQuery @unmask { - myField - } - `; - - const [isUnmasked, { warnOnFieldAccess }] = isUnmaskedDocument(query); - - expect(isUnmasked).toBe(true); - expect(warnOnFieldAccess).toBe(true); - }); - - it("allows disabling unmask warnings with argument", () => { - const query = gql` - query MyQuery @unmask(warnOnFieldAccess: false) { - myField - } - `; - - const [isUnmasked, { warnOnFieldAccess }] = isUnmaskedDocument(query); - - expect(isUnmasked).toBe(true); - expect(warnOnFieldAccess).toBe(false); - }); - - it("warns when passing variable to warnOnFieldAccess", () => { - using consoleSpy = spyOnConsole("warn"); - const query = gql` - query MyQuery($warnOnFieldAccess: Boolean!) - @unmask(warnOnFieldAccess: $warnOnFieldAccess) { - myField - } - `; - - const [isUnmasked, { warnOnFieldAccess }] = isUnmaskedDocument(query); - - expect(isUnmasked).toBe(true); - expect(warnOnFieldAccess).toBe(true); - expect(consoleSpy.warn).toHaveBeenCalledTimes(1); - expect(consoleSpy.warn).toHaveBeenCalledWith( - "@unmask 'warnOnFieldAccess' argument does not support variables." - ); - }); - - it("warns when passing non-boolean to warnOnFieldAccess", () => { - using consoleSpy = spyOnConsole("warn"); - const query = gql` - query MyQuery @unmask(warnOnFieldAccess: "") { - myField - } - `; - - const [isUnmasked, { warnOnFieldAccess }] = isUnmaskedDocument(query); - - expect(isUnmasked).toBe(true); - expect(warnOnFieldAccess).toBe(true); - expect(consoleSpy.warn).toHaveBeenCalledTimes(1); - expect(consoleSpy.warn).toHaveBeenCalledWith( - "@unmask 'warnOnFieldAccess' argument must be of type boolean." - ); - }); - - it("returns false when @unmask is not used", () => { - const query = gql` - query MyQuery { - myField - } - `; - - const [isUnmasked, { warnOnFieldAccess }] = isUnmaskedDocument(query); - - expect(isUnmasked).toBe(false); - expect(warnOnFieldAccess).toBe(true); - }); - - it("returns false when @unmask is used in a location other than the document root", () => { - using consoleSpy = spyOnConsole("warn"); - - const query = gql` - query MyQuery($id: ID! @unmask) { - foo @unmask - bar(arg: true) { - ... @unmask { - baz - } - ...MyFragment @unmask - } - } - `; - - const [isUnmasked, { warnOnFieldAccess }] = isUnmaskedDocument(query); - - expect(isUnmasked).toBe(false); - expect(warnOnFieldAccess).toBe(true); - expect(consoleSpy.warn).toHaveBeenCalledTimes(1); - expect(consoleSpy.warn).toHaveBeenCalledWith( - "@unmask directive used in %s is provided in a location other than the document root which is ignored.", - "'MyQuery': " - ); - }); - - it("warns when using @unmask directive a location other than the document root while also using @unmask at the root", () => { - using consoleSpy = spyOnConsole("warn"); - - const query = gql` - query MyQuery($id: ID! @unmask) @unmask { - foo @unmask - bar(arg: true) { - ... @unmask { - baz - } - ...MyFragment @unmask - } - } - `; - - const [isUnmasked, { warnOnFieldAccess }] = isUnmaskedDocument(query); - - expect(isUnmasked).toBe(true); - expect(warnOnFieldAccess).toBe(true); - expect(consoleSpy.warn).toHaveBeenCalledTimes(1); - expect(consoleSpy.warn).toHaveBeenCalledWith( - "@unmask directive used in %s is provided in a location other than the document root which is ignored.", - "'MyQuery': " - ); - }); -}); - describe("getFragmentMaskMode", () => { it("returns 'unmask' when @unmask used on fragment node", () => { const fragmentNode = getFragmentSpreadNode(gql` diff --git a/src/utilities/graphql/directives.ts b/src/utilities/graphql/directives.ts index 211fc614402..6ff47c20478 100644 --- a/src/utilities/graphql/directives.ts +++ b/src/utilities/graphql/directives.ts @@ -135,81 +135,6 @@ export function getInclusionDirectives( return result; } -export function isUnmaskedDocument( - document: DocumentNode -): [isUnmasked: boolean, options: { warnOnFieldAccess: boolean }] { - let masked = false; - let warnOnFieldAccess = true; - let operationName: string | undefined; - - visit(document, { - OperationDefinition(node) { - operationName = node.name?.value; - - if (node.directives) { - const directive = node.directives.find( - (directive) => directive.name.value === "unmask" - ); - - masked = !!directive; - - const warnsArg = directive?.arguments?.find( - (arg) => arg.name.value === "warnOnFieldAccess" - ); - - if (__DEV__) { - if (warnsArg && warnsArg.value.kind !== Kind.BOOLEAN) { - invariant.warn( - warnsArg.value.kind === Kind.VARIABLE ? - "@unmask 'warnOnFieldAccess' argument does not support variables." - : "@unmask 'warnOnFieldAccess' argument must be of type boolean." - ); - } - } - - if (warnsArg && "value" in warnsArg.value) { - warnOnFieldAccess = warnsArg.value.value !== false; - } - } - - if (__DEV__) { - // Allow us to continue traversal in development to warn if we detect - // the unmask directive anywhere else in the document. - return; - } - - return BREAK; - }, - Directive(node, _, __, ___, ancestors) { - if (__DEV__) { - if (node.name.value !== "unmask") { - return; - } - - const parent = ancestors[ancestors.length - 1]; - - // Make sure we aren't checking the `unmask` directive defined on - // the operation, which we don't want to warn on. - if ( - Array.isArray(parent) || - (parent as ASTNode).kind !== "OperationDefinition" - ) { - invariant.warn( - "@unmask directive used in %s is provided in a location other than the document root which is ignored.", - operationName ? `'${operationName}': ` : "anonymous operation" - ); - - // We only want to warn once if we detect misused of @unmask so we - // immediately stop traversal. - return BREAK; - } - } - }, - }); - - return [masked, { warnOnFieldAccess }]; -} - export function getFragmentMaskMode( fragment: FragmentSpreadNode ): "mask" | "migrate" | "unmask" { diff --git a/src/utilities/index.ts b/src/utilities/index.ts index 4d4a8094fc3..5523d2cba8c 100644 --- a/src/utilities/index.ts +++ b/src/utilities/index.ts @@ -12,7 +12,6 @@ export { hasClientExports, getDirectiveNames, getInclusionDirectives, - isUnmaskedDocument, getFragmentMaskMode, } from "./graphql/directives.js"; From bb528ffc536a2e20dcf1245c5eb154bdd05baef0 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Thu, 18 Jul 2024 11:27:06 -0600 Subject: [PATCH 054/103] Use mask mode to determine when to mask/warn on field access --- src/core/__tests__/masking.test.ts | 29 +++++++++++++---------------- src/core/masking.ts | 19 +++++++++++-------- 2 files changed, 24 insertions(+), 24 deletions(-) diff --git a/src/core/__tests__/masking.test.ts b/src/core/__tests__/masking.test.ts index 05039c62ad0..af0637ee073 100644 --- a/src/core/__tests__/masking.test.ts +++ b/src/core/__tests__/masking.test.ts @@ -723,9 +723,6 @@ test("maintains referential equality the entire result if there are no fragments }); test("does not mask named fragment fields when using `@unmask` directive", () => { - // Silence masked field access warning - using _ = spyOnConsole("warn"); - const query = gql` query UnmaskedQuery { currentUser { @@ -830,7 +827,7 @@ test("does not mask named fragment fields when using `@unmask` directive", () => }); }); -test("warns when accessing unmasked fields when using `@unmask` directive", () => { +test("warns when accessing unmasked fields when using `@unmask` directive with mode 'migrate'", () => { using consoleSpy = spyOnConsole("warn"); const query = gql` query UnmaskedQuery { @@ -838,7 +835,7 @@ test("warns when accessing unmasked fields when using `@unmask` directive", () = __typename id name - ...UserFields @unmask + ...UserFields @unmask(mode: "migrate") } } @@ -853,7 +850,7 @@ test("warns when accessing unmasked fields when using `@unmask` directive", () = __typename id name - ...UserFields @unmask + ...UserFields @unmask(mode: "migrate") } } @@ -904,7 +901,7 @@ test("warns when accessing unmasked fields when using `@unmask` directive", () = expect(consoleSpy.warn).toHaveBeenCalledTimes(2); }); -test("warns when accessing unmasked fields in arrays", () => { +test("warns when accessing unmasked fields in arrays with mode: 'migrate'", () => { using consoleSpy = spyOnConsole("warn"); const query = gql` query UnmaskedQuery { @@ -912,7 +909,7 @@ test("warns when accessing unmasked fields in arrays", () => { __typename id name - ...UserFields @unmask + ...UserFields @unmask(mode: "migrate") } } @@ -951,7 +948,7 @@ test("warns when accessing unmasked fields in arrays", () => { ); }); -test("warns when accessing unmasked fields with complex selections", () => { +test("warns when accessing unmasked fields with complex selections with mode: 'migrate'", () => { using consoleSpy = spyOnConsole("warn"); const query = gql` query UnmaskedQuery { @@ -959,7 +956,7 @@ test("warns when accessing unmasked fields with complex selections", () => { __typename id name - ...UserFields @unmask + ...UserFields @unmask(mode: "migrate") } } @@ -971,12 +968,12 @@ test("warns when accessing unmasked fields with complex selections", () => { ... @defer { username } - ...ProfileFields @unmask + ...ProfileFields @unmask(mode: "migrate") } skills { __typename name - ...SkillFields @unmask + ...SkillFields @unmask(mode: "migrate") } } @@ -1088,7 +1085,7 @@ test("warns when accessing unmasked fields with complex selections", () => { ); }); -test("does not warn when accessing fields shared between the query and fragment", () => { +test("does not warn when accessing fields shared between the query and fragment with mode: 'migrate'", () => { using consoleSpy = spyOnConsole("warn"); const query = gql` query UnmaskedQuery { @@ -1097,7 +1094,7 @@ test("does not warn when accessing fields shared between the query and fragment" id name age - ...UserFields @unmask + ...UserFields @unmask(mode: "migrate") email } } @@ -1130,7 +1127,7 @@ test("does not warn when accessing fields shared between the query and fragment" expect(consoleSpy.warn).not.toHaveBeenCalled(); }); -test.skip("disables warnings when setting warnOnFieldAccess to false", () => { +test("does not warn accessing fields with `@unmask` without mode argument", () => { using consoleSpy = spyOnConsole("warn"); const query = gql` query UnmaskedQuery { @@ -1138,7 +1135,7 @@ test.skip("disables warnings when setting warnOnFieldAccess to false", () => { __typename id name - ...UserFields @unmask(warnOnFieldAccess: false) + ...UserFields @unmask } } diff --git a/src/core/masking.ts b/src/core/masking.ts index f7a1b8e27e7..11f29ed2d14 100644 --- a/src/core/masking.ts +++ b/src/core/masking.ts @@ -10,7 +10,7 @@ import { resultKeyNameFromField, getFragmentDefinitions, getOperationName, - isUnmaskedFragment, + getFragmentMaskMode, } from "../utilities/index.js"; import type { FragmentMap } from "../utilities/index.js"; import type { DocumentNode, TypedDocumentNode } from "./index.js"; @@ -24,7 +24,6 @@ type MatchesFragmentFn = ( interface MaskingContext { operationName: string | null; fragmentMap: FragmentMap; - warnOnFieldAccess: boolean; matchesFragment: MatchesFragmentFn; } @@ -40,7 +39,6 @@ export function maskQuery( const context: MaskingContext = { operationName: getOperationName(document), fragmentMap: createFragmentMap(getFragmentDefinitions(document)), - warnOnFieldAccess: true, matchesFragment, }; @@ -68,7 +66,6 @@ export function maskFragment( const context: MaskingContext = { operationName: null, fragmentMap: createFragmentMap(getFragmentDefinitions(document)), - warnOnFieldAccess: true, matchesFragment, }; @@ -185,18 +182,19 @@ function maskSelectionSet( } case Kind.FRAGMENT_SPREAD: const fragment = context.fragmentMap[selection.name.value]; - const unmasked = isUnmaskedFragment(selection); + const mode = getFragmentMaskMode(selection); return [ - unmasked ? + mode === "mask" ? memo : ( unmaskFragmentFields( memo, data, fragment.selectionSet, path, + mode, context ) - : memo, + ), true, ]; } @@ -210,6 +208,7 @@ function unmaskFragmentFields( parent: Record, selectionSetNode: SelectionSetNode, path: PathSelection, + mode: "unmask" | "migrate", context: MaskingContext ) { if (Array.isArray(parent)) { @@ -219,6 +218,7 @@ function unmaskFragmentFields( item, selectionSetNode, [...path, index], + mode, context ); }); @@ -234,7 +234,7 @@ function unmaskFragmentFields( return; } - if (context.warnOnFieldAccess) { + if (mode === "migrate") { let value = parent[keyName]; if (childSelectionSet) { @@ -243,6 +243,7 @@ function unmaskFragmentFields( parent[keyName] as Record, childSelectionSet, [...path, keyName], + mode, context ); } @@ -260,6 +261,7 @@ function unmaskFragmentFields( parent, selection.selectionSet, path, + mode, context ); } @@ -269,6 +271,7 @@ function unmaskFragmentFields( parent, context.fragmentMap[selection.name.value].selectionSet, path, + mode, context ); } From a84958bce8e5946fbf682c13a548026afd4f4dfb Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Thu, 18 Jul 2024 11:43:39 -0600 Subject: [PATCH 055/103] Add a withProdMode helper --- src/testing/internal/disposables/index.ts | 1 + src/testing/internal/disposables/withProdMode.ts | 10 ++++++++++ 2 files changed, 11 insertions(+) create mode 100644 src/testing/internal/disposables/withProdMode.ts diff --git a/src/testing/internal/disposables/index.ts b/src/testing/internal/disposables/index.ts index 9895d129589..1a853f86de1 100644 --- a/src/testing/internal/disposables/index.ts +++ b/src/testing/internal/disposables/index.ts @@ -1,3 +1,4 @@ export { disableActWarnings } from "./disableActWarnings.js"; export { spyOnConsole } from "./spyOnConsole.js"; export { withCleanup } from "./withCleanup.js"; +export { withProdMode } from "./withProdMode.js"; diff --git a/src/testing/internal/disposables/withProdMode.ts b/src/testing/internal/disposables/withProdMode.ts new file mode 100644 index 00000000000..4f87a5672d1 --- /dev/null +++ b/src/testing/internal/disposables/withProdMode.ts @@ -0,0 +1,10 @@ +import { withCleanup } from "./withCleanup.js"; + +export function withProdMode() { + const prev = { prevDEV: __DEV__ }; + (globalThis as any).__DEV__ = false; + + return withCleanup(prev, ({ prevDEV }) => { + (globalThis as any).__DEV__ = prevDEV; + }); +} From 78c7570eaefde37a400c172341b7a33cf6aa9078 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Thu, 18 Jul 2024 11:43:50 -0600 Subject: [PATCH 056/103] Ensure warnings are disabled in prod --- src/core/__tests__/masking.test.ts | 40 +++++++++++++++++++++++++++++- src/core/masking.ts | 14 ++++++++++- 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/src/core/__tests__/masking.test.ts b/src/core/__tests__/masking.test.ts index af0637ee073..fc17681568a 100644 --- a/src/core/__tests__/masking.test.ts +++ b/src/core/__tests__/masking.test.ts @@ -3,7 +3,7 @@ import { InMemoryCache, gql } from "../index.js"; import { InlineFragmentNode } from "graphql"; import { deepFreeze } from "../../utilities/common/maybeDeepFreeze.js"; import { InvariantError } from "../../utilities/globals/index.js"; -import { spyOnConsole } from "../../testing/internal/index.js"; +import { spyOnConsole, withProdMode } from "../../testing/internal/index.js"; test("strips top-level fragment data from query", () => { const query = gql` @@ -901,6 +901,44 @@ test("warns when accessing unmasked fields when using `@unmask` directive with m expect(consoleSpy.warn).toHaveBeenCalledTimes(2); }); +test("does not warn when accessing unmasked fields when using `@unmask` directive with mode 'migrate' in non-DEV mode", () => { + using _ = withProdMode(); + using __ = spyOnConsole("warn"); + + const query = gql` + query UnmaskedQuery { + currentUser { + __typename + id + name + ...UserFields @unmask(mode: "migrate") + } + } + + fragment UserFields on User { + age + } + `; + + const data = maskQuery( + { + currentUser: { + __typename: "User", + id: 1, + name: "Test User", + age: 30, + }, + }, + query, + createFragmentMatcher(new InMemoryCache()) + ); + + const age = data.currentUser.age; + + expect(age).toBe(30); + expect(console.warn).not.toHaveBeenCalled(); +}); + test("warns when accessing unmasked fields in arrays with mode: 'migrate'", () => { using consoleSpy = spyOnConsole("warn"); const query = gql` diff --git a/src/core/masking.ts b/src/core/masking.ts index 11f29ed2d14..fbdc502079e 100644 --- a/src/core/masking.ts +++ b/src/core/masking.ts @@ -248,7 +248,19 @@ function unmaskFragmentFields( ); } - addAccessorWarning(memo, value, keyName, path, context.operationName); + if (__DEV__) { + addAccessorWarning( + memo, + value, + keyName, + path, + context.operationName + ); + } + + if (!__DEV__) { + memo[keyName] = parent[keyName]; + } } else { memo[keyName] = parent[keyName]; } From 89a3accf60173ee7ca126209b7b5ca96afe201a2 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Thu, 18 Jul 2024 11:45:35 -0600 Subject: [PATCH 057/103] Swap to check console.warn instead of consoleSpy.warn --- src/core/__tests__/masking.test.ts | 50 +++++++++++++++--------------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/src/core/__tests__/masking.test.ts b/src/core/__tests__/masking.test.ts index fc17681568a..a01f1782bf1 100644 --- a/src/core/__tests__/masking.test.ts +++ b/src/core/__tests__/masking.test.ts @@ -828,7 +828,7 @@ test("does not mask named fragment fields when using `@unmask` directive", () => }); test("warns when accessing unmasked fields when using `@unmask` directive with mode 'migrate'", () => { - using consoleSpy = spyOnConsole("warn"); + using _ = spyOnConsole("warn"); const query = gql` query UnmaskedQuery { currentUser { @@ -878,8 +878,8 @@ test("warns when accessing unmasked fields when using `@unmask` directive with m data.currentUser.age; - expect(consoleSpy.warn).toHaveBeenCalledTimes(1); - expect(consoleSpy.warn).toHaveBeenCalledWith( + expect(console.warn).toHaveBeenCalledTimes(1); + expect(console.warn).toHaveBeenCalledWith( "Accessing unmasked field on %s at path '%s'. This field will not be available when masking is enabled. Please read the field from the fragment instead.", "query 'UnmaskedQuery'", "currentUser.age" @@ -887,8 +887,8 @@ test("warns when accessing unmasked fields when using `@unmask` directive with m dataFromAnonymous.currentUser.age; - expect(consoleSpy.warn).toHaveBeenCalledTimes(2); - expect(consoleSpy.warn).toHaveBeenCalledWith( + expect(console.warn).toHaveBeenCalledTimes(2); + expect(console.warn).toHaveBeenCalledWith( "Accessing unmasked field on %s at path '%s'. This field will not be available when masking is enabled. Please read the field from the fragment instead.", "anonymous query", "currentUser.age" @@ -898,7 +898,7 @@ test("warns when accessing unmasked fields when using `@unmask` directive with m dataFromAnonymous.currentUser.age; // Ensure we only warn once for each masked field - expect(consoleSpy.warn).toHaveBeenCalledTimes(2); + expect(console.warn).toHaveBeenCalledTimes(2); }); test("does not warn when accessing unmasked fields when using `@unmask` directive with mode 'migrate' in non-DEV mode", () => { @@ -940,7 +940,7 @@ test("does not warn when accessing unmasked fields when using `@unmask` directiv }); test("warns when accessing unmasked fields in arrays with mode: 'migrate'", () => { - using consoleSpy = spyOnConsole("warn"); + using _ = spyOnConsole("warn"); const query = gql` query UnmaskedQuery { users { @@ -972,14 +972,14 @@ test("warns when accessing unmasked fields in arrays with mode: 'migrate'", () = data.users[0].age; data.users[1].age; - expect(consoleSpy.warn).toHaveBeenCalledTimes(2); - expect(consoleSpy.warn).toHaveBeenCalledWith( + expect(console.warn).toHaveBeenCalledTimes(2); + expect(console.warn).toHaveBeenCalledWith( "Accessing unmasked field on %s at path '%s'. This field will not be available when masking is enabled. Please read the field from the fragment instead.", "query 'UnmaskedQuery'", "users[0].age" ); - expect(consoleSpy.warn).toHaveBeenCalledWith( + expect(console.warn).toHaveBeenCalledWith( "Accessing unmasked field on %s at path '%s'. This field will not be available when masking is enabled. Please read the field from the fragment instead.", "query 'UnmaskedQuery'", "users[1].age" @@ -987,7 +987,7 @@ test("warns when accessing unmasked fields in arrays with mode: 'migrate'", () = }); test("warns when accessing unmasked fields with complex selections with mode: 'migrate'", () => { - using consoleSpy = spyOnConsole("warn"); + using _ = spyOnConsole("warn"); const query = gql` query UnmaskedQuery { currentUser { @@ -1067,56 +1067,56 @@ test("warns when accessing unmasked fields with complex selections with mode: 'm data.currentUser.skills[0].description; data.currentUser.skills[1].description; - expect(consoleSpy.warn).toHaveBeenCalledTimes(9); - expect(consoleSpy.warn).toHaveBeenCalledWith( + expect(console.warn).toHaveBeenCalledTimes(9); + expect(console.warn).toHaveBeenCalledWith( "Accessing unmasked field on %s at path '%s'. This field will not be available when masking is enabled. Please read the field from the fragment instead.", "query 'UnmaskedQuery'", "currentUser.age" ); - expect(consoleSpy.warn).toHaveBeenCalledWith( + expect(console.warn).toHaveBeenCalledWith( "Accessing unmasked field on %s at path '%s'. This field will not be available when masking is enabled. Please read the field from the fragment instead.", "query 'UnmaskedQuery'", "currentUser.profile" ); - expect(consoleSpy.warn).toHaveBeenCalledWith( + expect(console.warn).toHaveBeenCalledWith( "Accessing unmasked field on %s at path '%s'. This field will not be available when masking is enabled. Please read the field from the fragment instead.", "query 'UnmaskedQuery'", "currentUser.profile.email" ); - expect(consoleSpy.warn).toHaveBeenCalledWith( + expect(console.warn).toHaveBeenCalledWith( "Accessing unmasked field on %s at path '%s'. This field will not be available when masking is enabled. Please read the field from the fragment instead.", "query 'UnmaskedQuery'", "currentUser.profile.username" ); - expect(consoleSpy.warn).toHaveBeenCalledWith( + expect(console.warn).toHaveBeenCalledWith( "Accessing unmasked field on %s at path '%s'. This field will not be available when masking is enabled. Please read the field from the fragment instead.", "query 'UnmaskedQuery'", "currentUser.profile.settings" ); - expect(consoleSpy.warn).toHaveBeenCalledWith( + expect(console.warn).toHaveBeenCalledWith( "Accessing unmasked field on %s at path '%s'. This field will not be available when masking is enabled. Please read the field from the fragment instead.", "query 'UnmaskedQuery'", "currentUser.profile.settings.dark" ); - expect(consoleSpy.warn).toHaveBeenCalledWith( + expect(console.warn).toHaveBeenCalledWith( "Accessing unmasked field on %s at path '%s'. This field will not be available when masking is enabled. Please read the field from the fragment instead.", "query 'UnmaskedQuery'", "currentUser.skills" ); - expect(consoleSpy.warn).toHaveBeenCalledWith( + expect(console.warn).toHaveBeenCalledWith( "Accessing unmasked field on %s at path '%s'. This field will not be available when masking is enabled. Please read the field from the fragment instead.", "query 'UnmaskedQuery'", "currentUser.skills[0].description" ); - expect(consoleSpy.warn).toHaveBeenCalledWith( + expect(console.warn).toHaveBeenCalledWith( "Accessing unmasked field on %s at path '%s'. This field will not be available when masking is enabled. Please read the field from the fragment instead.", "query 'UnmaskedQuery'", "currentUser.skills[1].description" @@ -1124,7 +1124,7 @@ test("warns when accessing unmasked fields with complex selections with mode: 'm }); test("does not warn when accessing fields shared between the query and fragment with mode: 'migrate'", () => { - using consoleSpy = spyOnConsole("warn"); + using _ = spyOnConsole("warn"); const query = gql` query UnmaskedQuery { currentUser { @@ -1162,11 +1162,11 @@ test("does not warn when accessing fields shared between the query and fragment data.currentUser.age; data.currentUser.email; - expect(consoleSpy.warn).not.toHaveBeenCalled(); + expect(console.warn).not.toHaveBeenCalled(); }); test("does not warn accessing fields with `@unmask` without mode argument", () => { - using consoleSpy = spyOnConsole("warn"); + using _ = spyOnConsole("warn"); const query = gql` query UnmaskedQuery { currentUser { @@ -1199,7 +1199,7 @@ test("does not warn accessing fields with `@unmask` without mode argument", () = data.currentUser.age; - expect(consoleSpy.warn).not.toHaveBeenCalled(); + expect(console.warn).not.toHaveBeenCalled(); }); test("masks named fragments in fragment documents", () => { From cb98a541b40d44d9ede82fb7ad30de9da3b33b89 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Thu, 18 Jul 2024 11:49:36 -0600 Subject: [PATCH 058/103] Simplify first unmask case --- src/core/__tests__/masking.test.ts | 66 ------------------------------ 1 file changed, 66 deletions(-) diff --git a/src/core/__tests__/masking.test.ts b/src/core/__tests__/masking.test.ts index a01f1782bf1..fed17b2e064 100644 --- a/src/core/__tests__/masking.test.ts +++ b/src/core/__tests__/masking.test.ts @@ -735,30 +735,6 @@ test("does not mask named fragment fields when using `@unmask` directive", () => fragment UserFields on User { age - profile { - __typename - email - ... @defer { - username - } - ...ProfileFields @unmask - } - skills { - __typename - name - ...SkillFields @unmask - } - } - - fragment ProfileFields on Profile { - settings { - __typename - darkMode - } - } - - fragment SkillFields on Skill { - description } `; @@ -769,27 +745,6 @@ test("does not mask named fragment fields when using `@unmask` directive", () => id: 1, name: "Test User", age: 30, - profile: { - __typename: "Profile", - email: "testuser@example.com", - username: "testuser", - settings: { - __typename: "Settings", - darkMode: true, - }, - }, - skills: [ - { - __typename: "Skill", - name: "Skill 1", - description: "Skill 1 description", - }, - { - __typename: "Skill", - name: "Skill 2", - description: "Skill 2 description", - }, - ], }, }, query, @@ -802,27 +757,6 @@ test("does not mask named fragment fields when using `@unmask` directive", () => id: 1, name: "Test User", age: 30, - profile: { - __typename: "Profile", - email: "testuser@example.com", - username: "testuser", - settings: { - __typename: "Settings", - darkMode: true, - }, - }, - skills: [ - { - __typename: "Skill", - name: "Skill 1", - description: "Skill 1 description", - }, - { - __typename: "Skill", - name: "Skill 2", - description: "Skill 2 description", - }, - ], }, }); }); From 5495c3da3a4997a6dd484a7dafb2b890950c1fce Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Thu, 18 Jul 2024 12:06:21 -0600 Subject: [PATCH 059/103] Ensure mix and match works with unmask --- src/core/__tests__/masking.test.ts | 115 +++++++++++++++++++++++++++++ src/core/masking.ts | 31 +++++--- 2 files changed, 137 insertions(+), 9 deletions(-) diff --git a/src/core/__tests__/masking.test.ts b/src/core/__tests__/masking.test.ts index fed17b2e064..9681f43f226 100644 --- a/src/core/__tests__/masking.test.ts +++ b/src/core/__tests__/masking.test.ts @@ -920,6 +920,121 @@ test("warns when accessing unmasked fields in arrays with mode: 'migrate'", () = ); }); +test("can mix and match masked vs unmasked fragment fields with proper warnings", () => { + using _ = spyOnConsole("warn"); + + const query = gql` + query UnmaskedQuery { + currentUser { + __typename + id + name + ...UserFields @unmask + } + } + + fragment UserFields on User { + age + profile { + __typename + email + ... @defer { + username + } + ...ProfileFields + } + skills { + __typename + name + ...SkillFields @unmask(mode: "migrate") + } + } + + fragment ProfileFields on Profile { + settings { + __typename + darkMode + } + } + + fragment SkillFields on Skill { + description + } + `; + + const data = maskQuery( + { + currentUser: { + __typename: "User", + id: 1, + name: "Test User", + age: 30, + profile: { + __typename: "Profile", + email: "testuser@example.com", + username: "testuser", + settings: { + __typename: "Settings", + darkMode: true, + }, + }, + skills: [ + { + __typename: "Skill", + name: "Skill 1", + description: "Skill 1 description", + }, + { + __typename: "Skill", + name: "Skill 2", + description: "Skill 2 description", + }, + ], + }, + }, + query, + createFragmentMatcher(new InMemoryCache()) + ); + + expect(data).toEqual({ + currentUser: { + __typename: "User", + id: 1, + name: "Test User", + age: 30, + profile: { + __typename: "Profile", + email: "testuser@example.com", + username: "testuser", + }, + skills: [ + { + __typename: "Skill", + name: "Skill 1", + description: "Skill 1 description", + }, + { + __typename: "Skill", + name: "Skill 2", + description: "Skill 2 description", + }, + ], + }, + }); + + expect(console.warn).toHaveBeenCalledTimes(2); + expect(console.warn).toHaveBeenCalledWith( + "Accessing unmasked field on %s at path '%s'. This field will not be available when masking is enabled. Please read the field from the fragment instead.", + "query 'UnmaskedQuery'", + "currentUser.skills[0].description" + ); + expect(console.warn).toHaveBeenCalledWith( + "Accessing unmasked field on %s at path '%s'. This field will not be available when masking is enabled. Please read the field from the fragment instead.", + "query 'UnmaskedQuery'", + "currentUser.skills[1].description" + ); +}); + test("warns when accessing unmasked fields with complex selections with mode: 'migrate'", () => { using _ = spyOnConsole("warn"); const query = gql` diff --git a/src/core/masking.ts b/src/core/masking.ts index fbdc502079e..da3ccecea06 100644 --- a/src/core/masking.ts +++ b/src/core/masking.ts @@ -184,16 +184,29 @@ function maskSelectionSet( const fragment = context.fragmentMap[selection.name.value]; const mode = getFragmentMaskMode(selection); + if (mode === "mask") { + return [memo, true]; + } + + if (mode === "unmask") { + const [fragmentData, changed] = maskSelectionSet( + data, + fragment.selectionSet, + path, + context + ); + + return [changed ? { ...memo, ...fragmentData } : data, changed]; + } + return [ - mode === "mask" ? memo : ( - unmaskFragmentFields( - memo, - data, - fragment.selectionSet, - path, - mode, - context - ) + unmaskFragmentFields( + memo, + data, + fragment.selectionSet, + path, + mode, + context ), true, ]; From d29569756557aae0d336742f8e5ecb8bcf576bdb Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Thu, 18 Jul 2024 12:08:25 -0600 Subject: [PATCH 060/103] Update test to check for refrential equality on returned object --- src/core/__tests__/masking.test.ts | 27 +++++++++++---------------- 1 file changed, 11 insertions(+), 16 deletions(-) diff --git a/src/core/__tests__/masking.test.ts b/src/core/__tests__/masking.test.ts index 9681f43f226..83ec54e0700 100644 --- a/src/core/__tests__/masking.test.ts +++ b/src/core/__tests__/masking.test.ts @@ -722,7 +722,7 @@ test("maintains referential equality the entire result if there are no fragments expect(data).toBe(originalData); }); -test("does not mask named fragment fields when using `@unmask` directive", () => { +test("does not mask named fragment fields and returns original object when using `@unmask` directive", () => { const query = gql` query UnmaskedQuery { currentUser { @@ -738,27 +738,22 @@ test("does not mask named fragment fields when using `@unmask` directive", () => } `; - const data = maskQuery( - { - currentUser: { - __typename: "User", - id: 1, - name: "Test User", - age: 30, - }, - }, - query, - createFragmentMatcher(new InMemoryCache()) - ); - - expect(data).toEqual({ + const queryData = { currentUser: { __typename: "User", id: 1, name: "Test User", age: 30, }, - }); + }; + + const data = maskQuery( + queryData, + query, + createFragmentMatcher(new InMemoryCache()) + ); + + expect(data).toBe(queryData); }); test("warns when accessing unmasked fields when using `@unmask` directive with mode 'migrate'", () => { From f50bce294749bf032e3948b1b603cc1c32e55ab9 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Thu, 18 Jul 2024 12:17:39 -0600 Subject: [PATCH 061/103] Add test for usage of unmask and referential equality --- src/core/__tests__/masking.test.ts | 136 +++++++++++++++++++++++++++++ 1 file changed, 136 insertions(+) diff --git a/src/core/__tests__/masking.test.ts b/src/core/__tests__/masking.test.ts index 83ec54e0700..64765e5a84d 100644 --- a/src/core/__tests__/masking.test.ts +++ b/src/core/__tests__/masking.test.ts @@ -756,6 +756,142 @@ test("does not mask named fragment fields and returns original object when using expect(data).toBe(queryData); }); +test("maintains referential equality on subtrees that contain @unmask", () => { + const query = gql` + query { + user { + __typename + id + profile { + __typename + avatarUrl + } + ...UserFields @unmask + } + post { + __typename + id + title + } + authors { + __typename + id + name + } + industries { + __typename + ... on TechIndustry { + ...TechIndustryFields @unmask + } + ... on FinanceIndustry { + ...FinanceIndustryFields + } + ... on TradeIndustry { + id + yearsInBusiness + ...TradeIndustryFields @unmask + } + } + } + + fragment UserFields on User { + name + ...UserSubfields @unmask + } + + fragment UserSubfields on User { + age + } + + fragment FinanceIndustryFields on FinanceIndustry { + yearsInBusiness + } + + fragment TradeIndustryFields on TradeIndustry { + languageRequirements + } + + fragment TechIndustryFields on TechIndustry { + languageRequirements + ...TechIndustrySubFields + } + + fragment TechIndustrySubFields on TechIndustry { + focus + } + `; + + const profile = { + __typename: "Profile", + avatarUrl: "https://example.com/avatar.jpg", + }; + const user = { + __typename: "User", + id: 1, + name: "Test User", + profile, + age: 30, + }; + const post = { __typename: "Post", id: 1, title: "Test Post" }; + const authors = [{ __typename: "Author", id: 1, name: "A Author" }]; + const industries = [ + { + __typename: "TechIndustry", + languageRequirements: ["TypeScript"], + focus: "innovation", + }, + { __typename: "FinanceIndustry", yearsInBusiness: 10 }, + { + __typename: "TradeIndustry", + id: 10, + yearsInBusiness: 15, + languageRequirements: ["English", "German"], + }, + ]; + const originalData = deepFreeze({ user, post, authors, industries }); + + const data = maskQuery( + originalData, + query, + createFragmentMatcher(new InMemoryCache()) + ); + + expect(data).toEqual({ + user: { + __typename: "User", + name: "Test User", + id: 1, + profile: { + __typename: "Profile", + avatarUrl: "https://example.com/avatar.jpg", + }, + age: 30, + }, + post: { __typename: "Post", id: 1, title: "Test Post" }, + authors: [{ __typename: "Author", id: 1, name: "A Author" }], + industries: [ + { __typename: "TechIndustry", languageRequirements: ["TypeScript"] }, + { __typename: "FinanceIndustry" }, + { + __typename: "TradeIndustry", + id: 10, + yearsInBusiness: 15, + languageRequirements: ["English", "German"], + }, + ], + }); + + expect(data).not.toBe(originalData); + expect(data.user).toBe(user); + expect(data.user.profile).toBe(profile); + expect(data.post).toBe(post); + expect(data.authors).toBe(authors); + expect(data.industries).not.toBe(industries); + expect(data.industries[0]).not.toBe(industries[0]); + expect(data.industries[1]).not.toBe(industries[1]); + expect(data.industries[2]).toBe(industries[2]); +}); + test("warns when accessing unmasked fields when using `@unmask` directive with mode 'migrate'", () => { using _ = spyOnConsole("warn"); const query = gql` From 688653f6dbfb00a3ac2137034cdb3a29cb70bc82 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Thu, 18 Jul 2024 12:22:42 -0600 Subject: [PATCH 062/103] Update client tests to use unmask in proper location --- src/__tests__/client.ts | 83 +++++------------------------------------ 1 file changed, 10 insertions(+), 73 deletions(-) diff --git a/src/__tests__/client.ts b/src/__tests__/client.ts index ccd7502bec5..5d9b4372acf 100644 --- a/src/__tests__/client.ts +++ b/src/__tests__/client.ts @@ -7,6 +7,7 @@ import { print, visit, OperationDefinitionNode, + FragmentSpreadNode, } from "graphql"; import gql from "graphql-tag"; @@ -6600,7 +6601,7 @@ describe("data masking", () => { } }); - it("does not mask queries marked with @unmask", async () => { + it("does not mask fragments marked with @unmask", async () => { interface Query { currentUser: { __typename: "User"; @@ -6610,11 +6611,11 @@ describe("data masking", () => { } const query: TypedDocumentNode = gql` - query UnmaskedQuery @unmask { + query UnmaskedQuery { currentUser { id name - ...UserFields + ...UserFields @unmask } } @@ -6666,10 +6667,10 @@ describe("data masking", () => { } }); - it("does not mask queries marked with @unmask added by document transforms", async () => { + it("does not mask fragments marked with @unmask added by document transforms", async () => { const documentTransform = new DocumentTransform((document) => { return visit(document, { - OperationDefinition(node) { + FragmentSpread(node) { return { ...node, directives: [ @@ -6678,7 +6679,7 @@ describe("data masking", () => { name: { kind: Kind.NAME, value: "unmask" }, }, ], - } satisfies OperationDefinitionNode; + } satisfies FragmentSpreadNode; }, }); }); @@ -7403,7 +7404,7 @@ describe("data masking", () => { } ); - it("warns when accessing a unmasked field while using @unmask", async () => { + it("warns when accessing a unmasked field while using @unmask with mode: 'migrate'", async () => { using consoleSpy = spyOnConsole("warn"); interface Query { @@ -7416,11 +7417,11 @@ describe("data masking", () => { } const query: TypedDocumentNode = gql` - query UnmaskedQuery @unmask { + query UnmaskedQuery { currentUser { id name - ...UserFields + ...UserFields @unmask(mode: "migrate") } } @@ -7478,70 +7479,6 @@ describe("data masking", () => { expect(consoleSpy.warn).toHaveBeenCalledTimes(1); } }); - - it("allows disabling warnings when accessing a fragmented field while using @unmask", async () => { - using consoleSpy = spyOnConsole("warn"); - - interface Query { - currentUser: { - __typename: "User"; - id: number; - name: string; - age: number; - }; - } - - const query: TypedDocumentNode = gql` - query UnmaskedQuery @unmask(warnOnFieldAccess: false) { - currentUser { - id - name - ...UserFields - } - } - - fragment UserFields on User { - age - name - } - `; - - const mocks = [ - { - request: { query }, - result: { - data: { - currentUser: { - __typename: "User", - id: 1, - name: "Test User", - age: 34, - }, - }, - }, - delay: 20, - }, - ]; - - const client = new ApolloClient({ - dataMasking: true, - cache: new InMemoryCache(), - link: new MockLink(mocks), - }); - - const observable = client.watchQuery({ query }); - const stream = new ObservableStream(observable); - - { - const { data } = await stream.takeNext(); - data.currentUser.__typename; - data.currentUser.id; - data.currentUser.name; - data.currentUser.age; - - expect(consoleSpy.warn).not.toHaveBeenCalled(); - } - }); }); function clientRoundtrip( From 980abca5646f5169e91be6b6b82b10f241c79036 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Thu, 18 Jul 2024 12:53:53 -0600 Subject: [PATCH 063/103] Deep freeze all objects in masking test --- src/core/__tests__/masking.test.ts | 76 ++++++++++++++++-------------- 1 file changed, 40 insertions(+), 36 deletions(-) diff --git a/src/core/__tests__/masking.test.ts b/src/core/__tests__/masking.test.ts index 64765e5a84d..71b2ddc8fae 100644 --- a/src/core/__tests__/masking.test.ts +++ b/src/core/__tests__/masking.test.ts @@ -42,7 +42,7 @@ test("strips fragment data from nested object", () => { `; const data = maskQuery( - { user: { __typename: "User", id: 1, name: "Test User" } }, + deepFreeze({ user: { __typename: "User", id: 1, name: "Test User" } }), query, createFragmentMatcher(new InMemoryCache()) ); @@ -66,12 +66,12 @@ test("strips fragment data from arrays", () => { `; const data = maskQuery( - { + deepFreeze({ users: [ { __typename: "User", id: 1, name: "Test User 1" }, { __typename: "User", id: 2, name: "Test User 2" }, ], - }, + }), query, createFragmentMatcher(new InMemoryCache()) ); @@ -187,14 +187,14 @@ test("leaves overlapping fields in query", () => { `; const data = maskQuery( - { + deepFreeze({ user: { __typename: "User", id: 1, birthdate: "1990-01-01", name: "Test User", }, - }, + }), query, createFragmentMatcher(new InMemoryCache()) ); @@ -228,7 +228,7 @@ test("does not strip inline fragments", () => { `; const data = maskQuery( - { + deepFreeze({ user: { __typename: "User", id: 1, @@ -238,7 +238,7 @@ test("does not strip inline fragments", () => { __typename: "UserProfile", avatarUrl: "https://example.com/avatar.jpg", }, - }, + }), query, createFragmentMatcher(cache) ); @@ -299,7 +299,7 @@ test("strips named fragments inside inline fragments", () => { `; const data = maskQuery( - { + deepFreeze({ user: { __typename: "User", id: 1, @@ -311,7 +311,7 @@ test("strips named fragments inside inline fragments", () => { avatarUrl: "https://example.com/avatar.jpg", industry: { __typename: "TechIndustry", primaryLanguage: "TypeScript" }, }, - }, + }), query, createFragmentMatcher(cache) ); @@ -350,12 +350,12 @@ test("handles objects with no matching inline fragment condition", () => { `; const data = maskQuery( - { + deepFreeze({ drinks: [ { __typename: "HotChocolate", id: 1 }, { __typename: "Juice", id: 2, fruitBase: "Strawberry" }, ], - }, + }), query, createFragmentMatcher(cache) ); @@ -383,14 +383,14 @@ test("handles field aliases", () => { `; const data = maskQuery( - { + deepFreeze({ user: { __typename: "User", id: 1, fullName: "Test User", userAddress: "1234 Main St", }, - }, + }), query, createFragmentMatcher(new InMemoryCache()) ); @@ -476,7 +476,7 @@ test("handles overlapping fields inside multiple inline fragments", () => { `; const data = maskQuery( - { + deepFreeze({ drinks: [ { __typename: "Latte", @@ -513,7 +513,7 @@ test("handles overlapping fields inside multiple inline fragments", () => { chocolateType: "dark", }, ], - }, + }), query, createFragmentMatcher(cache) ); @@ -567,7 +567,7 @@ test("does nothing if there are no fragments to mask", () => { `; const data = maskQuery( - { user: { __typename: "User", id: 1, name: "Test User" } }, + deepFreeze({ user: { __typename: "User", id: 1, name: "Test User" } }), query, createFragmentMatcher(new InMemoryCache()) ); @@ -738,14 +738,14 @@ test("does not mask named fragment fields and returns original object when using } `; - const queryData = { + const queryData = deepFreeze({ currentUser: { __typename: "User", id: 1, name: "Test User", age: 30, }, - }; + }); const data = maskQuery( queryData, @@ -933,7 +933,7 @@ test("warns when accessing unmasked fields when using `@unmask` directive with m const fragmentMatcher = createFragmentMatcher(new InMemoryCache()); - const data = maskQuery({ currentUser }, query, fragmentMatcher); + const data = maskQuery(deepFreeze({ currentUser }), query, fragmentMatcher); const dataFromAnonymous = maskQuery( { currentUser }, @@ -986,14 +986,14 @@ test("does not warn when accessing unmasked fields when using `@unmask` directiv `; const data = maskQuery( - { + deepFreeze({ currentUser: { __typename: "User", id: 1, name: "Test User", age: 30, }, - }, + }), query, createFragmentMatcher(new InMemoryCache()) ); @@ -1024,12 +1024,12 @@ test("warns when accessing unmasked fields in arrays with mode: 'migrate'", () = const fragmentMatcher = createFragmentMatcher(new InMemoryCache()); const data = maskQuery( - { + deepFreeze({ users: [ { __typename: "User", id: 1, name: "John Doe", age: 30 }, { __typename: "User", id: 2, name: "Jane Doe", age: 30 }, ], - }, + }), query, fragmentMatcher ); @@ -1094,7 +1094,7 @@ test("can mix and match masked vs unmasked fragment fields with proper warnings" `; const data = maskQuery( - { + deepFreeze({ currentUser: { __typename: "User", id: 1, @@ -1122,7 +1122,7 @@ test("can mix and match masked vs unmasked fragment fields with proper warnings" }, ], }, - }, + }), query, createFragmentMatcher(new InMemoryCache()) ); @@ -1237,7 +1237,7 @@ test("warns when accessing unmasked fields with complex selections with mode: 'm const fragmentMatcher = createFragmentMatcher(new InMemoryCache()); - const data = maskQuery({ currentUser }, query, fragmentMatcher); + const data = maskQuery(deepFreeze({ currentUser }), query, fragmentMatcher); data.currentUser.age; data.currentUser.profile.email; @@ -1326,7 +1326,7 @@ test("does not warn when accessing fields shared between the query and fragment const fragmentMatcher = createFragmentMatcher(new InMemoryCache()); const data = maskQuery( - { + deepFreeze({ currentUser: { __typename: "User", id: 1, @@ -1334,7 +1334,7 @@ test("does not warn when accessing fields shared between the query and fragment age: 30, email: "testuser@example.com", }, - }, + }), query, fragmentMatcher ); @@ -1365,14 +1365,14 @@ test("does not warn accessing fields with `@unmask` without mode argument", () = const fragmentMatcher = createFragmentMatcher(new InMemoryCache()); const data = maskQuery( - { + deepFreeze({ currentUser: { __typename: "User", id: 1, name: "Test User", age: 30, }, - }, + }), query, fragmentMatcher ); @@ -1396,7 +1396,7 @@ test("masks named fragments in fragment documents", () => { `; const data = maskFragment( - { __typename: "User", id: 1, age: 30 }, + deepFreeze({ __typename: "User", id: 1, age: 30 }), fragment, createFragmentMatcher(new InMemoryCache()), "UserFields" @@ -1422,7 +1422,11 @@ test("masks named fragments in nested fragment objects", () => { `; const data = maskFragment( - { __typename: "User", id: 1, profile: { __typename: "Profile", age: 30 } }, + deepFreeze({ + __typename: "User", + id: 1, + profile: { __typename: "Profile", age: 30 }, + }), fragment, createFragmentMatcher(new InMemoryCache()), "UserFields" @@ -1447,7 +1451,7 @@ test("does not mask inline fragment in fragment documents", () => { `; const data = maskFragment( - { __typename: "User", id: 1, age: 30 }, + deepFreeze({ __typename: "User", id: 1, age: 30 }), fragment, createFragmentMatcher(new InMemoryCache()), "UserFields" @@ -1471,7 +1475,7 @@ test("throws when document contains more than 1 fragment without a fragmentName" expect(() => maskFragment( - { __typename: "User", id: 1, age: 30 }, + deepFreeze({ __typename: "User", id: 1, age: 30 }), fragment, createFragmentMatcher(new InMemoryCache()) ) @@ -1497,7 +1501,7 @@ test("throws when fragment cannot be found within document", () => { expect(() => maskFragment( - { __typename: "User", id: 1, age: 30 }, + deepFreeze({ __typename: "User", id: 1, age: 30 }), fragment, createFragmentMatcher(new InMemoryCache()), "ProfileFields" @@ -1635,7 +1639,7 @@ test("maintains referential equality on fragment when no data is masked", () => const user = { __typename: "User", id: 1, age: 30 }; const data = maskFragment( - user, + deepFreeze(user), fragment, createFragmentMatcher(new InMemoryCache()) ); From 3501decc29eae7d42eec36365898cb15a2030896 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Thu, 18 Jul 2024 13:57:28 -0600 Subject: [PATCH 064/103] Swap order of fields --- src/core/__tests__/masking.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/__tests__/masking.test.ts b/src/core/__tests__/masking.test.ts index 71b2ddc8fae..cddd213ec24 100644 --- a/src/core/__tests__/masking.test.ts +++ b/src/core/__tests__/masking.test.ts @@ -726,10 +726,10 @@ test("does not mask named fragment fields and returns original object when using const query = gql` query UnmaskedQuery { currentUser { - __typename id name ...UserFields @unmask + __typename } } From 0cc8665d786a432a6a165a9427ca9ba72928bd62 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Thu, 18 Jul 2024 14:58:30 -0600 Subject: [PATCH 065/103] Fix issue where unmask on fragment not last would error --- src/core/masking.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/masking.ts b/src/core/masking.ts index da3ccecea06..3e4373beaa9 100644 --- a/src/core/masking.ts +++ b/src/core/masking.ts @@ -196,7 +196,7 @@ function maskSelectionSet( context ); - return [changed ? { ...memo, ...fragmentData } : data, changed]; + return [{ ...memo, ...fragmentData }, changed]; } return [ From fa63e684b6aee4edd1ff77ccaa3b20593cf9a984 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Thu, 18 Jul 2024 15:09:44 -0600 Subject: [PATCH 066/103] Remove unused import --- src/__tests__/client.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/__tests__/client.ts b/src/__tests__/client.ts index 5d9b4372acf..2e5e8ab7485 100644 --- a/src/__tests__/client.ts +++ b/src/__tests__/client.ts @@ -6,7 +6,6 @@ import { Kind, print, visit, - OperationDefinitionNode, FragmentSpreadNode, } from "graphql"; import gql from "graphql-tag"; From 4514ecbcd4fe259a3698222c156d5cf5ea1ae41e Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Thu, 18 Jul 2024 15:11:06 -0600 Subject: [PATCH 067/103] Update snapshot test --- src/__tests__/__snapshots__/exports.ts.snap | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/__tests__/__snapshots__/exports.ts.snap b/src/__tests__/__snapshots__/exports.ts.snap index 033ec9a7fc0..f694b58f7af 100644 --- a/src/__tests__/__snapshots__/exports.ts.snap +++ b/src/__tests__/__snapshots__/exports.ts.snap @@ -440,6 +440,7 @@ Array [ "getFragmentDefinition", "getFragmentDefinitions", "getFragmentFromSelection", + "getFragmentMaskMode", "getFragmentQueryDocument", "getGraphQLErrorsFromResult", "getInclusionDirectives", @@ -470,7 +471,6 @@ Array [ "isReference", "isStatefulPromise", "isSubscriptionOperation", - "isUnmaskedDocument", "iterateObserversSafely", "makeReference", "makeUniqueId", From 69f84258de8ce550d275ddba5999cc35562a2d1b Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Thu, 18 Jul 2024 15:13:46 -0600 Subject: [PATCH 068/103] Rerun api report and update size limits --- .api-reports/api-report-utilities.api.md | 8 +++----- .size-limits.json | 4 ++-- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/.api-reports/api-report-utilities.api.md b/.api-reports/api-report-utilities.api.md index 4b8c9657b24..e847785ef51 100644 --- a/.api-reports/api-report-utilities.api.md +++ b/.api-reports/api-report-utilities.api.md @@ -1151,6 +1151,9 @@ export function getFragmentDefinitions(doc: DocumentNode): FragmentDefinitionNod // @public (undocumented) export function getFragmentFromSelection(selection: SelectionNode, fragmentMap?: FragmentMap | FragmentMapFunction): InlineFragmentNode | FragmentDefinitionNode | null; +// @public (undocumented) +export function getFragmentMaskMode(fragment: FragmentSpreadNode): "mask" | "migrate" | "unmask"; + // @public export function getFragmentQueryDocument(document: DocumentNode, fragmentName?: string): DocumentNode; @@ -1469,11 +1472,6 @@ export type IsStrictlyAny = UnionToIntersection> extends never // @public (undocumented) export function isSubscriptionOperation(document: DocumentNode): boolean; -// @public (undocumented) -export function isUnmaskedDocument(document: DocumentNode): [isUnmasked: boolean, options: { - warnOnFieldAccess: boolean; -}]; - // @public (undocumented) export function iterateObserversSafely(observers: Set>, method: keyof Observer, argument?: A): void; diff --git a/.size-limits.json b/.size-limits.json index 229413783ce..ce2e50b055d 100644 --- a/.size-limits.json +++ b/.size-limits.json @@ -1,4 +1,4 @@ { - "dist/apollo-client.min.cjs": 40443, - "import { ApolloClient, InMemoryCache, HttpLink } from \"dist/index.js\" (production)": 33696 + "dist/apollo-client.min.cjs": 40324, + "import { ApolloClient, InMemoryCache, HttpLink } from \"dist/index.js\" (production)": 33605 } From eb0b55031fffb89b8bd850544c013b681e7f5d0a Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Thu, 18 Jul 2024 15:13:59 -0600 Subject: [PATCH 069/103] Fix build issue with assigning __DEV__ for withProdMode --- src/testing/internal/disposables/withProdMode.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/testing/internal/disposables/withProdMode.ts b/src/testing/internal/disposables/withProdMode.ts index 4f87a5672d1..ecdf3b408c2 100644 --- a/src/testing/internal/disposables/withProdMode.ts +++ b/src/testing/internal/disposables/withProdMode.ts @@ -2,9 +2,9 @@ import { withCleanup } from "./withCleanup.js"; export function withProdMode() { const prev = { prevDEV: __DEV__ }; - (globalThis as any).__DEV__ = false; + Object.defineProperty(globalThis, "__DEV__", { value: false }); return withCleanup(prev, ({ prevDEV }) => { - (globalThis as any).__DEV__ = prevDEV; + Object.defineProperty(globalThis, "__DEV__", { value: prevDEV }); }); } From 0806fbb4e6708f22ed946f65763dbc86bd5884f8 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Thu, 18 Jul 2024 15:21:50 -0600 Subject: [PATCH 070/103] Remove unneed spies in client test --- src/__tests__/client.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/__tests__/client.ts b/src/__tests__/client.ts index 2e5e8ab7485..1325ffde287 100644 --- a/src/__tests__/client.ts +++ b/src/__tests__/client.ts @@ -6650,9 +6650,6 @@ describe("data masking", () => { const stream = new ObservableStream(observable); { - // Hide unmasked field warning - using _ = spyOnConsole("warn"); - const { data } = await stream.takeNext(); expect(data).toEqual({ @@ -6733,9 +6730,6 @@ describe("data masking", () => { const stream = new ObservableStream(observable); { - // Hide unmasked field warning - using _ = spyOnConsole("warn"); - const { data } = await stream.takeNext(); expect(data).toEqual({ From 00e970101092540609a5dc526179664b7e76fbd0 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Thu, 18 Jul 2024 15:36:14 -0600 Subject: [PATCH 071/103] Rename maskQuery to maskOperation --- src/cache/core/__tests__/cache.ts | 4 +-- src/cache/core/cache.ts | 6 ++-- src/core/ObservableQuery.ts | 2 +- src/core/__tests__/masking.test.ts | 58 +++++++++++++++++------------- src/core/masking.ts | 2 +- 5 files changed, 40 insertions(+), 32 deletions(-) diff --git a/src/cache/core/__tests__/cache.ts b/src/cache/core/__tests__/cache.ts index 0e477d28aa5..5b6e61cb956 100644 --- a/src/cache/core/__tests__/cache.ts +++ b/src/cache/core/__tests__/cache.ts @@ -185,7 +185,7 @@ describe("abstract cache", () => { }; const cache = new TestCache(); - const result = cache.maskDocument(query, data); + const result = cache.maskOperation(query, data); expect(result).toBe(data); expect(consoleSpy.warn).toHaveBeenCalledTimes(1); @@ -223,7 +223,7 @@ describe("abstract cache", () => { user: { __typename: "User", id: 1, name: "Mister Masked" }, }; - const result = cache.maskDocument(query, data); + const result = cache.maskOperation(query, data); expect(result).toEqual({ user: { __typename: "User", id: 1 }, diff --git a/src/cache/core/cache.ts b/src/cache/core/cache.ts index 1298c848611..e98ba554efb 100644 --- a/src/cache/core/cache.ts +++ b/src/cache/core/cache.ts @@ -23,7 +23,7 @@ import type { } from "../../core/types.js"; import type { MissingTree } from "./types/common.js"; import { equalByQuery } from "../../core/equalByQuery.js"; -import { maskQuery } from "../../core/masking.js"; +import { maskOperation } from "../../core/masking.js"; import { invariant } from "../../utilities/globals/index.js"; export type Transaction = (c: ApolloCache) => void; @@ -370,7 +370,7 @@ export abstract class ApolloCache implements DataProxy { }); } - public maskDocument(document: DocumentNode, data: TData) { + public maskOperation(document: DocumentNode, data: TData) { if (!this.fragmentMatches) { if (__DEV__) { invariant.warn( @@ -381,7 +381,7 @@ export abstract class ApolloCache implements DataProxy { return data; } - return maskQuery(data, document, this.fragmentMatches.bind(this)); + return maskOperation(data, document, this.fragmentMatches.bind(this)); } /** diff --git a/src/core/ObservableQuery.ts b/src/core/ObservableQuery.ts index d63f47b724a..4787a8ea050 100644 --- a/src/core/ObservableQuery.ts +++ b/src/core/ObservableQuery.ts @@ -1068,7 +1068,7 @@ Did you mean to call refetch(variables) instead of refetch({ variables })?`, const { queryManager } = this; return queryManager.dataMasking ? - queryManager.cache.maskDocument(this.query, data) + queryManager.cache.maskOperation(this.query, data) : data; } } diff --git a/src/core/__tests__/masking.test.ts b/src/core/__tests__/masking.test.ts index cddd213ec24..b04eec0a45c 100644 --- a/src/core/__tests__/masking.test.ts +++ b/src/core/__tests__/masking.test.ts @@ -1,4 +1,4 @@ -import { maskFragment, maskQuery } from "../masking.js"; +import { maskFragment, maskOperation } from "../masking.js"; import { InMemoryCache, gql } from "../index.js"; import { InlineFragmentNode } from "graphql"; import { deepFreeze } from "../../utilities/common/maybeDeepFreeze.js"; @@ -17,7 +17,7 @@ test("strips top-level fragment data from query", () => { } `; - const data = maskQuery( + const data = maskOperation( { foo: true, bar: true }, query, createFragmentMatcher(new InMemoryCache()) @@ -41,7 +41,7 @@ test("strips fragment data from nested object", () => { } `; - const data = maskQuery( + const data = maskOperation( deepFreeze({ user: { __typename: "User", id: 1, name: "Test User" } }), query, createFragmentMatcher(new InMemoryCache()) @@ -65,7 +65,7 @@ test("strips fragment data from arrays", () => { } `; - const data = maskQuery( + const data = maskOperation( deepFreeze({ users: [ { __typename: "User", id: 1, name: "Test User 1" }, @@ -104,7 +104,7 @@ test("strips multiple fragments in the same selection set", () => { } `; - const data = maskQuery( + const data = maskOperation( { user: { __typename: "User", @@ -146,7 +146,7 @@ test("strips multiple fragments across different selection sets", () => { } `; - const data = maskQuery( + const data = maskOperation( { user: { __typename: "User", @@ -186,7 +186,7 @@ test("leaves overlapping fields in query", () => { } `; - const data = maskQuery( + const data = maskOperation( deepFreeze({ user: { __typename: "User", @@ -227,7 +227,7 @@ test("does not strip inline fragments", () => { } `; - const data = maskQuery( + const data = maskOperation( deepFreeze({ user: { __typename: "User", @@ -298,7 +298,7 @@ test("strips named fragments inside inline fragments", () => { } `; - const data = maskQuery( + const data = maskOperation( deepFreeze({ user: { __typename: "User", @@ -349,7 +349,7 @@ test("handles objects with no matching inline fragment condition", () => { } `; - const data = maskQuery( + const data = maskOperation( deepFreeze({ drinks: [ { __typename: "HotChocolate", id: 1 }, @@ -382,7 +382,7 @@ test("handles field aliases", () => { } `; - const data = maskQuery( + const data = maskOperation( deepFreeze({ user: { __typename: "User", @@ -475,7 +475,7 @@ test("handles overlapping fields inside multiple inline fragments", () => { } `; - const data = maskQuery( + const data = maskOperation( deepFreeze({ drinks: [ { @@ -566,7 +566,7 @@ test("does nothing if there are no fragments to mask", () => { } `; - const data = maskQuery( + const data = maskOperation( deepFreeze({ user: { __typename: "User", id: 1, name: "Test User" } }), query, createFragmentMatcher(new InMemoryCache()) @@ -657,7 +657,7 @@ test("maintains referential equality on subtrees that did not change", () => { const drink = { __typename: "Espresso" }; const originalData = deepFreeze({ user, post, authors, industries, drink }); - const data = maskQuery( + const data = maskOperation( originalData, query, createFragmentMatcher(new InMemoryCache()) @@ -713,7 +713,7 @@ test("maintains referential equality the entire result if there are no fragments }, }); - const data = maskQuery( + const data = maskOperation( originalData, query, createFragmentMatcher(new InMemoryCache()) @@ -747,7 +747,7 @@ test("does not mask named fragment fields and returns original object when using }, }); - const data = maskQuery( + const data = maskOperation( queryData, query, createFragmentMatcher(new InMemoryCache()) @@ -850,7 +850,7 @@ test("maintains referential equality on subtrees that contain @unmask", () => { ]; const originalData = deepFreeze({ user, post, authors, industries }); - const data = maskQuery( + const data = maskOperation( originalData, query, createFragmentMatcher(new InMemoryCache()) @@ -933,9 +933,13 @@ test("warns when accessing unmasked fields when using `@unmask` directive with m const fragmentMatcher = createFragmentMatcher(new InMemoryCache()); - const data = maskQuery(deepFreeze({ currentUser }), query, fragmentMatcher); + const data = maskOperation( + deepFreeze({ currentUser }), + query, + fragmentMatcher + ); - const dataFromAnonymous = maskQuery( + const dataFromAnonymous = maskOperation( { currentUser }, anonymousQuery, fragmentMatcher @@ -985,7 +989,7 @@ test("does not warn when accessing unmasked fields when using `@unmask` directiv } `; - const data = maskQuery( + const data = maskOperation( deepFreeze({ currentUser: { __typename: "User", @@ -1023,7 +1027,7 @@ test("warns when accessing unmasked fields in arrays with mode: 'migrate'", () = const fragmentMatcher = createFragmentMatcher(new InMemoryCache()); - const data = maskQuery( + const data = maskOperation( deepFreeze({ users: [ { __typename: "User", id: 1, name: "John Doe", age: 30 }, @@ -1093,7 +1097,7 @@ test("can mix and match masked vs unmasked fragment fields with proper warnings" } `; - const data = maskQuery( + const data = maskOperation( deepFreeze({ currentUser: { __typename: "User", @@ -1237,7 +1241,11 @@ test("warns when accessing unmasked fields with complex selections with mode: 'm const fragmentMatcher = createFragmentMatcher(new InMemoryCache()); - const data = maskQuery(deepFreeze({ currentUser }), query, fragmentMatcher); + const data = maskOperation( + deepFreeze({ currentUser }), + query, + fragmentMatcher + ); data.currentUser.age; data.currentUser.profile.email; @@ -1325,7 +1333,7 @@ test("does not warn when accessing fields shared between the query and fragment const fragmentMatcher = createFragmentMatcher(new InMemoryCache()); - const data = maskQuery( + const data = maskOperation( deepFreeze({ currentUser: { __typename: "User", @@ -1364,7 +1372,7 @@ test("does not warn accessing fields with `@unmask` without mode argument", () = const fragmentMatcher = createFragmentMatcher(new InMemoryCache()); - const data = maskQuery( + const data = maskOperation( deepFreeze({ currentUser: { __typename: "User", diff --git a/src/core/masking.ts b/src/core/masking.ts index 3e4373beaa9..705fe4d51ff 100644 --- a/src/core/masking.ts +++ b/src/core/masking.ts @@ -29,7 +29,7 @@ interface MaskingContext { type PathSelection = Array; -export function maskQuery( +export function maskOperation( data: TData, document: TypedDocumentNode | DocumentNode, matchesFragment: MatchesFragmentFn From 320473e14eaf4d859bc20f11236554150dfc8b39 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Thu, 18 Jul 2024 15:42:26 -0600 Subject: [PATCH 072/103] Add tests to check against subscription/mutation operations --- src/core/__tests__/masking.test.ts | 108 +++++++++++++++++++++++++++++ 1 file changed, 108 insertions(+) diff --git a/src/core/__tests__/masking.test.ts b/src/core/__tests__/masking.test.ts index b04eec0a45c..4b418600d13 100644 --- a/src/core/__tests__/masking.test.ts +++ b/src/core/__tests__/masking.test.ts @@ -1390,6 +1390,114 @@ test("does not warn accessing fields with `@unmask` without mode argument", () = expect(console.warn).not.toHaveBeenCalled(); }); +test("masks fragments in subscription documents", () => { + const subscription = gql` + subscription { + onUserUpdated { + __typename + id + ...UserFields + } + } + + fragment UserFields on User { + name + } + `; + + const data = maskOperation( + deepFreeze({ + onUserUpdated: { __typename: "User", id: 1, name: "Test User" }, + }), + subscription, + createFragmentMatcher(new InMemoryCache()) + ); + + expect(data).toEqual({ onUserUpdated: { __typename: "User", id: 1 } }); +}); + +test("honors @unmask used in subscription documents", () => { + const subscription = gql` + subscription { + onUserUpdated { + __typename + id + ...UserFields @unmask + } + } + + fragment UserFields on User { + name + } + `; + + const subscriptionData = deepFreeze({ + onUserUpdated: { __typename: "User", id: 1, name: "Test User" }, + }); + + const data = maskOperation( + subscriptionData, + subscription, + createFragmentMatcher(new InMemoryCache()) + ); + + expect(data).toBe(subscriptionData); +}); + +test("masks fragments in mutation documents", () => { + const mutation = gql` + mutation { + updateUser { + __typename + id + ...UserFields + } + } + + fragment UserFields on User { + name + } + `; + + const data = maskOperation( + deepFreeze({ + updateUser: { __typename: "User", id: 1, name: "Test User" }, + }), + mutation, + createFragmentMatcher(new InMemoryCache()) + ); + + expect(data).toEqual({ updateUser: { __typename: "User", id: 1 } }); +}); + +test("honors @unmask used in mutation documents", () => { + const mutation = gql` + mutation { + updateUser { + __typename + id + ...UserFields @unmask + } + } + + fragment UserFields on User { + name + } + `; + + const mutationData = deepFreeze({ + updateUser: { __typename: "User", id: 1, name: "Test User" }, + }); + + const data = maskOperation( + mutationData, + mutation, + createFragmentMatcher(new InMemoryCache()) + ); + + expect(data).toBe(mutationData); +}); + test("masks named fragments in fragment documents", () => { const fragment = gql` fragment UserFields on User { From 053177e5f48ea8422844af6583fb57d1b7e5a7d9 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Thu, 18 Jul 2024 15:51:17 -0600 Subject: [PATCH 073/103] Ensure the operation type is reported correctly in warning --- src/core/__tests__/masking.test.ts | 74 ++++++++++++++++++++++++++++++ src/core/masking.ts | 21 +++++---- 2 files changed, 86 insertions(+), 9 deletions(-) diff --git a/src/core/__tests__/masking.test.ts b/src/core/__tests__/masking.test.ts index 4b418600d13..e915d6f819c 100644 --- a/src/core/__tests__/masking.test.ts +++ b/src/core/__tests__/masking.test.ts @@ -1444,6 +1444,43 @@ test("honors @unmask used in subscription documents", () => { expect(data).toBe(subscriptionData); }); +test("warns when accessing unmasked fields used in subscription documents with @unmask(mode: 'migrate')", () => { + using _ = spyOnConsole("warn"); + + const subscription = gql` + subscription UserUpdatedSubscription { + onUserUpdated { + __typename + id + ...UserFields @unmask(mode: "migrate") + } + } + + fragment UserFields on User { + name + } + `; + + const subscriptionData = deepFreeze({ + onUserUpdated: { __typename: "User", id: 1, name: "Test User" }, + }); + + const data = maskOperation( + subscriptionData, + subscription, + createFragmentMatcher(new InMemoryCache()) + ); + + data.onUserUpdated.name; + + expect(console.warn).toHaveBeenCalledTimes(1); + expect(console.warn).toHaveBeenCalledWith( + "Accessing unmasked field on %s at path '%s'. This field will not be available when masking is enabled. Please read the field from the fragment instead.", + "subscription 'UserUpdatedSubscription'", + "onUserUpdated.name" + ); +}); + test("masks fragments in mutation documents", () => { const mutation = gql` mutation { @@ -1498,6 +1535,43 @@ test("honors @unmask used in mutation documents", () => { expect(data).toBe(mutationData); }); +test("warns when accessing unmasked fields used in mutation documents with @unmask(mode: 'migrate')", () => { + using _ = spyOnConsole("warn"); + + const mutation = gql` + mutation UpdateUserMutation { + updateUser { + __typename + id + ...UserFields @unmask(mode: "migrate") + } + } + + fragment UserFields on User { + name + } + `; + + const mutationData = deepFreeze({ + updateUser: { __typename: "User", id: 1, name: "Test User" }, + }); + + const data = maskOperation( + mutationData, + mutation, + createFragmentMatcher(new InMemoryCache()) + ); + + data.updateUser.name; + + expect(console.warn).toHaveBeenCalledTimes(1); + expect(console.warn).toHaveBeenCalledWith( + "Accessing unmasked field on %s at path '%s'. This field will not be available when masking is enabled. Please read the field from the fragment instead.", + "mutation 'UpdateUserMutation'", + "updateUser.name" + ); +}); + test("masks named fragments in fragment documents", () => { const fragment = gql` fragment UserFields on User { diff --git a/src/core/masking.ts b/src/core/masking.ts index 705fe4d51ff..682a5bd4a42 100644 --- a/src/core/masking.ts +++ b/src/core/masking.ts @@ -22,6 +22,7 @@ type MatchesFragmentFn = ( ) => boolean; interface MaskingContext { + operationType: "query" | "mutation" | "subscription" | "fragment"; operationName: string | null; fragmentMap: FragmentMap; matchesFragment: MatchesFragmentFn; @@ -37,6 +38,11 @@ export function maskOperation( const definition = getMainDefinition(document); const context: MaskingContext = { + operationType: + definition.kind === Kind.OPERATION_DEFINITION ? + definition.operation + // FIXME: Use better means to get definition + : "query", operationName: getOperationName(document), fragmentMap: createFragmentMap(getFragmentDefinitions(document)), matchesFragment, @@ -64,6 +70,7 @@ export function maskFragment( ); const context: MaskingContext = { + operationType: "fragment", operationName: null, fragmentMap: createFragmentMap(getFragmentDefinitions(document)), matchesFragment, @@ -262,13 +269,7 @@ function unmaskFragmentFields( } if (__DEV__) { - addAccessorWarning( - memo, - value, - keyName, - path, - context.operationName - ); + addAccessorWarning(memo, value, keyName, path, context); } if (!__DEV__) { @@ -311,7 +312,7 @@ function addAccessorWarning( value: any, fieldName: string, path: PathSelection, - operationName: string | null + context: MaskingContext ) { let currentValue = value; let warned = false; @@ -321,7 +322,9 @@ function addAccessorWarning( if (!warned) { invariant.warn( "Accessing unmasked field on %s at path '%s'. This field will not be available when masking is enabled. Please read the field from the fragment instead.", - operationName ? `query '${operationName}'` : "anonymous query", + context.operationName ? + `${context.operationType} '${context.operationName}'` + : `anonymous ${context.operationType}`, getPathString([...path, fieldName]) ); warned = true; From 73be28a434fc061a042f20e9fda29f617a33c623 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Thu, 18 Jul 2024 16:03:02 -0600 Subject: [PATCH 074/103] Add more robust checking to document for maskOperation --- src/core/__tests__/masking.test.ts | 55 ++++++++++++++++++++++++++++++ src/core/masking.ts | 15 ++++---- 2 files changed, 63 insertions(+), 7 deletions(-) diff --git a/src/core/__tests__/masking.test.ts b/src/core/__tests__/masking.test.ts index e915d6f819c..cef66987064 100644 --- a/src/core/__tests__/masking.test.ts +++ b/src/core/__tests__/masking.test.ts @@ -5,6 +5,61 @@ import { deepFreeze } from "../../utilities/common/maybeDeepFreeze.js"; import { InvariantError } from "../../utilities/globals/index.js"; import { spyOnConsole, withProdMode } from "../../testing/internal/index.js"; +test("throws when passing document with no operation to maskOperation", () => { + const document = gql` + fragment Foo on Bar { + foo + } + `; + + expect(() => + maskOperation({}, document, createFragmentMatcher(new InMemoryCache())) + ).toThrow( + new InvariantError( + "Expected a parsed GraphQL document with a query, mutation, or subscription." + ) + ); +}); + +test("throws when passing string query to maskOperation", () => { + const document = ` + query Foo { + foo + } + `; + + expect(() => + maskOperation( + {}, + // @ts-expect-error + document, + createFragmentMatcher(new InMemoryCache()) + ) + ).toThrow( + new InvariantError( + 'Expecting a parsed GraphQL document. Perhaps you need to wrap the query string in a "gql" tag? http://docs.apollostack.com/apollo-client/core.html#gql' + ) + ); +}); + +test("throws when passing multiple operations to maskOperation", () => { + const document = gql` + query Foo { + foo + } + + query Bar { + bar + } + `; + + expect(() => + maskOperation({}, document, createFragmentMatcher(new InMemoryCache())) + ).toThrow( + new InvariantError("Ambiguous GraphQL document: contains 2 operations") + ); +}); + test("strips top-level fragment data from query", () => { const query = gql` query { diff --git a/src/core/masking.ts b/src/core/masking.ts index 682a5bd4a42..630020c72b2 100644 --- a/src/core/masking.ts +++ b/src/core/masking.ts @@ -6,11 +6,11 @@ import type { } from "graphql"; import { createFragmentMap, - getMainDefinition, resultKeyNameFromField, getFragmentDefinitions, getOperationName, getFragmentMaskMode, + getOperationDefinition, } from "../utilities/index.js"; import type { FragmentMap } from "../utilities/index.js"; import type { DocumentNode, TypedDocumentNode } from "./index.js"; @@ -35,14 +35,15 @@ export function maskOperation( document: TypedDocumentNode | DocumentNode, matchesFragment: MatchesFragmentFn ): TData { - const definition = getMainDefinition(document); + const definition = getOperationDefinition(document); + + invariant( + definition, + "Expected a parsed GraphQL document with a query, mutation, or subscription." + ); const context: MaskingContext = { - operationType: - definition.kind === Kind.OPERATION_DEFINITION ? - definition.operation - // FIXME: Use better means to get definition - : "query", + operationType: definition.operation, operationName: getOperationName(document), fragmentMap: createFragmentMap(getFragmentDefinitions(document)), matchesFragment, From 7464a9466bd777df06f6a8944a68c0191f12d569 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Thu, 18 Jul 2024 16:04:58 -0600 Subject: [PATCH 075/103] Pull operationName from the definition instead of helper --- src/core/masking.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/core/masking.ts b/src/core/masking.ts index 630020c72b2..e180ce2839e 100644 --- a/src/core/masking.ts +++ b/src/core/masking.ts @@ -8,7 +8,6 @@ import { createFragmentMap, resultKeyNameFromField, getFragmentDefinitions, - getOperationName, getFragmentMaskMode, getOperationDefinition, } from "../utilities/index.js"; @@ -23,7 +22,7 @@ type MatchesFragmentFn = ( interface MaskingContext { operationType: "query" | "mutation" | "subscription" | "fragment"; - operationName: string | null; + operationName: string | undefined; fragmentMap: FragmentMap; matchesFragment: MatchesFragmentFn; } @@ -44,7 +43,7 @@ export function maskOperation( const context: MaskingContext = { operationType: definition.operation, - operationName: getOperationName(document), + operationName: definition.name?.value, fragmentMap: createFragmentMap(getFragmentDefinitions(document)), matchesFragment, }; @@ -72,7 +71,6 @@ export function maskFragment( const context: MaskingContext = { operationType: "fragment", - operationName: null, fragmentMap: createFragmentMap(getFragmentDefinitions(document)), matchesFragment, }; From c3e8e099ae83f7ea562762d735ca845915c84aec Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Thu, 18 Jul 2024 16:05:58 -0600 Subject: [PATCH 076/103] Set proper operationName for fragment --- src/core/masking.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/core/masking.ts b/src/core/masking.ts index e180ce2839e..652b85ecee1 100644 --- a/src/core/masking.ts +++ b/src/core/masking.ts @@ -69,12 +69,6 @@ export function maskFragment( node.kind === Kind.FRAGMENT_DEFINITION ); - const context: MaskingContext = { - operationType: "fragment", - fragmentMap: createFragmentMap(getFragmentDefinitions(document)), - matchesFragment, - }; - if (typeof fragmentName === "undefined") { invariant( fragments.length === 1, @@ -94,6 +88,13 @@ export function maskFragment( fragmentName ); + const context: MaskingContext = { + operationType: "fragment", + operationName: fragment.name.value, + fragmentMap: createFragmentMap(getFragmentDefinitions(document)), + matchesFragment, + }; + const [masked, changed] = maskSelectionSet( data, fragment.selectionSet, From ffb5f934d754477744a8aac80ed7ce197b583333 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Thu, 18 Jul 2024 16:08:28 -0600 Subject: [PATCH 077/103] Reorder args to provide default value --- src/core/masking.ts | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/src/core/masking.ts b/src/core/masking.ts index 652b85ecee1..ad49b9bc355 100644 --- a/src/core/masking.ts +++ b/src/core/masking.ts @@ -51,7 +51,6 @@ export function maskOperation( const [masked, changed] = maskSelectionSet( data, definition.selectionSet, - [], context ); @@ -98,7 +97,6 @@ export function maskFragment( const [masked, changed] = maskSelectionSet( data, fragment.selectionSet, - [], context ); @@ -108,8 +106,8 @@ export function maskFragment( function maskSelectionSet( data: any, selectionSet: SelectionSetNode, - path: PathSelection, - context: MaskingContext + context: MaskingContext, + path: PathSelection = [] ): [data: any, changed: boolean] { if (Array.isArray(data)) { let changed = false; @@ -118,8 +116,8 @@ function maskSelectionSet( const [masked, itemChanged] = maskSelectionSet( item, selectionSet, - [...path, index], - context + context, + [...path, index] ); changed ||= itemChanged; @@ -152,8 +150,8 @@ function maskSelectionSet( const [masked, childChanged] = maskSelectionSet( data[keyName], childSelectionSet, - [...path, keyName], - context + context, + [...path, keyName] ); if (childChanged) { @@ -175,8 +173,8 @@ function maskSelectionSet( const [fragmentData, childChanged] = maskSelectionSet( data, selection.selectionSet, - path, - context + context, + path ); return [ @@ -199,8 +197,8 @@ function maskSelectionSet( const [fragmentData, changed] = maskSelectionSet( data, fragment.selectionSet, - path, - context + context, + path ); return [{ ...memo, ...fragmentData }, changed]; From 95ccb232bdc711ba60945f8a28065c3e812a9e12 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Thu, 18 Jul 2024 16:10:25 -0600 Subject: [PATCH 078/103] Inline the context --- src/core/masking.ts | 20 ++++---------------- 1 file changed, 4 insertions(+), 16 deletions(-) diff --git a/src/core/masking.ts b/src/core/masking.ts index ad49b9bc355..64ac412d256 100644 --- a/src/core/masking.ts +++ b/src/core/masking.ts @@ -41,18 +41,12 @@ export function maskOperation( "Expected a parsed GraphQL document with a query, mutation, or subscription." ); - const context: MaskingContext = { + const [masked, changed] = maskSelectionSet(data, definition.selectionSet, { operationType: definition.operation, operationName: definition.name?.value, fragmentMap: createFragmentMap(getFragmentDefinitions(document)), matchesFragment, - }; - - const [masked, changed] = maskSelectionSet( - data, - definition.selectionSet, - context - ); + }); return changed ? masked : data; } @@ -87,18 +81,12 @@ export function maskFragment( fragmentName ); - const context: MaskingContext = { + const [masked, changed] = maskSelectionSet(data, fragment.selectionSet, { operationType: "fragment", operationName: fragment.name.value, fragmentMap: createFragmentMap(getFragmentDefinitions(document)), matchesFragment, - }; - - const [masked, changed] = maskSelectionSet( - data, - fragment.selectionSet, - context - ); + }); return changed ? masked : data; } From 26d404bdf101c5ecd276bf243bda5c3179ac4096 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Thu, 18 Jul 2024 16:12:08 -0600 Subject: [PATCH 079/103] Group maskOperation and maskFragment tests in separate describes --- src/core/__tests__/masking.test.ts | 3011 ++++++++++++++-------------- 1 file changed, 1509 insertions(+), 1502 deletions(-) diff --git a/src/core/__tests__/masking.test.ts b/src/core/__tests__/masking.test.ts index cef66987064..896a3dee8f8 100644 --- a/src/core/__tests__/masking.test.ts +++ b/src/core/__tests__/masking.test.ts @@ -5,285 +5,301 @@ import { deepFreeze } from "../../utilities/common/maybeDeepFreeze.js"; import { InvariantError } from "../../utilities/globals/index.js"; import { spyOnConsole, withProdMode } from "../../testing/internal/index.js"; -test("throws when passing document with no operation to maskOperation", () => { - const document = gql` - fragment Foo on Bar { - foo - } - `; - - expect(() => - maskOperation({}, document, createFragmentMatcher(new InMemoryCache())) - ).toThrow( - new InvariantError( - "Expected a parsed GraphQL document with a query, mutation, or subscription." - ) - ); -}); +describe("maskOperation", () => { + test("throws when passing document with no operation to maskOperation", () => { + const document = gql` + fragment Foo on Bar { + foo + } + `; + + expect(() => + maskOperation({}, document, createFragmentMatcher(new InMemoryCache())) + ).toThrow( + new InvariantError( + "Expected a parsed GraphQL document with a query, mutation, or subscription." + ) + ); + }); -test("throws when passing string query to maskOperation", () => { - const document = ` + test("throws when passing string query to maskOperation", () => { + const document = ` query Foo { foo } `; - expect(() => - maskOperation( - {}, - // @ts-expect-error - document, - createFragmentMatcher(new InMemoryCache()) - ) - ).toThrow( - new InvariantError( - 'Expecting a parsed GraphQL document. Perhaps you need to wrap the query string in a "gql" tag? http://docs.apollostack.com/apollo-client/core.html#gql' - ) - ); -}); + expect(() => + maskOperation( + {}, + // @ts-expect-error + document, + createFragmentMatcher(new InMemoryCache()) + ) + ).toThrow( + new InvariantError( + 'Expecting a parsed GraphQL document. Perhaps you need to wrap the query string in a "gql" tag? http://docs.apollostack.com/apollo-client/core.html#gql' + ) + ); + }); -test("throws when passing multiple operations to maskOperation", () => { - const document = gql` - query Foo { - foo - } + test("throws when passing multiple operations to maskOperation", () => { + const document = gql` + query Foo { + foo + } - query Bar { - bar - } - `; + query Bar { + bar + } + `; - expect(() => - maskOperation({}, document, createFragmentMatcher(new InMemoryCache())) - ).toThrow( - new InvariantError("Ambiguous GraphQL document: contains 2 operations") - ); -}); + expect(() => + maskOperation({}, document, createFragmentMatcher(new InMemoryCache())) + ).toThrow( + new InvariantError("Ambiguous GraphQL document: contains 2 operations") + ); + }); -test("strips top-level fragment data from query", () => { - const query = gql` - query { - foo - ...QueryFields - } + test("strips top-level fragment data from query", () => { + const query = gql` + query { + foo + ...QueryFields + } - fragment QueryFields on Query { - bar - } - `; + fragment QueryFields on Query { + bar + } + `; - const data = maskOperation( - { foo: true, bar: true }, - query, - createFragmentMatcher(new InMemoryCache()) - ); + const data = maskOperation( + { foo: true, bar: true }, + query, + createFragmentMatcher(new InMemoryCache()) + ); - expect(data).toEqual({ foo: true }); -}); + expect(data).toEqual({ foo: true }); + }); -test("strips fragment data from nested object", () => { - const query = gql` - query { - user { - __typename - id - ...UserFields + test("strips fragment data from nested object", () => { + const query = gql` + query { + user { + __typename + id + ...UserFields + } } - } - fragment UserFields on User { - name - } - `; + fragment UserFields on User { + name + } + `; - const data = maskOperation( - deepFreeze({ user: { __typename: "User", id: 1, name: "Test User" } }), - query, - createFragmentMatcher(new InMemoryCache()) - ); + const data = maskOperation( + deepFreeze({ user: { __typename: "User", id: 1, name: "Test User" } }), + query, + createFragmentMatcher(new InMemoryCache()) + ); - expect(data).toEqual({ user: { __typename: "User", id: 1 } }); -}); + expect(data).toEqual({ user: { __typename: "User", id: 1 } }); + }); -test("strips fragment data from arrays", () => { - const query = gql` - query { - users { - __typename - id - ...UserFields + test("strips fragment data from arrays", () => { + const query = gql` + query { + users { + __typename + id + ...UserFields + } } - } - fragment UserFields on User { - name - } - `; + fragment UserFields on User { + name + } + `; + + const data = maskOperation( + deepFreeze({ + users: [ + { __typename: "User", id: 1, name: "Test User 1" }, + { __typename: "User", id: 2, name: "Test User 2" }, + ], + }), + query, + createFragmentMatcher(new InMemoryCache()) + ); - const data = maskOperation( - deepFreeze({ + expect(data).toEqual({ users: [ - { __typename: "User", id: 1, name: "Test User 1" }, - { __typename: "User", id: 2, name: "Test User 2" }, + { __typename: "User", id: 1 }, + { __typename: "User", id: 2 }, ], - }), - query, - createFragmentMatcher(new InMemoryCache()) - ); - - expect(data).toEqual({ - users: [ - { __typename: "User", id: 1 }, - { __typename: "User", id: 2 }, - ], + }); }); -}); -test("strips multiple fragments in the same selection set", () => { - const query = gql` - query { - user { - __typename - id - ...UserProfileFields - ...UserAvatarFields + test("strips multiple fragments in the same selection set", () => { + const query = gql` + query { + user { + __typename + id + ...UserProfileFields + ...UserAvatarFields + } } - } - fragment UserProfileFields on User { - age - } + fragment UserProfileFields on User { + age + } - fragment UserAvatarFields on User { - avatarUrl - } - `; + fragment UserAvatarFields on User { + avatarUrl + } + `; - const data = maskOperation( - { - user: { - __typename: "User", - id: 1, - age: 30, - avatarUrl: "https://example.com/avatar.jpg", + const data = maskOperation( + { + user: { + __typename: "User", + id: 1, + age: 30, + avatarUrl: "https://example.com/avatar.jpg", + }, }, - }, - query, - createFragmentMatcher(new InMemoryCache()) - ); + query, + createFragmentMatcher(new InMemoryCache()) + ); - expect(data).toEqual({ - user: { __typename: "User", id: 1 }, + expect(data).toEqual({ + user: { __typename: "User", id: 1 }, + }); }); -}); -test("strips multiple fragments across different selection sets", () => { - const query = gql` - query { - user { - __typename - id - ...UserFields - } - post { - __typename - id - ...PostFields + test("strips multiple fragments across different selection sets", () => { + const query = gql` + query { + user { + __typename + id + ...UserFields + } + post { + __typename + id + ...PostFields + } } - } - fragment UserFields on User { - name - } + fragment UserFields on User { + name + } - fragment PostFields on Post { - title - } - `; + fragment PostFields on Post { + title + } + `; - const data = maskOperation( - { - user: { - __typename: "User", - id: 1, - name: "test user", - }, - post: { - __typename: "Post", - id: 1, - title: "Test Post", + const data = maskOperation( + { + user: { + __typename: "User", + id: 1, + name: "test user", + }, + post: { + __typename: "Post", + id: 1, + title: "Test Post", + }, }, - }, - query, - createFragmentMatcher(new InMemoryCache()) - ); - - expect(data).toEqual({ - user: { __typename: "User", id: 1 }, - post: { __typename: "Post", id: 1 }, + query, + createFragmentMatcher(new InMemoryCache()) + ); + + expect(data).toEqual({ + user: { __typename: "User", id: 1 }, + post: { __typename: "Post", id: 1 }, + }); }); -}); -test("leaves overlapping fields in query", () => { - const query = gql` - query { - user { - __typename - id - birthdate - ...UserProfileFields + test("leaves overlapping fields in query", () => { + const query = gql` + query { + user { + __typename + id + birthdate + ...UserProfileFields + } } - } - fragment UserProfileFields on User { - birthdate - name - } - `; + fragment UserProfileFields on User { + birthdate + name + } + `; - const data = maskOperation( - deepFreeze({ - user: { - __typename: "User", - id: 1, - birthdate: "1990-01-01", - name: "Test User", - }, - }), - query, - createFragmentMatcher(new InMemoryCache()) - ); + const data = maskOperation( + deepFreeze({ + user: { + __typename: "User", + id: 1, + birthdate: "1990-01-01", + name: "Test User", + }, + }), + query, + createFragmentMatcher(new InMemoryCache()) + ); - expect(data).toEqual({ - user: { __typename: "User", id: 1, birthdate: "1990-01-01" }, + expect(data).toEqual({ + user: { __typename: "User", id: 1, birthdate: "1990-01-01" }, + }); }); -}); -test("does not strip inline fragments", () => { - const cache = new InMemoryCache({ - possibleTypes: { Profile: ["UserProfile"] }, - }); + test("does not strip inline fragments", () => { + const cache = new InMemoryCache({ + possibleTypes: { Profile: ["UserProfile"] }, + }); - const query = gql` - query { - user { - __typename - id - ... @defer { - name + const query = gql` + query { + user { + __typename + id + ... @defer { + name + } } - } - profile { - __typename - ... on UserProfile { - avatarUrl + profile { + __typename + ... on UserProfile { + avatarUrl + } } } - } - `; + `; + + const data = maskOperation( + deepFreeze({ + user: { + __typename: "User", + id: 1, + name: "Test User", + }, + profile: { + __typename: "UserProfile", + avatarUrl: "https://example.com/avatar.jpg", + }, + }), + query, + createFragmentMatcher(cache) + ); - const data = maskOperation( - deepFreeze({ + expect(data).toEqual({ user: { __typename: "User", id: 1, @@ -293,245 +309,275 @@ test("does not strip inline fragments", () => { __typename: "UserProfile", avatarUrl: "https://example.com/avatar.jpg", }, - }), - query, - createFragmentMatcher(cache) - ); - - expect(data).toEqual({ - user: { - __typename: "User", - id: 1, - name: "Test User", - }, - profile: { - __typename: "UserProfile", - avatarUrl: "https://example.com/avatar.jpg", - }, + }); }); -}); -test("strips named fragments inside inline fragments", () => { - const cache = new InMemoryCache({ - possibleTypes: { Industry: ["TechIndustry"], Profile: ["UserProfile"] }, - }); - const query = gql` - query { - user { - __typename - id - ... @defer { - name - ...UserFields - } - } - profile { - __typename - ... on UserProfile { - avatarUrl - ...UserProfileFields + test("strips named fragments inside inline fragments", () => { + const cache = new InMemoryCache({ + possibleTypes: { Industry: ["TechIndustry"], Profile: ["UserProfile"] }, + }); + const query = gql` + query { + user { + __typename + id + ... @defer { + name + ...UserFields + } } - industry { + profile { __typename - ... on TechIndustry { - ...TechIndustryFields + ... on UserProfile { + avatarUrl + ...UserProfileFields + } + industry { + __typename + ... on TechIndustry { + ...TechIndustryFields + } } } } - } - fragment UserFields on User { - age - } + fragment UserFields on User { + age + } - fragment UserProfileFields on UserProfile { - hometown - } + fragment UserProfileFields on UserProfile { + hometown + } - fragment TechIndustryFields on TechIndustry { - favoriteLanguage - } - `; + fragment TechIndustryFields on TechIndustry { + favoriteLanguage + } + `; + + const data = maskOperation( + deepFreeze({ + user: { + __typename: "User", + id: 1, + name: "Test User", + age: 30, + }, + profile: { + __typename: "UserProfile", + avatarUrl: "https://example.com/avatar.jpg", + industry: { + __typename: "TechIndustry", + primaryLanguage: "TypeScript", + }, + }, + }), + query, + createFragmentMatcher(cache) + ); - const data = maskOperation( - deepFreeze({ + expect(data).toEqual({ user: { __typename: "User", id: 1, name: "Test User", - age: 30, }, profile: { __typename: "UserProfile", avatarUrl: "https://example.com/avatar.jpg", - industry: { __typename: "TechIndustry", primaryLanguage: "TypeScript" }, + industry: { __typename: "TechIndustry" }, }, - }), - query, - createFragmentMatcher(cache) - ); - - expect(data).toEqual({ - user: { - __typename: "User", - id: 1, - name: "Test User", - }, - profile: { - __typename: "UserProfile", - avatarUrl: "https://example.com/avatar.jpg", - industry: { __typename: "TechIndustry" }, - }, + }); }); -}); -test("handles objects with no matching inline fragment condition", () => { - const cache = new InMemoryCache({ - possibleTypes: { - Drink: ["HotChocolate", "Juice"], - }, - }); + test("handles objects with no matching inline fragment condition", () => { + const cache = new InMemoryCache({ + possibleTypes: { + Drink: ["HotChocolate", "Juice"], + }, + }); - const query = gql` - query { - drinks { - __typename - id - ... on Juice { - fruitBase + const query = gql` + query { + drinks { + __typename + id + ... on Juice { + fruitBase + } } } - } - `; + `; + + const data = maskOperation( + deepFreeze({ + drinks: [ + { __typename: "HotChocolate", id: 1 }, + { __typename: "Juice", id: 2, fruitBase: "Strawberry" }, + ], + }), + query, + createFragmentMatcher(cache) + ); - const data = maskOperation( - deepFreeze({ + expect(data).toEqual({ drinks: [ { __typename: "HotChocolate", id: 1 }, { __typename: "Juice", id: 2, fruitBase: "Strawberry" }, ], - }), - query, - createFragmentMatcher(cache) - ); - - expect(data).toEqual({ - drinks: [ - { __typename: "HotChocolate", id: 1 }, - { __typename: "Juice", id: 2, fruitBase: "Strawberry" }, - ], + }); }); -}); -test("handles field aliases", () => { - const query = gql` - query { - user { - __typename - id - fullName: name - ... @defer { - userAddress: address + test("handles field aliases", () => { + const query = gql` + query { + user { + __typename + id + fullName: name + ... @defer { + userAddress: address + } } } - } - `; + `; + + const data = maskOperation( + deepFreeze({ + user: { + __typename: "User", + id: 1, + fullName: "Test User", + userAddress: "1234 Main St", + }, + }), + query, + createFragmentMatcher(new InMemoryCache()) + ); - const data = maskOperation( - deepFreeze({ + expect(data).toEqual({ user: { __typename: "User", id: 1, fullName: "Test User", userAddress: "1234 Main St", }, - }), - query, - createFragmentMatcher(new InMemoryCache()) - ); - - expect(data).toEqual({ - user: { - __typename: "User", - id: 1, - fullName: "Test User", - userAddress: "1234 Main St", - }, + }); }); -}); -test("handles overlapping fields inside multiple inline fragments", () => { - const cache = new InMemoryCache({ - possibleTypes: { - Drink: [ - "Espresso", - "Latte", - "Cappuccino", - "Cortado", - "Juice", - "HotChocolate", - ], - Espresso: ["Latte", "Cappuccino", "Cortado"], - }, - }); - const query = gql` - query { - drinks { - __typename - id - ... @defer { - amount - } - ... on Espresso { - milkType - ... on Latte { - flavor { - __typename - name - ...FlavorFields + test("handles overlapping fields inside multiple inline fragments", () => { + const cache = new InMemoryCache({ + possibleTypes: { + Drink: [ + "Espresso", + "Latte", + "Cappuccino", + "Cortado", + "Juice", + "HotChocolate", + ], + Espresso: ["Latte", "Cappuccino", "Cortado"], + }, + }); + const query = gql` + query { + drinks { + __typename + id + ... @defer { + amount + } + ... on Espresso { + milkType + ... on Latte { + flavor { + __typename + name + ...FlavorFields + } + } + ... on Cappuccino { + roast + } + ... on Cortado { + ...CortadoFields } } - ... on Cappuccino { - roast + ... on Latte { + ... @defer { + shots + } } - ... on Cortado { - ...CortadoFields + ... on Juice { + ...JuiceFields } - } - ... on Latte { - ... @defer { - shots + ... on HotChocolate { + milkType + ...HotChocolateFields } } - ... on Juice { - ...JuiceFields - } - ... on HotChocolate { - milkType - ...HotChocolateFields - } } - } - fragment JuiceFields on Juice { - fruitBase - } + fragment JuiceFields on Juice { + fruitBase + } - fragment HotChocolateFields on HotChocolate { - chocolateType - } + fragment HotChocolateFields on HotChocolate { + chocolateType + } - fragment FlavorFields on Flavor { - sweetness - } + fragment FlavorFields on Flavor { + sweetness + } - fragment CortadoFields on Cortado { - temperature - } - `; + fragment CortadoFields on Cortado { + temperature + } + `; + + const data = maskOperation( + deepFreeze({ + drinks: [ + { + __typename: "Latte", + id: 1, + amount: 12, + shots: 2, + milkType: "Cow", + flavor: { + __typename: "Flavor", + name: "Cookie Butter", + sweetness: "high", + }, + }, + { + __typename: "Cappuccino", + id: 2, + amount: 12, + milkType: "Cow", + roast: "medium", + }, + { + __typename: "Cortado", + id: 3, + amount: 12, + milkType: "Cow", + temperature: 150, + }, + { __typename: "Juice", id: 4, amount: 10, fruitBase: "Apple" }, + { + __typename: "HotChocolate", + id: 5, + amount: 8, + milkType: "Cow", + chocolateType: "dark", + }, + ], + }), + query, + createFragmentMatcher(cache) + ); - const data = maskOperation( - deepFreeze({ + expect(data).toEqual({ drinks: [ { __typename: "Latte", @@ -542,7 +588,6 @@ test("handles overlapping fields inside multiple inline fragments", () => { flavor: { __typename: "Flavor", name: "Cookie Butter", - sweetness: "high", }, }, { @@ -557,603 +602,595 @@ test("handles overlapping fields inside multiple inline fragments", () => { id: 3, amount: 12, milkType: "Cow", - temperature: 150, }, - { __typename: "Juice", id: 4, amount: 10, fruitBase: "Apple" }, + { __typename: "Juice", id: 4, amount: 10 }, { __typename: "HotChocolate", id: 5, amount: 8, milkType: "Cow", - chocolateType: "dark", }, ], - }), - query, - createFragmentMatcher(cache) - ); - - expect(data).toEqual({ - drinks: [ - { - __typename: "Latte", - id: 1, - amount: 12, - shots: 2, - milkType: "Cow", - flavor: { - __typename: "Flavor", - name: "Cookie Butter", - }, - }, - { - __typename: "Cappuccino", - id: 2, - amount: 12, - milkType: "Cow", - roast: "medium", - }, - { - __typename: "Cortado", - id: 3, - amount: 12, - milkType: "Cow", - }, - { __typename: "Juice", id: 4, amount: 10 }, - { - __typename: "HotChocolate", - id: 5, - amount: 8, - milkType: "Cow", - }, - ], + }); }); -}); -test("does nothing if there are no fragments to mask", () => { - const query = gql` - query { - user { - __typename - id - name + test("does nothing if there are no fragments to mask", () => { + const query = gql` + query { + user { + __typename + id + name + } } - } - `; + `; - const data = maskOperation( - deepFreeze({ user: { __typename: "User", id: 1, name: "Test User" } }), - query, - createFragmentMatcher(new InMemoryCache()) - ); + const data = maskOperation( + deepFreeze({ user: { __typename: "User", id: 1, name: "Test User" } }), + query, + createFragmentMatcher(new InMemoryCache()) + ); - expect(data).toEqual({ - user: { __typename: "User", id: 1, name: "Test User" }, + expect(data).toEqual({ + user: { __typename: "User", id: 1, name: "Test User" }, + }); }); -}); -test("maintains referential equality on subtrees that did not change", () => { - const query = gql` - query { - user { - __typename - id - profile { + test("maintains referential equality on subtrees that did not change", () => { + const query = gql` + query { + user { __typename - avatarUrl - } - ...UserFields - } - post { - __typename - id - title - } - authors { - __typename - id - name - } - industries { - __typename - ... on TechIndustry { - languageRequirements + id + profile { + __typename + avatarUrl + } + ...UserFields } - ... on FinanceIndustry { - ...FinanceIndustryFields + post { + __typename + id + title } - ... on TradeIndustry { + authors { + __typename id - yearsInBusiness - ...TradeIndustryFields + name } - } - drink { - __typename - ... on SportsDrink { - saltContent + industries { + __typename + ... on TechIndustry { + languageRequirements + } + ... on FinanceIndustry { + ...FinanceIndustryFields + } + ... on TradeIndustry { + id + yearsInBusiness + ...TradeIndustryFields + } } - ... on Espresso { + drink { __typename + ... on SportsDrink { + saltContent + } + ... on Espresso { + __typename + } } } - } - fragment UserFields on User { - name - } + fragment UserFields on User { + name + } - fragment FinanceIndustryFields on FinanceIndustry { - yearsInBusiness - } + fragment FinanceIndustryFields on FinanceIndustry { + yearsInBusiness + } - fragment TradeIndustryFields on TradeIndustry { - languageRequirements - } - `; + fragment TradeIndustryFields on TradeIndustry { + languageRequirements + } + `; - const profile = { - __typename: "Profile", - avatarUrl: "https://example.com/avatar.jpg", - }; - const user = { __typename: "User", id: 1, name: "Test User", profile }; - const post = { __typename: "Post", id: 1, title: "Test Post" }; - const authors = [{ __typename: "Author", id: 1, name: "A Author" }]; - const industries = [ - { __typename: "TechIndustry", languageRequirements: ["TypeScript"] }, - { __typename: "FinanceIndustry", yearsInBusiness: 10 }, - { - __typename: "TradeIndustry", - id: 10, - yearsInBusiness: 15, - languageRequirements: ["English", "German"], - }, - ]; - const drink = { __typename: "Espresso" }; - const originalData = deepFreeze({ user, post, authors, industries, drink }); - - const data = maskOperation( - originalData, - query, - createFragmentMatcher(new InMemoryCache()) - ); - - expect(data).toEqual({ - user: { - __typename: "User", - id: 1, - profile: { - __typename: "Profile", - avatarUrl: "https://example.com/avatar.jpg", - }, - }, - post: { __typename: "Post", id: 1, title: "Test Post" }, - authors: [{ __typename: "Author", id: 1, name: "A Author" }], - industries: [ + const profile = { + __typename: "Profile", + avatarUrl: "https://example.com/avatar.jpg", + }; + const user = { __typename: "User", id: 1, name: "Test User", profile }; + const post = { __typename: "Post", id: 1, title: "Test Post" }; + const authors = [{ __typename: "Author", id: 1, name: "A Author" }]; + const industries = [ { __typename: "TechIndustry", languageRequirements: ["TypeScript"] }, - { __typename: "FinanceIndustry" }, - { __typename: "TradeIndustry", id: 10, yearsInBusiness: 15 }, - ], - drink: { __typename: "Espresso" }, - }); + { __typename: "FinanceIndustry", yearsInBusiness: 10 }, + { + __typename: "TradeIndustry", + id: 10, + yearsInBusiness: 15, + languageRequirements: ["English", "German"], + }, + ]; + const drink = { __typename: "Espresso" }; + const originalData = deepFreeze({ user, post, authors, industries, drink }); - expect(data).not.toBe(originalData); - expect(data.user).not.toBe(user); - expect(data.user.profile).toBe(profile); - expect(data.post).toBe(post); - expect(data.authors).toBe(authors); - expect(data.industries).not.toBe(industries); - expect(data.industries[0]).toBe(industries[0]); - expect(data.industries[1]).not.toBe(industries[1]); - expect(data.industries[2]).not.toBe(industries[2]); - expect(data.drink).toBe(drink); -}); + const data = maskOperation( + originalData, + query, + createFragmentMatcher(new InMemoryCache()) + ); -test("maintains referential equality the entire result if there are no fragments", () => { - const query = gql` - query { - user { - __typename - id - name + expect(data).toEqual({ + user: { + __typename: "User", + id: 1, + profile: { + __typename: "Profile", + avatarUrl: "https://example.com/avatar.jpg", + }, + }, + post: { __typename: "Post", id: 1, title: "Test Post" }, + authors: [{ __typename: "Author", id: 1, name: "A Author" }], + industries: [ + { __typename: "TechIndustry", languageRequirements: ["TypeScript"] }, + { __typename: "FinanceIndustry" }, + { __typename: "TradeIndustry", id: 10, yearsInBusiness: 15 }, + ], + drink: { __typename: "Espresso" }, + }); + + expect(data).not.toBe(originalData); + expect(data.user).not.toBe(user); + expect(data.user.profile).toBe(profile); + expect(data.post).toBe(post); + expect(data.authors).toBe(authors); + expect(data.industries).not.toBe(industries); + expect(data.industries[0]).toBe(industries[0]); + expect(data.industries[1]).not.toBe(industries[1]); + expect(data.industries[2]).not.toBe(industries[2]); + expect(data.drink).toBe(drink); + }); + + test("maintains referential equality the entire result if there are no fragments", () => { + const query = gql` + query { + user { + __typename + id + name + } } - } - `; + `; - const originalData = deepFreeze({ - user: { - __typename: "User", - id: 1, - name: "Test User", - }, - }); + const originalData = deepFreeze({ + user: { + __typename: "User", + id: 1, + name: "Test User", + }, + }); - const data = maskOperation( - originalData, - query, - createFragmentMatcher(new InMemoryCache()) - ); + const data = maskOperation( + originalData, + query, + createFragmentMatcher(new InMemoryCache()) + ); - expect(data).toBe(originalData); -}); + expect(data).toBe(originalData); + }); -test("does not mask named fragment fields and returns original object when using `@unmask` directive", () => { - const query = gql` - query UnmaskedQuery { - currentUser { - id - name - ...UserFields @unmask - __typename + test("does not mask named fragment fields and returns original object when using `@unmask` directive", () => { + const query = gql` + query UnmaskedQuery { + currentUser { + id + name + ...UserFields @unmask + __typename + } } - } - fragment UserFields on User { - age - } - `; + fragment UserFields on User { + age + } + `; - const queryData = deepFreeze({ - currentUser: { - __typename: "User", - id: 1, - name: "Test User", - age: 30, - }, - }); + const queryData = deepFreeze({ + currentUser: { + __typename: "User", + id: 1, + name: "Test User", + age: 30, + }, + }); - const data = maskOperation( - queryData, - query, - createFragmentMatcher(new InMemoryCache()) - ); + const data = maskOperation( + queryData, + query, + createFragmentMatcher(new InMemoryCache()) + ); - expect(data).toBe(queryData); -}); + expect(data).toBe(queryData); + }); -test("maintains referential equality on subtrees that contain @unmask", () => { - const query = gql` - query { - user { - __typename - id - profile { + test("maintains referential equality on subtrees that contain @unmask", () => { + const query = gql` + query { + user { __typename - avatarUrl - } - ...UserFields @unmask - } - post { - __typename - id - title - } - authors { - __typename - id - name - } - industries { - __typename - ... on TechIndustry { - ...TechIndustryFields @unmask + id + profile { + __typename + avatarUrl + } + ...UserFields @unmask } - ... on FinanceIndustry { - ...FinanceIndustryFields + post { + __typename + id + title } - ... on TradeIndustry { + authors { + __typename id - yearsInBusiness - ...TradeIndustryFields @unmask + name + } + industries { + __typename + ... on TechIndustry { + ...TechIndustryFields @unmask + } + ... on FinanceIndustry { + ...FinanceIndustryFields + } + ... on TradeIndustry { + id + yearsInBusiness + ...TradeIndustryFields @unmask + } } } - } - fragment UserFields on User { - name - ...UserSubfields @unmask - } + fragment UserFields on User { + name + ...UserSubfields @unmask + } - fragment UserSubfields on User { - age - } + fragment UserSubfields on User { + age + } - fragment FinanceIndustryFields on FinanceIndustry { - yearsInBusiness - } + fragment FinanceIndustryFields on FinanceIndustry { + yearsInBusiness + } - fragment TradeIndustryFields on TradeIndustry { - languageRequirements - } + fragment TradeIndustryFields on TradeIndustry { + languageRequirements + } - fragment TechIndustryFields on TechIndustry { - languageRequirements - ...TechIndustrySubFields - } + fragment TechIndustryFields on TechIndustry { + languageRequirements + ...TechIndustrySubFields + } - fragment TechIndustrySubFields on TechIndustry { - focus - } - `; + fragment TechIndustrySubFields on TechIndustry { + focus + } + `; - const profile = { - __typename: "Profile", - avatarUrl: "https://example.com/avatar.jpg", - }; - const user = { - __typename: "User", - id: 1, - name: "Test User", - profile, - age: 30, - }; - const post = { __typename: "Post", id: 1, title: "Test Post" }; - const authors = [{ __typename: "Author", id: 1, name: "A Author" }]; - const industries = [ - { - __typename: "TechIndustry", - languageRequirements: ["TypeScript"], - focus: "innovation", - }, - { __typename: "FinanceIndustry", yearsInBusiness: 10 }, - { - __typename: "TradeIndustry", - id: 10, - yearsInBusiness: 15, - languageRequirements: ["English", "German"], - }, - ]; - const originalData = deepFreeze({ user, post, authors, industries }); - - const data = maskOperation( - originalData, - query, - createFragmentMatcher(new InMemoryCache()) - ); - - expect(data).toEqual({ - user: { + const profile = { + __typename: "Profile", + avatarUrl: "https://example.com/avatar.jpg", + }; + const user = { __typename: "User", - name: "Test User", id: 1, - profile: { - __typename: "Profile", - avatarUrl: "https://example.com/avatar.jpg", - }, + name: "Test User", + profile, age: 30, - }, - post: { __typename: "Post", id: 1, title: "Test Post" }, - authors: [{ __typename: "Author", id: 1, name: "A Author" }], - industries: [ - { __typename: "TechIndustry", languageRequirements: ["TypeScript"] }, - { __typename: "FinanceIndustry" }, + }; + const post = { __typename: "Post", id: 1, title: "Test Post" }; + const authors = [{ __typename: "Author", id: 1, name: "A Author" }]; + const industries = [ + { + __typename: "TechIndustry", + languageRequirements: ["TypeScript"], + focus: "innovation", + }, + { __typename: "FinanceIndustry", yearsInBusiness: 10 }, { __typename: "TradeIndustry", id: 10, yearsInBusiness: 15, languageRequirements: ["English", "German"], }, - ], + ]; + const originalData = deepFreeze({ user, post, authors, industries }); + + const data = maskOperation( + originalData, + query, + createFragmentMatcher(new InMemoryCache()) + ); + + expect(data).toEqual({ + user: { + __typename: "User", + name: "Test User", + id: 1, + profile: { + __typename: "Profile", + avatarUrl: "https://example.com/avatar.jpg", + }, + age: 30, + }, + post: { __typename: "Post", id: 1, title: "Test Post" }, + authors: [{ __typename: "Author", id: 1, name: "A Author" }], + industries: [ + { __typename: "TechIndustry", languageRequirements: ["TypeScript"] }, + { __typename: "FinanceIndustry" }, + { + __typename: "TradeIndustry", + id: 10, + yearsInBusiness: 15, + languageRequirements: ["English", "German"], + }, + ], + }); + + expect(data).not.toBe(originalData); + expect(data.user).toBe(user); + expect(data.user.profile).toBe(profile); + expect(data.post).toBe(post); + expect(data.authors).toBe(authors); + expect(data.industries).not.toBe(industries); + expect(data.industries[0]).not.toBe(industries[0]); + expect(data.industries[1]).not.toBe(industries[1]); + expect(data.industries[2]).toBe(industries[2]); }); - expect(data).not.toBe(originalData); - expect(data.user).toBe(user); - expect(data.user.profile).toBe(profile); - expect(data.post).toBe(post); - expect(data.authors).toBe(authors); - expect(data.industries).not.toBe(industries); - expect(data.industries[0]).not.toBe(industries[0]); - expect(data.industries[1]).not.toBe(industries[1]); - expect(data.industries[2]).toBe(industries[2]); -}); + test("warns when accessing unmasked fields when using `@unmask` directive with mode 'migrate'", () => { + using _ = spyOnConsole("warn"); + const query = gql` + query UnmaskedQuery { + currentUser { + __typename + id + name + ...UserFields @unmask(mode: "migrate") + } + } -test("warns when accessing unmasked fields when using `@unmask` directive with mode 'migrate'", () => { - using _ = spyOnConsole("warn"); - const query = gql` - query UnmaskedQuery { - currentUser { - __typename - id - name - ...UserFields @unmask(mode: "migrate") + fragment UserFields on User { + age } - } + `; - fragment UserFields on User { - age - } - `; + const anonymousQuery = gql` + query { + currentUser { + __typename + id + name + ...UserFields @unmask(mode: "migrate") + } + } - const anonymousQuery = gql` - query { - currentUser { - __typename - id - name - ...UserFields @unmask(mode: "migrate") + fragment UserFields on User { + age } - } + `; - fragment UserFields on User { - age - } - `; + const currentUser = { + __typename: "User", + id: 1, + name: "Test User", + age: 30, + }; - const currentUser = { - __typename: "User", - id: 1, - name: "Test User", - age: 30, - }; - - const fragmentMatcher = createFragmentMatcher(new InMemoryCache()); - - const data = maskOperation( - deepFreeze({ currentUser }), - query, - fragmentMatcher - ); - - const dataFromAnonymous = maskOperation( - { currentUser }, - anonymousQuery, - fragmentMatcher - ); - - data.currentUser.age; - - expect(console.warn).toHaveBeenCalledTimes(1); - expect(console.warn).toHaveBeenCalledWith( - "Accessing unmasked field on %s at path '%s'. This field will not be available when masking is enabled. Please read the field from the fragment instead.", - "query 'UnmaskedQuery'", - "currentUser.age" - ); - - dataFromAnonymous.currentUser.age; - - expect(console.warn).toHaveBeenCalledTimes(2); - expect(console.warn).toHaveBeenCalledWith( - "Accessing unmasked field on %s at path '%s'. This field will not be available when masking is enabled. Please read the field from the fragment instead.", - "anonymous query", - "currentUser.age" - ); - - data.currentUser.age; - dataFromAnonymous.currentUser.age; - - // Ensure we only warn once for each masked field - expect(console.warn).toHaveBeenCalledTimes(2); -}); + const fragmentMatcher = createFragmentMatcher(new InMemoryCache()); -test("does not warn when accessing unmasked fields when using `@unmask` directive with mode 'migrate' in non-DEV mode", () => { - using _ = withProdMode(); - using __ = spyOnConsole("warn"); + const data = maskOperation( + deepFreeze({ currentUser }), + query, + fragmentMatcher + ); - const query = gql` - query UnmaskedQuery { - currentUser { - __typename - id - name - ...UserFields @unmask(mode: "migrate") - } - } + const dataFromAnonymous = maskOperation( + { currentUser }, + anonymousQuery, + fragmentMatcher + ); - fragment UserFields on User { - age - } - `; + data.currentUser.age; - const data = maskOperation( - deepFreeze({ - currentUser: { - __typename: "User", - id: 1, - name: "Test User", - age: 30, - }, - }), - query, - createFragmentMatcher(new InMemoryCache()) - ); + expect(console.warn).toHaveBeenCalledTimes(1); + expect(console.warn).toHaveBeenCalledWith( + "Accessing unmasked field on %s at path '%s'. This field will not be available when masking is enabled. Please read the field from the fragment instead.", + "query 'UnmaskedQuery'", + "currentUser.age" + ); - const age = data.currentUser.age; + dataFromAnonymous.currentUser.age; - expect(age).toBe(30); - expect(console.warn).not.toHaveBeenCalled(); -}); + expect(console.warn).toHaveBeenCalledTimes(2); + expect(console.warn).toHaveBeenCalledWith( + "Accessing unmasked field on %s at path '%s'. This field will not be available when masking is enabled. Please read the field from the fragment instead.", + "anonymous query", + "currentUser.age" + ); -test("warns when accessing unmasked fields in arrays with mode: 'migrate'", () => { - using _ = spyOnConsole("warn"); - const query = gql` - query UnmaskedQuery { - users { - __typename - id - name - ...UserFields @unmask(mode: "migrate") + data.currentUser.age; + dataFromAnonymous.currentUser.age; + + // Ensure we only warn once for each masked field + expect(console.warn).toHaveBeenCalledTimes(2); + }); + + test("does not warn when accessing unmasked fields when using `@unmask` directive with mode 'migrate' in non-DEV mode", () => { + using _ = withProdMode(); + using __ = spyOnConsole("warn"); + + const query = gql` + query UnmaskedQuery { + currentUser { + __typename + id + name + ...UserFields @unmask(mode: "migrate") + } } - } - fragment UserFields on User { - age - } - `; + fragment UserFields on User { + age + } + `; - const fragmentMatcher = createFragmentMatcher(new InMemoryCache()); + const data = maskOperation( + deepFreeze({ + currentUser: { + __typename: "User", + id: 1, + name: "Test User", + age: 30, + }, + }), + query, + createFragmentMatcher(new InMemoryCache()) + ); - const data = maskOperation( - deepFreeze({ - users: [ - { __typename: "User", id: 1, name: "John Doe", age: 30 }, - { __typename: "User", id: 2, name: "Jane Doe", age: 30 }, - ], - }), - query, - fragmentMatcher - ); - - data.users[0].age; - data.users[1].age; - - expect(console.warn).toHaveBeenCalledTimes(2); - expect(console.warn).toHaveBeenCalledWith( - "Accessing unmasked field on %s at path '%s'. This field will not be available when masking is enabled. Please read the field from the fragment instead.", - "query 'UnmaskedQuery'", - "users[0].age" - ); - - expect(console.warn).toHaveBeenCalledWith( - "Accessing unmasked field on %s at path '%s'. This field will not be available when masking is enabled. Please read the field from the fragment instead.", - "query 'UnmaskedQuery'", - "users[1].age" - ); -}); + const age = data.currentUser.age; -test("can mix and match masked vs unmasked fragment fields with proper warnings", () => { - using _ = spyOnConsole("warn"); + expect(age).toBe(30); + expect(console.warn).not.toHaveBeenCalled(); + }); - const query = gql` - query UnmaskedQuery { - currentUser { - __typename - id - name - ...UserFields @unmask + test("warns when accessing unmasked fields in arrays with mode: 'migrate'", () => { + using _ = spyOnConsole("warn"); + const query = gql` + query UnmaskedQuery { + users { + __typename + id + name + ...UserFields @unmask(mode: "migrate") + } } - } - fragment UserFields on User { - age - profile { - __typename - email - ... @defer { - username + fragment UserFields on User { + age + } + `; + + const fragmentMatcher = createFragmentMatcher(new InMemoryCache()); + + const data = maskOperation( + deepFreeze({ + users: [ + { __typename: "User", id: 1, name: "John Doe", age: 30 }, + { __typename: "User", id: 2, name: "Jane Doe", age: 30 }, + ], + }), + query, + fragmentMatcher + ); + + data.users[0].age; + data.users[1].age; + + expect(console.warn).toHaveBeenCalledTimes(2); + expect(console.warn).toHaveBeenCalledWith( + "Accessing unmasked field on %s at path '%s'. This field will not be available when masking is enabled. Please read the field from the fragment instead.", + "query 'UnmaskedQuery'", + "users[0].age" + ); + + expect(console.warn).toHaveBeenCalledWith( + "Accessing unmasked field on %s at path '%s'. This field will not be available when masking is enabled. Please read the field from the fragment instead.", + "query 'UnmaskedQuery'", + "users[1].age" + ); + }); + + test("can mix and match masked vs unmasked fragment fields with proper warnings", () => { + using _ = spyOnConsole("warn"); + + const query = gql` + query UnmaskedQuery { + currentUser { + __typename + id + name + ...UserFields @unmask } - ...ProfileFields } - skills { - __typename - name - ...SkillFields @unmask(mode: "migrate") + + fragment UserFields on User { + age + profile { + __typename + email + ... @defer { + username + } + ...ProfileFields + } + skills { + __typename + name + ...SkillFields @unmask(mode: "migrate") + } } - } - fragment ProfileFields on Profile { - settings { - __typename - darkMode + fragment ProfileFields on Profile { + settings { + __typename + darkMode + } } - } - fragment SkillFields on Skill { - description - } - `; + fragment SkillFields on Skill { + description + } + `; - const data = maskOperation( - deepFreeze({ + const data = maskOperation( + deepFreeze({ + currentUser: { + __typename: "User", + id: 1, + name: "Test User", + age: 30, + profile: { + __typename: "Profile", + email: "testuser@example.com", + username: "testuser", + settings: { + __typename: "Settings", + darkMode: true, + }, + }, + skills: [ + { + __typename: "Skill", + name: "Skill 1", + description: "Skill 1 description", + }, + { + __typename: "Skill", + name: "Skill 2", + description: "Skill 2 description", + }, + ], + }, + }), + query, + createFragmentMatcher(new InMemoryCache()) + ); + + expect(data).toEqual({ currentUser: { __typename: "User", id: 1, @@ -1163,10 +1200,6 @@ test("can mix and match masked vs unmasked fragment fields with proper warnings" __typename: "Profile", email: "testuser@example.com", username: "testuser", - settings: { - __typename: "Settings", - darkMode: true, - }, }, skills: [ { @@ -1181,13 +1214,63 @@ test("can mix and match masked vs unmasked fragment fields with proper warnings" }, ], }, - }), - query, - createFragmentMatcher(new InMemoryCache()) - ); + }); + + expect(console.warn).toHaveBeenCalledTimes(2); + expect(console.warn).toHaveBeenCalledWith( + "Accessing unmasked field on %s at path '%s'. This field will not be available when masking is enabled. Please read the field from the fragment instead.", + "query 'UnmaskedQuery'", + "currentUser.skills[0].description" + ); + expect(console.warn).toHaveBeenCalledWith( + "Accessing unmasked field on %s at path '%s'. This field will not be available when masking is enabled. Please read the field from the fragment instead.", + "query 'UnmaskedQuery'", + "currentUser.skills[1].description" + ); + }); + + test("warns when accessing unmasked fields with complex selections with mode: 'migrate'", () => { + using _ = spyOnConsole("warn"); + const query = gql` + query UnmaskedQuery { + currentUser { + __typename + id + name + ...UserFields @unmask(mode: "migrate") + } + } - expect(data).toEqual({ - currentUser: { + fragment UserFields on User { + age + profile { + __typename + email + ... @defer { + username + } + ...ProfileFields @unmask(mode: "migrate") + } + skills { + __typename + name + ...SkillFields @unmask(mode: "migrate") + } + } + + fragment ProfileFields on Profile { + settings { + __typename + dark: darkMode + } + } + + fragment SkillFields on Skill { + description + } + `; + + const currentUser = { __typename: "User", id: 1, name: "Test User", @@ -1196,6 +1279,10 @@ test("can mix and match masked vs unmasked fragment fields with proper warnings" __typename: "Profile", email: "testuser@example.com", username: "testuser", + settings: { + __typename: "Settings", + dark: true, + }, }, skills: [ { @@ -1209,687 +1296,607 @@ test("can mix and match masked vs unmasked fragment fields with proper warnings" description: "Skill 2 description", }, ], - }, + }; + + const fragmentMatcher = createFragmentMatcher(new InMemoryCache()); + + const data = maskOperation( + deepFreeze({ currentUser }), + query, + fragmentMatcher + ); + + data.currentUser.age; + data.currentUser.profile.email; + data.currentUser.profile.username; + data.currentUser.profile.settings; + data.currentUser.profile.settings.dark; + data.currentUser.skills[0].description; + data.currentUser.skills[1].description; + + expect(console.warn).toHaveBeenCalledTimes(9); + expect(console.warn).toHaveBeenCalledWith( + "Accessing unmasked field on %s at path '%s'. This field will not be available when masking is enabled. Please read the field from the fragment instead.", + "query 'UnmaskedQuery'", + "currentUser.age" + ); + + expect(console.warn).toHaveBeenCalledWith( + "Accessing unmasked field on %s at path '%s'. This field will not be available when masking is enabled. Please read the field from the fragment instead.", + "query 'UnmaskedQuery'", + "currentUser.profile" + ); + + expect(console.warn).toHaveBeenCalledWith( + "Accessing unmasked field on %s at path '%s'. This field will not be available when masking is enabled. Please read the field from the fragment instead.", + "query 'UnmaskedQuery'", + "currentUser.profile.email" + ); + + expect(console.warn).toHaveBeenCalledWith( + "Accessing unmasked field on %s at path '%s'. This field will not be available when masking is enabled. Please read the field from the fragment instead.", + "query 'UnmaskedQuery'", + "currentUser.profile.username" + ); + + expect(console.warn).toHaveBeenCalledWith( + "Accessing unmasked field on %s at path '%s'. This field will not be available when masking is enabled. Please read the field from the fragment instead.", + "query 'UnmaskedQuery'", + "currentUser.profile.settings" + ); + + expect(console.warn).toHaveBeenCalledWith( + "Accessing unmasked field on %s at path '%s'. This field will not be available when masking is enabled. Please read the field from the fragment instead.", + "query 'UnmaskedQuery'", + "currentUser.profile.settings.dark" + ); + + expect(console.warn).toHaveBeenCalledWith( + "Accessing unmasked field on %s at path '%s'. This field will not be available when masking is enabled. Please read the field from the fragment instead.", + "query 'UnmaskedQuery'", + "currentUser.skills" + ); + + expect(console.warn).toHaveBeenCalledWith( + "Accessing unmasked field on %s at path '%s'. This field will not be available when masking is enabled. Please read the field from the fragment instead.", + "query 'UnmaskedQuery'", + "currentUser.skills[0].description" + ); + + expect(console.warn).toHaveBeenCalledWith( + "Accessing unmasked field on %s at path '%s'. This field will not be available when masking is enabled. Please read the field from the fragment instead.", + "query 'UnmaskedQuery'", + "currentUser.skills[1].description" + ); }); - expect(console.warn).toHaveBeenCalledTimes(2); - expect(console.warn).toHaveBeenCalledWith( - "Accessing unmasked field on %s at path '%s'. This field will not be available when masking is enabled. Please read the field from the fragment instead.", - "query 'UnmaskedQuery'", - "currentUser.skills[0].description" - ); - expect(console.warn).toHaveBeenCalledWith( - "Accessing unmasked field on %s at path '%s'. This field will not be available when masking is enabled. Please read the field from the fragment instead.", - "query 'UnmaskedQuery'", - "currentUser.skills[1].description" - ); -}); - -test("warns when accessing unmasked fields with complex selections with mode: 'migrate'", () => { - using _ = spyOnConsole("warn"); - const query = gql` - query UnmaskedQuery { - currentUser { - __typename - id - name - ...UserFields @unmask(mode: "migrate") - } - } - - fragment UserFields on User { - age - profile { - __typename - email - ... @defer { - username + test("does not warn when accessing fields shared between the query and fragment with mode: 'migrate'", () => { + using _ = spyOnConsole("warn"); + const query = gql` + query UnmaskedQuery { + currentUser { + __typename + id + name + age + ...UserFields @unmask(mode: "migrate") + email } - ...ProfileFields @unmask(mode: "migrate") - } - skills { - __typename - name - ...SkillFields @unmask(mode: "migrate") } - } - fragment ProfileFields on Profile { - settings { - __typename - dark: darkMode - } - } - - fragment SkillFields on Skill { - description - } - `; - - const currentUser = { - __typename: "User", - id: 1, - name: "Test User", - age: 30, - profile: { - __typename: "Profile", - email: "testuser@example.com", - username: "testuser", - settings: { - __typename: "Settings", - dark: true, - }, - }, - skills: [ - { - __typename: "Skill", - name: "Skill 1", - description: "Skill 1 description", - }, - { - __typename: "Skill", - name: "Skill 2", - description: "Skill 2 description", - }, - ], - }; - - const fragmentMatcher = createFragmentMatcher(new InMemoryCache()); - - const data = maskOperation( - deepFreeze({ currentUser }), - query, - fragmentMatcher - ); - - data.currentUser.age; - data.currentUser.profile.email; - data.currentUser.profile.username; - data.currentUser.profile.settings; - data.currentUser.profile.settings.dark; - data.currentUser.skills[0].description; - data.currentUser.skills[1].description; - - expect(console.warn).toHaveBeenCalledTimes(9); - expect(console.warn).toHaveBeenCalledWith( - "Accessing unmasked field on %s at path '%s'. This field will not be available when masking is enabled. Please read the field from the fragment instead.", - "query 'UnmaskedQuery'", - "currentUser.age" - ); - - expect(console.warn).toHaveBeenCalledWith( - "Accessing unmasked field on %s at path '%s'. This field will not be available when masking is enabled. Please read the field from the fragment instead.", - "query 'UnmaskedQuery'", - "currentUser.profile" - ); - - expect(console.warn).toHaveBeenCalledWith( - "Accessing unmasked field on %s at path '%s'. This field will not be available when masking is enabled. Please read the field from the fragment instead.", - "query 'UnmaskedQuery'", - "currentUser.profile.email" - ); - - expect(console.warn).toHaveBeenCalledWith( - "Accessing unmasked field on %s at path '%s'. This field will not be available when masking is enabled. Please read the field from the fragment instead.", - "query 'UnmaskedQuery'", - "currentUser.profile.username" - ); - - expect(console.warn).toHaveBeenCalledWith( - "Accessing unmasked field on %s at path '%s'. This field will not be available when masking is enabled. Please read the field from the fragment instead.", - "query 'UnmaskedQuery'", - "currentUser.profile.settings" - ); - - expect(console.warn).toHaveBeenCalledWith( - "Accessing unmasked field on %s at path '%s'. This field will not be available when masking is enabled. Please read the field from the fragment instead.", - "query 'UnmaskedQuery'", - "currentUser.profile.settings.dark" - ); - - expect(console.warn).toHaveBeenCalledWith( - "Accessing unmasked field on %s at path '%s'. This field will not be available when masking is enabled. Please read the field from the fragment instead.", - "query 'UnmaskedQuery'", - "currentUser.skills" - ); - - expect(console.warn).toHaveBeenCalledWith( - "Accessing unmasked field on %s at path '%s'. This field will not be available when masking is enabled. Please read the field from the fragment instead.", - "query 'UnmaskedQuery'", - "currentUser.skills[0].description" - ); - - expect(console.warn).toHaveBeenCalledWith( - "Accessing unmasked field on %s at path '%s'. This field will not be available when masking is enabled. Please read the field from the fragment instead.", - "query 'UnmaskedQuery'", - "currentUser.skills[1].description" - ); -}); - -test("does not warn when accessing fields shared between the query and fragment with mode: 'migrate'", () => { - using _ = spyOnConsole("warn"); - const query = gql` - query UnmaskedQuery { - currentUser { - __typename - id - name + fragment UserFields on User { age - ...UserFields @unmask(mode: "migrate") email } - } + `; - fragment UserFields on User { - age - email - } - `; - - const fragmentMatcher = createFragmentMatcher(new InMemoryCache()); + const fragmentMatcher = createFragmentMatcher(new InMemoryCache()); - const data = maskOperation( - deepFreeze({ - currentUser: { - __typename: "User", - id: 1, - name: "Test User", - age: 30, - email: "testuser@example.com", - }, - }), - query, - fragmentMatcher - ); + const data = maskOperation( + deepFreeze({ + currentUser: { + __typename: "User", + id: 1, + name: "Test User", + age: 30, + email: "testuser@example.com", + }, + }), + query, + fragmentMatcher + ); - data.currentUser.age; - data.currentUser.email; + data.currentUser.age; + data.currentUser.email; - expect(console.warn).not.toHaveBeenCalled(); -}); + expect(console.warn).not.toHaveBeenCalled(); + }); -test("does not warn accessing fields with `@unmask` without mode argument", () => { - using _ = spyOnConsole("warn"); - const query = gql` - query UnmaskedQuery { - currentUser { - __typename - id - name - ...UserFields @unmask + test("does not warn accessing fields with `@unmask` without mode argument", () => { + using _ = spyOnConsole("warn"); + const query = gql` + query UnmaskedQuery { + currentUser { + __typename + id + name + ...UserFields @unmask + } } - } - fragment UserFields on User { - age - } - `; + fragment UserFields on User { + age + } + `; - const fragmentMatcher = createFragmentMatcher(new InMemoryCache()); + const fragmentMatcher = createFragmentMatcher(new InMemoryCache()); - const data = maskOperation( - deepFreeze({ - currentUser: { - __typename: "User", - id: 1, - name: "Test User", - age: 30, - }, - }), - query, - fragmentMatcher - ); + const data = maskOperation( + deepFreeze({ + currentUser: { + __typename: "User", + id: 1, + name: "Test User", + age: 30, + }, + }), + query, + fragmentMatcher + ); - data.currentUser.age; + data.currentUser.age; - expect(console.warn).not.toHaveBeenCalled(); -}); + expect(console.warn).not.toHaveBeenCalled(); + }); -test("masks fragments in subscription documents", () => { - const subscription = gql` - subscription { - onUserUpdated { - __typename - id - ...UserFields + test("masks fragments in subscription documents", () => { + const subscription = gql` + subscription { + onUserUpdated { + __typename + id + ...UserFields + } } - } - fragment UserFields on User { - name - } - `; + fragment UserFields on User { + name + } + `; - const data = maskOperation( - deepFreeze({ - onUserUpdated: { __typename: "User", id: 1, name: "Test User" }, - }), - subscription, - createFragmentMatcher(new InMemoryCache()) - ); + const data = maskOperation( + deepFreeze({ + onUserUpdated: { __typename: "User", id: 1, name: "Test User" }, + }), + subscription, + createFragmentMatcher(new InMemoryCache()) + ); - expect(data).toEqual({ onUserUpdated: { __typename: "User", id: 1 } }); -}); + expect(data).toEqual({ onUserUpdated: { __typename: "User", id: 1 } }); + }); -test("honors @unmask used in subscription documents", () => { - const subscription = gql` - subscription { - onUserUpdated { - __typename - id - ...UserFields @unmask + test("honors @unmask used in subscription documents", () => { + const subscription = gql` + subscription { + onUserUpdated { + __typename + id + ...UserFields @unmask + } } - } - fragment UserFields on User { - name - } - `; + fragment UserFields on User { + name + } + `; - const subscriptionData = deepFreeze({ - onUserUpdated: { __typename: "User", id: 1, name: "Test User" }, - }); + const subscriptionData = deepFreeze({ + onUserUpdated: { __typename: "User", id: 1, name: "Test User" }, + }); - const data = maskOperation( - subscriptionData, - subscription, - createFragmentMatcher(new InMemoryCache()) - ); + const data = maskOperation( + subscriptionData, + subscription, + createFragmentMatcher(new InMemoryCache()) + ); - expect(data).toBe(subscriptionData); -}); + expect(data).toBe(subscriptionData); + }); -test("warns when accessing unmasked fields used in subscription documents with @unmask(mode: 'migrate')", () => { - using _ = spyOnConsole("warn"); + test("warns when accessing unmasked fields used in subscription documents with @unmask(mode: 'migrate')", () => { + using _ = spyOnConsole("warn"); - const subscription = gql` - subscription UserUpdatedSubscription { - onUserUpdated { - __typename - id - ...UserFields @unmask(mode: "migrate") + const subscription = gql` + subscription UserUpdatedSubscription { + onUserUpdated { + __typename + id + ...UserFields @unmask(mode: "migrate") + } } - } - fragment UserFields on User { - name - } - `; + fragment UserFields on User { + name + } + `; - const subscriptionData = deepFreeze({ - onUserUpdated: { __typename: "User", id: 1, name: "Test User" }, - }); + const subscriptionData = deepFreeze({ + onUserUpdated: { __typename: "User", id: 1, name: "Test User" }, + }); - const data = maskOperation( - subscriptionData, - subscription, - createFragmentMatcher(new InMemoryCache()) - ); + const data = maskOperation( + subscriptionData, + subscription, + createFragmentMatcher(new InMemoryCache()) + ); - data.onUserUpdated.name; + data.onUserUpdated.name; - expect(console.warn).toHaveBeenCalledTimes(1); - expect(console.warn).toHaveBeenCalledWith( - "Accessing unmasked field on %s at path '%s'. This field will not be available when masking is enabled. Please read the field from the fragment instead.", - "subscription 'UserUpdatedSubscription'", - "onUserUpdated.name" - ); -}); + expect(console.warn).toHaveBeenCalledTimes(1); + expect(console.warn).toHaveBeenCalledWith( + "Accessing unmasked field on %s at path '%s'. This field will not be available when masking is enabled. Please read the field from the fragment instead.", + "subscription 'UserUpdatedSubscription'", + "onUserUpdated.name" + ); + }); -test("masks fragments in mutation documents", () => { - const mutation = gql` - mutation { - updateUser { - __typename - id - ...UserFields + test("masks fragments in mutation documents", () => { + const mutation = gql` + mutation { + updateUser { + __typename + id + ...UserFields + } } - } - - fragment UserFields on User { - name - } - `; - - const data = maskOperation( - deepFreeze({ - updateUser: { __typename: "User", id: 1, name: "Test User" }, - }), - mutation, - createFragmentMatcher(new InMemoryCache()) - ); - expect(data).toEqual({ updateUser: { __typename: "User", id: 1 } }); -}); - -test("honors @unmask used in mutation documents", () => { - const mutation = gql` - mutation { - updateUser { - __typename - id - ...UserFields @unmask + fragment UserFields on User { + name } - } + `; - fragment UserFields on User { - name - } - `; + const data = maskOperation( + deepFreeze({ + updateUser: { __typename: "User", id: 1, name: "Test User" }, + }), + mutation, + createFragmentMatcher(new InMemoryCache()) + ); - const mutationData = deepFreeze({ - updateUser: { __typename: "User", id: 1, name: "Test User" }, + expect(data).toEqual({ updateUser: { __typename: "User", id: 1 } }); }); - const data = maskOperation( - mutationData, - mutation, - createFragmentMatcher(new InMemoryCache()) - ); - - expect(data).toBe(mutationData); -}); - -test("warns when accessing unmasked fields used in mutation documents with @unmask(mode: 'migrate')", () => { - using _ = spyOnConsole("warn"); + test("honors @unmask used in mutation documents", () => { + const mutation = gql` + mutation { + updateUser { + __typename + id + ...UserFields @unmask + } + } - const mutation = gql` - mutation UpdateUserMutation { - updateUser { - __typename - id - ...UserFields @unmask(mode: "migrate") + fragment UserFields on User { + name } - } + `; - fragment UserFields on User { - name - } - `; + const mutationData = deepFreeze({ + updateUser: { __typename: "User", id: 1, name: "Test User" }, + }); + + const data = maskOperation( + mutationData, + mutation, + createFragmentMatcher(new InMemoryCache()) + ); - const mutationData = deepFreeze({ - updateUser: { __typename: "User", id: 1, name: "Test User" }, + expect(data).toBe(mutationData); }); - const data = maskOperation( - mutationData, - mutation, - createFragmentMatcher(new InMemoryCache()) - ); + test("warns when accessing unmasked fields used in mutation documents with @unmask(mode: 'migrate')", () => { + using _ = spyOnConsole("warn"); - data.updateUser.name; + const mutation = gql` + mutation UpdateUserMutation { + updateUser { + __typename + id + ...UserFields @unmask(mode: "migrate") + } + } - expect(console.warn).toHaveBeenCalledTimes(1); - expect(console.warn).toHaveBeenCalledWith( - "Accessing unmasked field on %s at path '%s'. This field will not be available when masking is enabled. Please read the field from the fragment instead.", - "mutation 'UpdateUserMutation'", - "updateUser.name" - ); -}); + fragment UserFields on User { + name + } + `; -test("masks named fragments in fragment documents", () => { - const fragment = gql` - fragment UserFields on User { - __typename - id - ...UserProfile - } + const mutationData = deepFreeze({ + updateUser: { __typename: "User", id: 1, name: "Test User" }, + }); - fragment UserProfile on User { - age - } - `; + const data = maskOperation( + mutationData, + mutation, + createFragmentMatcher(new InMemoryCache()) + ); - const data = maskFragment( - deepFreeze({ __typename: "User", id: 1, age: 30 }), - fragment, - createFragmentMatcher(new InMemoryCache()), - "UserFields" - ); + data.updateUser.name; - expect(data).toEqual({ __typename: "User", id: 1 }); + expect(console.warn).toHaveBeenCalledTimes(1); + expect(console.warn).toHaveBeenCalledWith( + "Accessing unmasked field on %s at path '%s'. This field will not be available when masking is enabled. Please read the field from the fragment instead.", + "mutation 'UpdateUserMutation'", + "updateUser.name" + ); + }); }); -test("masks named fragments in nested fragment objects", () => { - const fragment = gql` - fragment UserFields on User { - __typename - id - profile { +describe("maskFragment", () => { + test("masks named fragments in fragment documents", () => { + const fragment = gql` + fragment UserFields on User { __typename + id ...UserProfile } - } - - fragment UserProfile on User { - age - } - `; - - const data = maskFragment( - deepFreeze({ - __typename: "User", - id: 1, - profile: { __typename: "Profile", age: 30 }, - }), - fragment, - createFragmentMatcher(new InMemoryCache()), - "UserFields" - ); - - expect(data).toEqual({ - __typename: "User", - id: 1, - profile: { __typename: "Profile" }, - }); -}); -test("does not mask inline fragment in fragment documents", () => { - const fragment = gql` - fragment UserFields on User { - __typename - id - ... @defer { + fragment UserProfile on User { age } - } - `; + `; - const data = maskFragment( - deepFreeze({ __typename: "User", id: 1, age: 30 }), - fragment, - createFragmentMatcher(new InMemoryCache()), - "UserFields" - ); + const data = maskFragment( + deepFreeze({ __typename: "User", id: 1, age: 30 }), + fragment, + createFragmentMatcher(new InMemoryCache()), + "UserFields" + ); - expect(data).toEqual({ __typename: "User", id: 1, age: 30 }); -}); + expect(data).toEqual({ __typename: "User", id: 1 }); + }); -test("throws when document contains more than 1 fragment without a fragmentName", () => { - const fragment = gql` - fragment UserFields on User { - __typename - id - ...UserProfile - } + test("masks named fragments in nested fragment objects", () => { + const fragment = gql` + fragment UserFields on User { + __typename + id + profile { + __typename + ...UserProfile + } + } - fragment UserProfile on User { - age - } - `; + fragment UserProfile on User { + age + } + `; - expect(() => - maskFragment( - deepFreeze({ __typename: "User", id: 1, age: 30 }), + const data = maskFragment( + deepFreeze({ + __typename: "User", + id: 1, + profile: { __typename: "Profile", age: 30 }, + }), fragment, - createFragmentMatcher(new InMemoryCache()) - ) - ).toThrow( - new InvariantError( - "Found 2 fragments. `fragmentName` must be provided when there is not exactly 1 fragment." - ) - ); -}); + createFragmentMatcher(new InMemoryCache()), + "UserFields" + ); -test("throws when fragment cannot be found within document", () => { - const fragment = gql` - fragment UserFields on User { - __typename - id - ...UserProfile - } + expect(data).toEqual({ + __typename: "User", + id: 1, + profile: { __typename: "Profile" }, + }); + }); - fragment UserProfile on User { - age - } - `; + test("does not mask inline fragment in fragment documents", () => { + const fragment = gql` + fragment UserFields on User { + __typename + id + ... @defer { + age + } + } + `; - expect(() => - maskFragment( + const data = maskFragment( deepFreeze({ __typename: "User", id: 1, age: 30 }), fragment, createFragmentMatcher(new InMemoryCache()), - "ProfileFields" - ) - ).toThrow( - new InvariantError('Could not find fragment with name "ProfileFields".') - ); -}); + "UserFields" + ); + + expect(data).toEqual({ __typename: "User", id: 1, age: 30 }); + }); -test("maintains referential equality on fragment subtrees that did not change", () => { - const fragment = gql` - fragment UserFields on User { - __typename - id - profile { + test("throws when document contains more than 1 fragment without a fragmentName", () => { + const fragment = gql` + fragment UserFields on User { __typename - ...ProfileFields + id + ...UserProfile } - post { + + fragment UserProfile on User { + age + } + `; + + expect(() => + maskFragment( + deepFreeze({ __typename: "User", id: 1, age: 30 }), + fragment, + createFragmentMatcher(new InMemoryCache()) + ) + ).toThrow( + new InvariantError( + "Found 2 fragments. `fragmentName` must be provided when there is not exactly 1 fragment." + ) + ); + }); + + test("throws when fragment cannot be found within document", () => { + const fragment = gql` + fragment UserFields on User { __typename id - title + ...UserProfile + } + + fragment UserProfile on User { + age } - industries { + `; + + expect(() => + maskFragment( + deepFreeze({ __typename: "User", id: 1, age: 30 }), + fragment, + createFragmentMatcher(new InMemoryCache()), + "ProfileFields" + ) + ).toThrow( + new InvariantError('Could not find fragment with name "ProfileFields".') + ); + }); + + test("maintains referential equality on fragment subtrees that did not change", () => { + const fragment = gql` + fragment UserFields on User { __typename - ... on TechIndustry { - languageRequirements - } - ... on FinanceIndustry { - ...FinanceIndustryFields + id + profile { + __typename + ...ProfileFields } - ... on TradeIndustry { + post { + __typename id - yearsInBusiness - ...TradeIndustryFields + title } - } - drinks { - __typename - ... on SportsDrink { - ...SportsDrinkFields + industries { + __typename + ... on TechIndustry { + languageRequirements + } + ... on FinanceIndustry { + ...FinanceIndustryFields + } + ... on TradeIndustry { + id + yearsInBusiness + ...TradeIndustryFields + } } - ... on Espresso { + drinks { __typename + ... on SportsDrink { + ...SportsDrinkFields + } + ... on Espresso { + __typename + } } } - } - fragment ProfileFields on Profile { - age - } - - fragment FinanceIndustryFields on FinanceIndustry { - yearsInBusiness - } + fragment ProfileFields on Profile { + age + } - fragment TradeIndustryFields on TradeIndustry { - languageRequirements - } + fragment FinanceIndustryFields on FinanceIndustry { + yearsInBusiness + } - fragment SportsDrinkFields on SportsDrink { - saltContent - } - `; + fragment TradeIndustryFields on TradeIndustry { + languageRequirements + } - const profile = { - __typename: "Profile", - age: 30, - }; - const post = { __typename: "Post", id: 1, title: "Test Post" }; - const industries = [ - { __typename: "TechIndustry", languageRequirements: ["TypeScript"] }, - { __typename: "FinanceIndustry", yearsInBusiness: 10 }, - { - __typename: "TradeIndustry", - id: 10, - yearsInBusiness: 15, - languageRequirements: ["English", "German"], - }, - ]; - const drinks = [ - { __typename: "Espresso" }, - { __typename: "SportsDrink", saltContent: "1000mg" }, - ]; - const user = deepFreeze({ - __typename: "User", - id: 1, - profile, - post, - industries, - drinks, - }); + fragment SportsDrinkFields on SportsDrink { + saltContent + } + `; - const data = maskFragment( - user, - fragment, - createFragmentMatcher(new InMemoryCache()), - "UserFields" - ); - - expect(data).toEqual({ - __typename: "User", - id: 1, - profile: { __typename: "Profile" }, - post: { __typename: "Post", id: 1, title: "Test Post" }, - industries: [ + const profile = { + __typename: "Profile", + age: 30, + }; + const post = { __typename: "Post", id: 1, title: "Test Post" }; + const industries = [ { __typename: "TechIndustry", languageRequirements: ["TypeScript"] }, - { __typename: "FinanceIndustry" }, - { __typename: "TradeIndustry", id: 10, yearsInBusiness: 15 }, - ], - drinks: [{ __typename: "Espresso" }, { __typename: "SportsDrink" }], - }); + { __typename: "FinanceIndustry", yearsInBusiness: 10 }, + { + __typename: "TradeIndustry", + id: 10, + yearsInBusiness: 15, + languageRequirements: ["English", "German"], + }, + ]; + const drinks = [ + { __typename: "Espresso" }, + { __typename: "SportsDrink", saltContent: "1000mg" }, + ]; + const user = deepFreeze({ + __typename: "User", + id: 1, + profile, + post, + industries, + drinks, + }); + + const data = maskFragment( + user, + fragment, + createFragmentMatcher(new InMemoryCache()), + "UserFields" + ); - expect(data).not.toBe(user); - expect(data.profile).not.toBe(profile); - expect(data.post).toBe(post); - expect(data.industries).not.toBe(industries); - expect(data.industries[0]).toBe(industries[0]); - expect(data.industries[1]).not.toBe(industries[1]); - expect(data.industries[2]).not.toBe(industries[2]); - expect(data.drinks).not.toBe(drinks); - expect(data.drinks[0]).toBe(drinks[0]); - expect(data.drinks[1]).not.toBe(drinks[1]); -}); + expect(data).toEqual({ + __typename: "User", + id: 1, + profile: { __typename: "Profile" }, + post: { __typename: "Post", id: 1, title: "Test Post" }, + industries: [ + { __typename: "TechIndustry", languageRequirements: ["TypeScript"] }, + { __typename: "FinanceIndustry" }, + { __typename: "TradeIndustry", id: 10, yearsInBusiness: 15 }, + ], + drinks: [{ __typename: "Espresso" }, { __typename: "SportsDrink" }], + }); + + expect(data).not.toBe(user); + expect(data.profile).not.toBe(profile); + expect(data.post).toBe(post); + expect(data.industries).not.toBe(industries); + expect(data.industries[0]).toBe(industries[0]); + expect(data.industries[1]).not.toBe(industries[1]); + expect(data.industries[2]).not.toBe(industries[2]); + expect(data.drinks).not.toBe(drinks); + expect(data.drinks[0]).toBe(drinks[0]); + expect(data.drinks[1]).not.toBe(drinks[1]); + }); -test("maintains referential equality on fragment when no data is masked", () => { - const fragment = gql` - fragment UserFields on User { - __typename - id - age - } - `; + test("maintains referential equality on fragment when no data is masked", () => { + const fragment = gql` + fragment UserFields on User { + __typename + id + age + } + `; - const user = { __typename: "User", id: 1, age: 30 }; + const user = { __typename: "User", id: 1, age: 30 }; - const data = maskFragment( - deepFreeze(user), - fragment, - createFragmentMatcher(new InMemoryCache()) - ); + const data = maskFragment( + deepFreeze(user), + fragment, + createFragmentMatcher(new InMemoryCache()) + ); - expect(data).toBe(user); + expect(data).toBe(user); + }); }); function createFragmentMatcher(cache: InMemoryCache) { From 216452307464665b1cf47ce70260eced94c7d29f Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Thu, 18 Jul 2024 16:23:59 -0600 Subject: [PATCH 080/103] Add additional tests to ensure @unmask works with maskFragment --- src/core/__tests__/masking.test.ts | 202 +++++++++++++++++++++++++++++ 1 file changed, 202 insertions(+) diff --git a/src/core/__tests__/masking.test.ts b/src/core/__tests__/masking.test.ts index 896a3dee8f8..721d3355e31 100644 --- a/src/core/__tests__/masking.test.ts +++ b/src/core/__tests__/masking.test.ts @@ -1897,6 +1897,208 @@ describe("maskFragment", () => { expect(data).toBe(user); }); + + test("does not mask named fragments and returns original object when using `@unmask` directive", () => { + const fragment = gql` + fragment UnmaskedFragment on User { + id + name + ...UserFields @unmask + __typename + } + + fragment UserFields on User { + age + } + `; + + const fragmentData = deepFreeze({ + __typename: "User", + id: 1, + name: "Test User", + age: 30, + }); + + const data = maskFragment( + fragmentData, + fragment, + createFragmentMatcher(new InMemoryCache()), + "UnmaskedFragment" + ); + + expect(data).toBe(fragmentData); + }); + + test("warns when accessing unmasked fields when using `@unmask` directive with mode 'migrate'", () => { + using _ = spyOnConsole("warn"); + const query = gql` + fragment UnmaskedFragment on User { + __typename + id + name + ...UserFields @unmask(mode: "migrate") + } + + fragment UserFields on User { + age + } + `; + + const fragmentMatcher = createFragmentMatcher(new InMemoryCache()); + + const data = maskFragment( + deepFreeze({ + currentUser: { + __typename: "User", + id: 1, + name: "Test User", + age: 30, + }, + }), + query, + fragmentMatcher, + "UnmaskedFragment" + ); + + data.age; + + expect(console.warn).toHaveBeenCalledTimes(1); + expect(console.warn).toHaveBeenCalledWith( + "Accessing unmasked field on %s at path '%s'. This field will not be available when masking is enabled. Please read the field from the fragment instead.", + "fragment 'UnmaskedFragment'", + "age" + ); + + data.age; + + // Ensure we only warn once for each masked field + expect(console.warn).toHaveBeenCalledTimes(1); + }); + + test("maintains referential equality on `@unmask` fragment subtrees", () => { + const fragment = gql` + fragment UserFields on User { + __typename + id + profile { + __typename + ...ProfileFields @unmask + } + post { + __typename + id + title + } + industries { + __typename + ... on TechIndustry { + languageRequirements + } + ... on FinanceIndustry { + ...FinanceIndustryFields + } + ... on TradeIndustry { + id + yearsInBusiness + ...TradeIndustryFields + } + } + drinks { + __typename + ... on SportsDrink { + ...SportsDrinkFields @unmask + } + ... on Espresso { + __typename + } + } + } + + fragment ProfileFields on Profile { + age + ...ProfileSubfields @unmask + } + + fragment ProfileSubfields on Profile { + name + } + + fragment FinanceIndustryFields on FinanceIndustry { + yearsInBusiness + } + + fragment TradeIndustryFields on TradeIndustry { + languageRequirements + } + + fragment SportsDrinkFields on SportsDrink { + saltContent + } + `; + + const profile = { + __typename: "Profile", + age: 30, + name: "Test User", + }; + const post = { __typename: "Post", id: 1, title: "Test Post" }; + const industries = [ + { __typename: "TechIndustry", languageRequirements: ["TypeScript"] }, + { __typename: "FinanceIndustry", yearsInBusiness: 10 }, + { + __typename: "TradeIndustry", + id: 10, + yearsInBusiness: 15, + languageRequirements: ["English", "German"], + }, + ]; + const drinks = [ + { __typename: "Espresso" }, + { __typename: "SportsDrink", saltContent: "1000mg" }, + ]; + const user = deepFreeze({ + __typename: "User", + id: 1, + profile, + post, + industries, + drinks, + }); + + const data = maskFragment( + user, + fragment, + createFragmentMatcher(new InMemoryCache()), + "UserFields" + ); + + expect(data).toEqual({ + __typename: "User", + id: 1, + profile: { __typename: "Profile", age: 30, name: "Test User" }, + post: { __typename: "Post", id: 1, title: "Test Post" }, + industries: [ + { __typename: "TechIndustry", languageRequirements: ["TypeScript"] }, + { __typename: "FinanceIndustry" }, + { __typename: "TradeIndustry", id: 10, yearsInBusiness: 15 }, + ], + drinks: [ + { __typename: "Espresso" }, + { __typename: "SportsDrink", saltContent: "1000mg" }, + ], + }); + + expect(data).not.toBe(user); + expect(data.profile).toBe(profile); + expect(data.post).toBe(post); + expect(data.industries).not.toBe(industries); + expect(data.industries[0]).toBe(industries[0]); + expect(data.industries[1]).not.toBe(industries[1]); + expect(data.industries[2]).not.toBe(industries[2]); + expect(data.drinks).toBe(drinks); + expect(data.drinks[0]).toBe(drinks[0]); + expect(data.drinks[1]).toBe(drinks[1]); + }); }); function createFragmentMatcher(cache: InMemoryCache) { From f4b5734bcb325a904b822e4a638861f6edaba353 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Thu, 18 Jul 2024 16:27:24 -0600 Subject: [PATCH 081/103] Remove mode from unmaskFragmentFields --- src/core/masking.ts | 38 ++++++++++++++------------------------ 1 file changed, 14 insertions(+), 24 deletions(-) diff --git a/src/core/masking.ts b/src/core/masking.ts index 64ac412d256..2b91b8df92b 100644 --- a/src/core/masking.ts +++ b/src/core/masking.ts @@ -198,7 +198,6 @@ function maskSelectionSet( data, fragment.selectionSet, path, - mode, context ), true, @@ -214,7 +213,6 @@ function unmaskFragmentFields( parent: Record, selectionSetNode: SelectionSetNode, path: PathSelection, - mode: "unmask" | "migrate", context: MaskingContext ) { if (Array.isArray(parent)) { @@ -224,7 +222,6 @@ function unmaskFragmentFields( item, selectionSetNode, [...path, index], - mode, context ); }); @@ -240,28 +237,23 @@ function unmaskFragmentFields( return; } - if (mode === "migrate") { - let value = parent[keyName]; + let value = parent[keyName]; - if (childSelectionSet) { - value = unmaskFragmentFields( - memo[keyName] ?? Object.create(null), - parent[keyName] as Record, - childSelectionSet, - [...path, keyName], - mode, - context - ); - } + if (childSelectionSet) { + value = unmaskFragmentFields( + memo[keyName] ?? Object.create(null), + parent[keyName] as Record, + childSelectionSet, + [...path, keyName], + context + ); + } - if (__DEV__) { - addAccessorWarning(memo, value, keyName, path, context); - } + if (__DEV__) { + addAccessorWarning(memo, value, keyName, path, context); + } - if (!__DEV__) { - memo[keyName] = parent[keyName]; - } - } else { + if (!__DEV__) { memo[keyName] = parent[keyName]; } @@ -273,7 +265,6 @@ function unmaskFragmentFields( parent, selection.selectionSet, path, - mode, context ); } @@ -283,7 +274,6 @@ function unmaskFragmentFields( parent, context.fragmentMap[selection.name.value].selectionSet, path, - mode, context ); } From 532f2de07e33a1cdb0ce2064a2158fabfb612c9c Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Thu, 18 Jul 2024 16:29:58 -0600 Subject: [PATCH 082/103] Rename unmaskFragmentFields to addFieldAccessorWarnings --- src/core/masking.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/core/masking.ts b/src/core/masking.ts index 2b91b8df92b..d18025b3f0c 100644 --- a/src/core/masking.ts +++ b/src/core/masking.ts @@ -193,7 +193,7 @@ function maskSelectionSet( } return [ - unmaskFragmentFields( + addFieldAccessorWarnings( memo, data, fragment.selectionSet, @@ -208,7 +208,7 @@ function maskSelectionSet( ); } -function unmaskFragmentFields( +function addFieldAccessorWarnings( memo: Record, parent: Record, selectionSetNode: SelectionSetNode, @@ -217,7 +217,7 @@ function unmaskFragmentFields( ) { if (Array.isArray(parent)) { return parent.map((item, index): unknown => { - return unmaskFragmentFields( + return addFieldAccessorWarnings( memo[index] ?? Object.create(null), item, selectionSetNode, @@ -240,7 +240,7 @@ function unmaskFragmentFields( let value = parent[keyName]; if (childSelectionSet) { - value = unmaskFragmentFields( + value = addFieldAccessorWarnings( memo[keyName] ?? Object.create(null), parent[keyName] as Record, childSelectionSet, @@ -260,7 +260,7 @@ function unmaskFragmentFields( return; } case Kind.INLINE_FRAGMENT: { - return unmaskFragmentFields( + return addFieldAccessorWarnings( memo, parent, selection.selectionSet, @@ -269,7 +269,7 @@ function unmaskFragmentFields( ); } case Kind.FRAGMENT_SPREAD: { - return unmaskFragmentFields( + return addFieldAccessorWarnings( memo, parent, context.fragmentMap[selection.name.value].selectionSet, From fdbcbc77765a804e4e14baddc5c29126b0f49b85 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Thu, 18 Jul 2024 16:39:04 -0600 Subject: [PATCH 083/103] Rename parent to data --- src/core/masking.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/core/masking.ts b/src/core/masking.ts index d18025b3f0c..6f83a511fc2 100644 --- a/src/core/masking.ts +++ b/src/core/masking.ts @@ -210,13 +210,13 @@ function maskSelectionSet( function addFieldAccessorWarnings( memo: Record, - parent: Record, + data: Record, selectionSetNode: SelectionSetNode, path: PathSelection, context: MaskingContext ) { - if (Array.isArray(parent)) { - return parent.map((item, index): unknown => { + if (Array.isArray(data)) { + return data.map((item, index): unknown => { return addFieldAccessorWarnings( memo[index] ?? Object.create(null), item, @@ -237,12 +237,12 @@ function addFieldAccessorWarnings( return; } - let value = parent[keyName]; + let value = data[keyName]; if (childSelectionSet) { value = addFieldAccessorWarnings( memo[keyName] ?? Object.create(null), - parent[keyName] as Record, + data[keyName] as Record, childSelectionSet, [...path, keyName], context @@ -254,7 +254,7 @@ function addFieldAccessorWarnings( } if (!__DEV__) { - memo[keyName] = parent[keyName]; + memo[keyName] = data[keyName]; } return; @@ -262,7 +262,7 @@ function addFieldAccessorWarnings( case Kind.INLINE_FRAGMENT: { return addFieldAccessorWarnings( memo, - parent, + data, selection.selectionSet, path, context @@ -271,7 +271,7 @@ function addFieldAccessorWarnings( case Kind.FRAGMENT_SPREAD: { return addFieldAccessorWarnings( memo, - parent, + data, context.fragmentMap[selection.name.value].selectionSet, path, context From 77c636aa337b63dbcc54f028c6a1e12554844dbd Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Thu, 18 Jul 2024 16:50:27 -0600 Subject: [PATCH 084/103] Ensure child fragments of migrate mode are handled correctly --- src/core/__tests__/masking.test.ts | 61 ++++++++++++++++++++++++++++++ src/core/masking.ts | 25 +++++++++++- 2 files changed, 84 insertions(+), 2 deletions(-) diff --git a/src/core/__tests__/masking.test.ts b/src/core/__tests__/masking.test.ts index 721d3355e31..58a0d28638b 100644 --- a/src/core/__tests__/masking.test.ts +++ b/src/core/__tests__/masking.test.ts @@ -1229,6 +1229,67 @@ describe("maskOperation", () => { ); }); + test("masks child fragments of @unmask(mode: 'migrate')", () => { + using _ = spyOnConsole("warn"); + + const query = gql` + query UnmaskedQuery { + currentUser { + __typename + id + name + ...UserFields @unmask(mode: "migrate") + } + } + + fragment UserFields on User { + age + ...UserSubfields + ...UserSubfields2 @unmask + } + + fragment UserSubfields on User { + username + } + + fragment UserSubfields2 on User { + email + } + `; + + const data = maskOperation( + deepFreeze({ + currentUser: { + __typename: "User", + id: 1, + name: "Test User", + age: 30, + username: "testuser", + email: "test@example.com", + }, + }), + query, + createFragmentMatcher(new InMemoryCache()) + ); + + expect(data).toEqual({ + currentUser: { + __typename: "User", + id: 1, + name: "Test User", + age: 30, + email: "test@example.com", + }, + }); + + expect(console.warn).toHaveBeenCalledTimes(1); + expect(console.warn).toHaveBeenCalledWith( + "Accessing unmasked field on %s at path '%s'. This field will not be available when masking is enabled. Please read the field from the fragment instead.", + "query 'UnmaskedQuery'", + "currentUser.age" + ); + }); + test("warns when accessing unmasked fields with complex selections with mode: 'migrate'", () => { using _ = spyOnConsole("warn"); const query = gql` diff --git a/src/core/masking.ts b/src/core/masking.ts index 6f83a511fc2..9f5c95ed12b 100644 --- a/src/core/masking.ts +++ b/src/core/masking.ts @@ -173,7 +173,7 @@ function maskSelectionSet( changed || childChanged, ]; } - case Kind.FRAGMENT_SPREAD: + case Kind.FRAGMENT_SPREAD: { const fragment = context.fragmentMap[selection.name.value]; const mode = getFragmentMaskMode(selection); @@ -202,6 +202,7 @@ function maskSelectionSet( ), true, ]; + } } }, [Object.create(null), false] @@ -269,10 +270,30 @@ function addFieldAccessorWarnings( ); } case Kind.FRAGMENT_SPREAD: { + const fragment = context.fragmentMap[selection.name.value]; + const mode = getFragmentMaskMode(selection); + + if (mode === "mask") { + return memo; + } + + if (mode === "unmask") { + const [fragmentData] = maskSelectionSet( + data, + fragment.selectionSet, + context, + path + ); + + Object.assign(memo, fragmentData); + + return; + } + return addFieldAccessorWarnings( memo, data, - context.fragmentMap[selection.name.value].selectionSet, + fragment.selectionSet, path, context ); From c562e522ee80eaeae56b017d99a01f6ca7b6c58d Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Thu, 18 Jul 2024 16:55:20 -0600 Subject: [PATCH 085/103] Use reduce instead of forEach --- src/core/masking.ts | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/src/core/masking.ts b/src/core/masking.ts index 9f5c95ed12b..ecb8696299b 100644 --- a/src/core/masking.ts +++ b/src/core/masking.ts @@ -228,14 +228,14 @@ function addFieldAccessorWarnings( }); } - selectionSetNode.selections.forEach((selection) => { + return selectionSetNode.selections.reduce((memo, selection) => { switch (selection.kind) { case Kind.FIELD: { const keyName = resultKeyNameFromField(selection); const childSelectionSet = selection.selectionSet; if (keyName in memo) { - return; + return memo; } let value = data[keyName]; @@ -258,7 +258,7 @@ function addFieldAccessorWarnings( memo[keyName] = data[keyName]; } - return; + return memo; } case Kind.INLINE_FRAGMENT: { return addFieldAccessorWarnings( @@ -285,9 +285,7 @@ function addFieldAccessorWarnings( path ); - Object.assign(memo, fragmentData); - - return; + return Object.assign(memo, fragmentData); } return addFieldAccessorWarnings( @@ -299,9 +297,7 @@ function addFieldAccessorWarnings( ); } } - }); - - return memo; + }, memo); } function addAccessorWarning( From 8ff5a7fca1a8736a25f3b618ddc9aaef2bd4437b Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Thu, 18 Jul 2024 16:58:42 -0600 Subject: [PATCH 086/103] Add test for maskFragment on child of @unmask(mode: 'migrate') --- src/core/__tests__/masking.test.ts | 56 ++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/src/core/__tests__/masking.test.ts b/src/core/__tests__/masking.test.ts index 58a0d28638b..dde3237c1aa 100644 --- a/src/core/__tests__/masking.test.ts +++ b/src/core/__tests__/masking.test.ts @@ -2160,6 +2160,62 @@ describe("maskFragment", () => { expect(data.drinks[0]).toBe(drinks[0]); expect(data.drinks[1]).toBe(drinks[1]); }); + + test("masks child fragments of @unmask(mode: 'migrate')", () => { + using _ = spyOnConsole("warn"); + + const fragment = gql` + fragment UnmaskedUser on User { + __typename + id + name + ...UserFields @unmask(mode: "migrate") + } + + fragment UserFields on User { + age + ...UserSubfields + ...UserSubfields2 @unmask + } + + fragment UserSubfields on User { + username + } + + fragment UserSubfields2 on User { + email + } + `; + + const data = maskFragment( + deepFreeze({ + __typename: "User", + id: 1, + name: "Test User", + age: 30, + username: "testuser", + email: "test@example.com", + }), + fragment, + createFragmentMatcher(new InMemoryCache()), + "UnmaskedUser" + ); + + expect(data).toEqual({ + __typename: "User", + id: 1, + name: "Test User", + age: 30, + email: "test@example.com", + }); + + expect(console.warn).toHaveBeenCalledTimes(1); + expect(console.warn).toHaveBeenCalledWith( + "Accessing unmasked field on %s at path '%s'. This field will not be available when masking is enabled. Please read the field from the fragment instead.", + "fragment 'UnmaskedUser'", + "age" + ); + }); }); function createFragmentMatcher(cache: InMemoryCache) { From 27219baafbd9860b5372b9e11e3b0d83bdd08c94 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Thu, 18 Jul 2024 17:11:46 -0600 Subject: [PATCH 087/103] Deep freeze the masked value if data is frozen --- src/core/__tests__/masking.test.ts | 31 ++++++++++++++++++++++++++++++ src/core/masking.ts | 22 +++++++++++++++++++-- 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/src/core/__tests__/masking.test.ts b/src/core/__tests__/masking.test.ts index dde3237c1aa..d9ea27e96c2 100644 --- a/src/core/__tests__/masking.test.ts +++ b/src/core/__tests__/masking.test.ts @@ -106,6 +106,37 @@ describe("maskOperation", () => { expect(data).toEqual({ user: { __typename: "User", id: 1 } }); }); + test("deep freezes the masked result if the original data is frozen", () => { + const query = gql` + query { + user { + __typename + id + ...UserFields + } + } + + fragment UserFields on User { + name + } + `; + + const frozenData = maskOperation( + deepFreeze({ user: { __typename: "User", id: 1, name: "Test User" } }), + query, + createFragmentMatcher(new InMemoryCache()) + ); + + const nonFrozenData = maskOperation( + { user: { __typename: "User", id: 1, name: "Test User" } }, + query, + createFragmentMatcher(new InMemoryCache()) + ); + + expect(Object.isFrozen(frozenData)).toBe(true); + expect(Object.isFrozen(nonFrozenData)).toBe(false); + }); + test("strips fragment data from arrays", () => { const query = gql` query { diff --git a/src/core/masking.ts b/src/core/masking.ts index ecb8696299b..3aa87e56760 100644 --- a/src/core/masking.ts +++ b/src/core/masking.ts @@ -10,6 +10,7 @@ import { getFragmentDefinitions, getFragmentMaskMode, getOperationDefinition, + maybeDeepFreeze, } from "../utilities/index.js"; import type { FragmentMap } from "../utilities/index.js"; import type { DocumentNode, TypedDocumentNode } from "./index.js"; @@ -25,6 +26,7 @@ interface MaskingContext { operationName: string | undefined; fragmentMap: FragmentMap; matchesFragment: MatchesFragmentFn; + disableWarnings?: boolean; } type PathSelection = Array; @@ -41,12 +43,24 @@ export function maskOperation( "Expected a parsed GraphQL document with a query, mutation, or subscription." ); - const [masked, changed] = maskSelectionSet(data, definition.selectionSet, { + const context: MaskingContext = { operationType: definition.operation, operationName: definition.name?.value, fragmentMap: createFragmentMap(getFragmentDefinitions(document)), matchesFragment, - }); + }; + + const [masked, changed] = maskSelectionSet( + data, + definition.selectionSet, + context + ); + + if (Object.isFrozen(data)) { + context.disableWarnings = true; + maybeDeepFreeze(masked); + context.disableWarnings = false; + } return changed ? masked : data; } @@ -312,6 +326,10 @@ function addAccessorWarning( Object.defineProperty(data, fieldName, { get() { + if (context.disableWarnings) { + return currentValue; + } + if (!warned) { invariant.warn( "Accessing unmasked field on %s at path '%s'. This field will not be available when masking is enabled. Please read the field from the fragment instead.", From 2d983176e93b92e0e14ef6b6918adef0650208fe Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Thu, 18 Jul 2024 17:24:25 -0600 Subject: [PATCH 088/103] Regenerate API report and update size limits --- .api-reports/api-report-cache.api.md | 2 +- .api-reports/api-report-core.api.md | 2 +- .api-reports/api-report-react.api.md | 2 +- .api-reports/api-report-react_components.api.md | 2 +- .api-reports/api-report-react_context.api.md | 2 +- .api-reports/api-report-react_hoc.api.md | 2 +- .api-reports/api-report-react_hooks.api.md | 2 +- .api-reports/api-report-react_internal.api.md | 2 +- .api-reports/api-report-react_ssr.api.md | 2 +- .api-reports/api-report-testing.api.md | 2 +- .api-reports/api-report-testing_core.api.md | 2 +- .api-reports/api-report-utilities.api.md | 2 +- .api-reports/api-report.api.md | 2 +- .size-limits.json | 4 ++-- 14 files changed, 15 insertions(+), 15 deletions(-) diff --git a/.api-reports/api-report-cache.api.md b/.api-reports/api-report-cache.api.md index 2086ab303c5..6980d593e17 100644 --- a/.api-reports/api-report-cache.api.md +++ b/.api-reports/api-report-cache.api.md @@ -40,7 +40,7 @@ export abstract class ApolloCache implements DataProxy { // (undocumented) identify(object: StoreObject | Reference): string | undefined; // (undocumented) - maskDocument(document: DocumentNode, data: TData): TData; + maskOperation(document: DocumentNode, data: TData): TData; // (undocumented) modify = Record>(options: Cache_2.ModifyOptions): boolean; // (undocumented) diff --git a/.api-reports/api-report-core.api.md b/.api-reports/api-report-core.api.md index 2806a43bb5c..d5d20e9819b 100644 --- a/.api-reports/api-report-core.api.md +++ b/.api-reports/api-report-core.api.md @@ -55,7 +55,7 @@ export abstract class ApolloCache implements DataProxy { // (undocumented) identify(object: StoreObject | Reference): string | undefined; // (undocumented) - maskDocument(document: DocumentNode, data: TData): TData; + maskOperation(document: DocumentNode, data: TData): TData; // (undocumented) modify = Record>(options: Cache_2.ModifyOptions): boolean; // (undocumented) diff --git a/.api-reports/api-report-react.api.md b/.api-reports/api-report-react.api.md index 98dd2be7802..07c850f348a 100644 --- a/.api-reports/api-report-react.api.md +++ b/.api-reports/api-report-react.api.md @@ -53,7 +53,7 @@ abstract class ApolloCache implements DataProxy { // (undocumented) identify(object: StoreObject | Reference): string | undefined; // (undocumented) - maskDocument(document: DocumentNode, data: TData): TData; + maskOperation(document: DocumentNode, data: TData): TData; // (undocumented) modify = Record>(options: Cache_2.ModifyOptions): boolean; // Warning: (ae-forgotten-export) The symbol "Transaction" needs to be exported by the entry point index.d.ts diff --git a/.api-reports/api-report-react_components.api.md b/.api-reports/api-report-react_components.api.md index 409827e585f..33335eaa514 100644 --- a/.api-reports/api-report-react_components.api.md +++ b/.api-reports/api-report-react_components.api.md @@ -53,7 +53,7 @@ abstract class ApolloCache implements DataProxy { // (undocumented) identify(object: StoreObject | Reference): string | undefined; // (undocumented) - maskDocument(document: DocumentNode, data: TData): TData; + maskOperation(document: DocumentNode, data: TData): TData; // (undocumented) modify = Record>(options: Cache_2.ModifyOptions): boolean; // Warning: (ae-forgotten-export) The symbol "Transaction" needs to be exported by the entry point index.d.ts diff --git a/.api-reports/api-report-react_context.api.md b/.api-reports/api-report-react_context.api.md index 37830f0d577..93c0991df98 100644 --- a/.api-reports/api-report-react_context.api.md +++ b/.api-reports/api-report-react_context.api.md @@ -52,7 +52,7 @@ abstract class ApolloCache implements DataProxy { // (undocumented) identify(object: StoreObject | Reference): string | undefined; // (undocumented) - maskDocument(document: DocumentNode, data: TData): TData; + maskOperation(document: DocumentNode, data: TData): TData; // (undocumented) modify = Record>(options: Cache_2.ModifyOptions): boolean; // Warning: (ae-forgotten-export) The symbol "Transaction" needs to be exported by the entry point index.d.ts diff --git a/.api-reports/api-report-react_hoc.api.md b/.api-reports/api-report-react_hoc.api.md index befffbf4ad4..da7e3958327 100644 --- a/.api-reports/api-report-react_hoc.api.md +++ b/.api-reports/api-report-react_hoc.api.md @@ -52,7 +52,7 @@ abstract class ApolloCache implements DataProxy { // (undocumented) identify(object: StoreObject | Reference): string | undefined; // (undocumented) - maskDocument(document: DocumentNode, data: TData): TData; + maskOperation(document: DocumentNode, data: TData): TData; // (undocumented) modify = Record>(options: Cache_2.ModifyOptions): boolean; // Warning: (ae-forgotten-export) The symbol "Transaction" needs to be exported by the entry point index.d.ts diff --git a/.api-reports/api-report-react_hooks.api.md b/.api-reports/api-report-react_hooks.api.md index b4d6e73326c..0e6b9a7c957 100644 --- a/.api-reports/api-report-react_hooks.api.md +++ b/.api-reports/api-report-react_hooks.api.md @@ -51,7 +51,7 @@ abstract class ApolloCache implements DataProxy { // (undocumented) identify(object: StoreObject | Reference): string | undefined; // (undocumented) - maskDocument(document: DocumentNode, data: TData): TData; + maskOperation(document: DocumentNode, data: TData): TData; // (undocumented) modify = Record>(options: Cache_2.ModifyOptions): boolean; // Warning: (ae-forgotten-export) The symbol "Transaction" needs to be exported by the entry point index.d.ts diff --git a/.api-reports/api-report-react_internal.api.md b/.api-reports/api-report-react_internal.api.md index d0e0b5990a2..2d1bdca578c 100644 --- a/.api-reports/api-report-react_internal.api.md +++ b/.api-reports/api-report-react_internal.api.md @@ -51,7 +51,7 @@ abstract class ApolloCache implements DataProxy { // (undocumented) identify(object: StoreObject | Reference): string | undefined; // (undocumented) - maskDocument(document: DocumentNode, data: TData): TData; + maskOperation(document: DocumentNode, data: TData): TData; // (undocumented) modify = Record>(options: Cache_2.ModifyOptions): boolean; // Warning: (ae-forgotten-export) The symbol "Transaction" needs to be exported by the entry point index.d.ts diff --git a/.api-reports/api-report-react_ssr.api.md b/.api-reports/api-report-react_ssr.api.md index 692b2787008..217c8127811 100644 --- a/.api-reports/api-report-react_ssr.api.md +++ b/.api-reports/api-report-react_ssr.api.md @@ -52,7 +52,7 @@ abstract class ApolloCache implements DataProxy { // (undocumented) identify(object: StoreObject | Reference): string | undefined; // (undocumented) - maskDocument(document: DocumentNode, data: TData): TData; + maskOperation(document: DocumentNode, data: TData): TData; // (undocumented) modify = Record>(options: Cache_2.ModifyOptions): boolean; // Warning: (ae-forgotten-export) The symbol "Transaction" needs to be exported by the entry point index.d.ts diff --git a/.api-reports/api-report-testing.api.md b/.api-reports/api-report-testing.api.md index 3680400251a..aa6b14c5aff 100644 --- a/.api-reports/api-report-testing.api.md +++ b/.api-reports/api-report-testing.api.md @@ -52,7 +52,7 @@ abstract class ApolloCache implements DataProxy { // (undocumented) identify(object: StoreObject | Reference): string | undefined; // (undocumented) - maskDocument(document: DocumentNode, data: TData): TData; + maskOperation(document: DocumentNode, data: TData): TData; // (undocumented) modify = Record>(options: Cache_2.ModifyOptions): boolean; // Warning: (ae-forgotten-export) The symbol "Transaction" needs to be exported by the entry point index.d.ts diff --git a/.api-reports/api-report-testing_core.api.md b/.api-reports/api-report-testing_core.api.md index 85c5dd56811..71f63a1ea17 100644 --- a/.api-reports/api-report-testing_core.api.md +++ b/.api-reports/api-report-testing_core.api.md @@ -51,7 +51,7 @@ abstract class ApolloCache implements DataProxy { // (undocumented) identify(object: StoreObject | Reference): string | undefined; // (undocumented) - maskDocument(document: DocumentNode, data: TData): TData; + maskOperation(document: DocumentNode, data: TData): TData; // (undocumented) modify = Record>(options: Cache_2.ModifyOptions): boolean; // Warning: (ae-forgotten-export) The symbol "Transaction" needs to be exported by the entry point index.d.ts diff --git a/.api-reports/api-report-utilities.api.md b/.api-reports/api-report-utilities.api.md index e847785ef51..baa347b5efd 100644 --- a/.api-reports/api-report-utilities.api.md +++ b/.api-reports/api-report-utilities.api.md @@ -66,7 +66,7 @@ abstract class ApolloCache implements DataProxy { // (undocumented) identify(object: StoreObject | Reference): string | undefined; // (undocumented) - maskDocument(document: DocumentNode, data: TData): TData; + maskOperation(document: DocumentNode, data: TData): TData; // (undocumented) modify = Record>(options: Cache_2.ModifyOptions): boolean; // Warning: (ae-forgotten-export) The symbol "Transaction" needs to be exported by the entry point index.d.ts diff --git a/.api-reports/api-report.api.md b/.api-reports/api-report.api.md index be7ad76570a..427a9923c21 100644 --- a/.api-reports/api-report.api.md +++ b/.api-reports/api-report.api.md @@ -57,7 +57,7 @@ export abstract class ApolloCache implements DataProxy { // (undocumented) identify(object: StoreObject | Reference): string | undefined; // (undocumented) - maskDocument(document: DocumentNode, data: TData): TData; + maskOperation(document: DocumentNode, data: TData): TData; // (undocumented) modify = Record>(options: Cache_2.ModifyOptions): boolean; // (undocumented) diff --git a/.size-limits.json b/.size-limits.json index ce2e50b055d..2a02f133e54 100644 --- a/.size-limits.json +++ b/.size-limits.json @@ -1,4 +1,4 @@ { - "dist/apollo-client.min.cjs": 40324, - "import { ApolloClient, InMemoryCache, HttpLink } from \"dist/index.js\" (production)": 33605 + "dist/apollo-client.min.cjs": 40410, + "import { ApolloClient, InMemoryCache, HttpLink } from \"dist/index.js\" (production)": 33667 } From cf4b04856552643b5ae138d56678fdf3eb4af248 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Thu, 18 Jul 2024 17:25:08 -0600 Subject: [PATCH 089/103] Mark getFragmentMaskMode as internal --- src/utilities/graphql/directives.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/utilities/graphql/directives.ts b/src/utilities/graphql/directives.ts index 6ff47c20478..40a55ade632 100644 --- a/src/utilities/graphql/directives.ts +++ b/src/utilities/graphql/directives.ts @@ -135,6 +135,7 @@ export function getInclusionDirectives( return result; } +/** @internal */ export function getFragmentMaskMode( fragment: FragmentSpreadNode ): "mask" | "migrate" | "unmask" { From a30bd6c3ee001747771167e5ebaee9660a37053a Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Thu, 18 Jul 2024 17:26:26 -0600 Subject: [PATCH 090/103] Rerun api report --- .api-reports/api-report-utilities.api.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.api-reports/api-report-utilities.api.md b/.api-reports/api-report-utilities.api.md index baa347b5efd..fd4219b41b9 100644 --- a/.api-reports/api-report-utilities.api.md +++ b/.api-reports/api-report-utilities.api.md @@ -1151,7 +1151,7 @@ export function getFragmentDefinitions(doc: DocumentNode): FragmentDefinitionNod // @public (undocumented) export function getFragmentFromSelection(selection: SelectionNode, fragmentMap?: FragmentMap | FragmentMapFunction): InlineFragmentNode | FragmentDefinitionNode | null; -// @public (undocumented) +// @internal (undocumented) export function getFragmentMaskMode(fragment: FragmentSpreadNode): "mask" | "migrate" | "unmask"; // @public From f5352ffc0de1ccd5376006a17a2f656a172453c4 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Thu, 18 Jul 2024 17:31:05 -0600 Subject: [PATCH 091/103] Ensure maskFragment honors frozen objects --- src/core/__tests__/masking.test.ts | 42 ++++++++++++++++++++++++++++++ src/core/masking.ts | 16 ++++++++++-- 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/src/core/__tests__/masking.test.ts b/src/core/__tests__/masking.test.ts index d9ea27e96c2..1186da58143 100644 --- a/src/core/__tests__/masking.test.ts +++ b/src/core/__tests__/masking.test.ts @@ -1782,6 +1782,48 @@ describe("maskFragment", () => { }); }); + test("deep freezes the masked result if the original data is frozen", () => { + const fragment = gql` + fragment UserFields on User { + __typename + id + profile { + __typename + ...UserProfile + } + } + + fragment UserProfile on User { + age + } + `; + + const frozenData = maskFragment( + deepFreeze({ + __typename: "User", + id: 1, + profile: { __typename: "Profile", age: 30 }, + }), + fragment, + createFragmentMatcher(new InMemoryCache()), + "UserFields" + ); + + const nonFrozenData = maskFragment( + { + __typename: "User", + id: 1, + profile: { __typename: "Profile", age: 30 }, + }, + fragment, + createFragmentMatcher(new InMemoryCache()), + "UserFields" + ); + + expect(Object.isFrozen(frozenData)).toBe(true); + expect(Object.isFrozen(nonFrozenData)).toBe(false); + }); + test("does not mask inline fragment in fragment documents", () => { const fragment = gql` fragment UserFields on User { diff --git a/src/core/masking.ts b/src/core/masking.ts index 3aa87e56760..4830f92948f 100644 --- a/src/core/masking.ts +++ b/src/core/masking.ts @@ -95,12 +95,24 @@ export function maskFragment( fragmentName ); - const [masked, changed] = maskSelectionSet(data, fragment.selectionSet, { + const context: MaskingContext = { operationType: "fragment", operationName: fragment.name.value, fragmentMap: createFragmentMap(getFragmentDefinitions(document)), matchesFragment, - }); + }; + + const [masked, changed] = maskSelectionSet( + data, + fragment.selectionSet, + context + ); + + if (Object.isFrozen(data)) { + context.disableWarnings = true; + maybeDeepFreeze(masked); + context.disableWarnings = false; + } return changed ? masked : data; } From 39fb40946dbe298d656a92fdb7d93a7e5f82af97 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 24 Jul 2024 09:20:48 -0600 Subject: [PATCH 092/103] Minor perf boost by reassigning getter function instead of value variable --- src/core/masking.ts | 48 ++++++++++++++++++--------------------------- 1 file changed, 19 insertions(+), 29 deletions(-) diff --git a/src/core/masking.ts b/src/core/masking.ts index 4830f92948f..b78ee07212a 100644 --- a/src/core/masking.ts +++ b/src/core/masking.ts @@ -146,18 +146,8 @@ function maskSelectionSet( switch (selection.kind) { case Kind.FIELD: { const keyName = resultKeyNameFromField(selection); - const descriptor = Object.getOwnPropertyDescriptor(memo, keyName); const childSelectionSet = selection.selectionSet; - // If we've set a descriptor on the object by adding warnings to field - // access, overwrite the descriptor because we're adding a field that - // is accessible when masked. This avoids the need for us to maintain - // which fields are masked/unmasked and avoids dependence on field - // order. - if (descriptor) { - delete memo[keyName]; - } - memo[keyName] = data[keyName]; if (childSelectionSet) { @@ -333,30 +323,30 @@ function addAccessorWarning( path: PathSelection, context: MaskingContext ) { - let currentValue = value; - let warned = false; + let getValue = () => { + if (context.disableWarnings) { + return value; + } - Object.defineProperty(data, fieldName, { - get() { - if (context.disableWarnings) { - return currentValue; - } + invariant.warn( + "Accessing unmasked field on %s at path '%s'. This field will not be available when masking is enabled. Please read the field from the fragment instead.", + context.operationName ? + `${context.operationType} '${context.operationName}'` + : `anonymous ${context.operationType}`, + getPathString([...path, fieldName]) + ); - if (!warned) { - invariant.warn( - "Accessing unmasked field on %s at path '%s'. This field will not be available when masking is enabled. Please read the field from the fragment instead.", - context.operationName ? - `${context.operationType} '${context.operationName}'` - : `anonymous ${context.operationType}`, - getPathString([...path, fieldName]) - ); - warned = true; - } + getValue = () => value; + + return value; + }; - return currentValue; + Object.defineProperty(data, fieldName, { + get() { + return getValue(); }, set(value) { - currentValue = value; + getValue = () => value; }, enumerable: true, configurable: true, From 435bfea64f56ee1f5589bf7cedb4542107ba17bd Mon Sep 17 00:00:00 2001 From: jerelmiller Date: Wed, 24 Jul 2024 15:23:48 +0000 Subject: [PATCH 093/103] Clean up Prettier, Size-limit, and Api-Extractor --- .size-limits.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.size-limits.json b/.size-limits.json index 2a02f133e54..f9ceb922c08 100644 --- a/.size-limits.json +++ b/.size-limits.json @@ -1,4 +1,4 @@ { - "dist/apollo-client.min.cjs": 40410, - "import { ApolloClient, InMemoryCache, HttpLink } from \"dist/index.js\" (production)": 33667 + "dist/apollo-client.min.cjs": 40389, + "import { ApolloClient, InMemoryCache, HttpLink } from \"dist/index.js\" (production)": 33638 } From 042aaf2c45b17f04c894826a7e7c4b95253f18fe Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 24 Jul 2024 09:29:42 -0600 Subject: [PATCH 094/103] Use string concatenation to track path --- src/core/masking.ts | 28 ++++++++-------------------- 1 file changed, 8 insertions(+), 20 deletions(-) diff --git a/src/core/masking.ts b/src/core/masking.ts index b78ee07212a..e38ef40d22f 100644 --- a/src/core/masking.ts +++ b/src/core/masking.ts @@ -29,8 +29,6 @@ interface MaskingContext { disableWarnings?: boolean; } -type PathSelection = Array; - export function maskOperation( data: TData, document: TypedDocumentNode | DocumentNode, @@ -121,7 +119,7 @@ function maskSelectionSet( data: any, selectionSet: SelectionSetNode, context: MaskingContext, - path: PathSelection = [] + path: string = "" ): [data: any, changed: boolean] { if (Array.isArray(data)) { let changed = false; @@ -131,7 +129,7 @@ function maskSelectionSet( item, selectionSet, context, - [...path, index] + `${path}[${index}]` ); changed ||= itemChanged; @@ -155,7 +153,7 @@ function maskSelectionSet( data[keyName], childSelectionSet, context, - [...path, keyName] + `${path}.${keyName}` ); if (childChanged) { @@ -229,7 +227,7 @@ function addFieldAccessorWarnings( memo: Record, data: Record, selectionSetNode: SelectionSetNode, - path: PathSelection, + path: string, context: MaskingContext ) { if (Array.isArray(data)) { @@ -238,7 +236,7 @@ function addFieldAccessorWarnings( memo[index] ?? Object.create(null), item, selectionSetNode, - [...path, index], + `${path}[${index}]`, context ); }); @@ -261,7 +259,7 @@ function addFieldAccessorWarnings( memo[keyName] ?? Object.create(null), data[keyName] as Record, childSelectionSet, - [...path, keyName], + `${path}.${keyName}`, context ); } @@ -320,7 +318,7 @@ function addAccessorWarning( data: Record, value: any, fieldName: string, - path: PathSelection, + path: string, context: MaskingContext ) { let getValue = () => { @@ -333,7 +331,7 @@ function addAccessorWarning( context.operationName ? `${context.operationType} '${context.operationName}'` : `anonymous ${context.operationType}`, - getPathString([...path, fieldName]) + `${path}.${fieldName}`.replace(/^\./, "") ); getValue = () => value; @@ -352,13 +350,3 @@ function addAccessorWarning( configurable: true, }); } - -function getPathString(path: PathSelection) { - return path.reduce((memo, segment, index) => { - if (typeof segment === "number") { - return `${memo}[${segment}]`; - } - - return index === 0 ? segment : `${memo}.${segment}`; - }, ""); -} From 43d3389435b6b529f9551e8751c000066d178ca5 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 24 Jul 2024 09:31:31 -0600 Subject: [PATCH 095/103] Run unmask path in prod mode --- src/core/masking.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/masking.ts b/src/core/masking.ts index e38ef40d22f..a0e3d6c8188 100644 --- a/src/core/masking.ts +++ b/src/core/masking.ts @@ -195,7 +195,7 @@ function maskSelectionSet( return [memo, true]; } - if (mode === "unmask") { + if (mode === "unmask" || !__DEV__) { const [fragmentData, changed] = maskSelectionSet( data, fragment.selectionSet, From 7e5ec6017a0b04699673f0ed7738dc71fa496511 Mon Sep 17 00:00:00 2001 From: jerelmiller Date: Wed, 24 Jul 2024 15:34:13 +0000 Subject: [PATCH 096/103] Clean up Prettier, Size-limit, and Api-Extractor --- .size-limits.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.size-limits.json b/.size-limits.json index f9ceb922c08..958cb2279b3 100644 --- a/.size-limits.json +++ b/.size-limits.json @@ -1,4 +1,4 @@ { - "dist/apollo-client.min.cjs": 40389, - "import { ApolloClient, InMemoryCache, HttpLink } from \"dist/index.js\" (production)": 33638 + "dist/apollo-client.min.cjs": 40175, + "import { ApolloClient, InMemoryCache, HttpLink } from \"dist/index.js\" (production)": 33621 } From 7a678e9fa609f0227910862d3f00db876fe8a887 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 24 Jul 2024 09:34:58 -0600 Subject: [PATCH 097/103] Run compare build output on all branches --- .github/workflows/compare-build-output.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/workflows/compare-build-output.yml b/.github/workflows/compare-build-output.yml index 7b97c556665..b367e4672e6 100644 --- a/.github/workflows/compare-build-output.yml +++ b/.github/workflows/compare-build-output.yml @@ -1,9 +1,6 @@ name: Compare Build Output on: pull_request: - branches: - - main - - release-* concurrency: ${{ github.workflow }}-${{ github.ref }} From 58bf7d411afc01769772e519cc82ab3abeb6ac22 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 24 Jul 2024 09:40:14 -0600 Subject: [PATCH 098/103] Reorder call for migrate path for friendlier prod optimization --- src/core/masking.ts | 40 +++++++++++++++++++++------------------- 1 file changed, 21 insertions(+), 19 deletions(-) diff --git a/src/core/masking.ts b/src/core/masking.ts index a0e3d6c8188..f6b01c9fd3f 100644 --- a/src/core/masking.ts +++ b/src/core/masking.ts @@ -195,27 +195,29 @@ function maskSelectionSet( return [memo, true]; } - if (mode === "unmask" || !__DEV__) { - const [fragmentData, changed] = maskSelectionSet( - data, - fragment.selectionSet, - context, - path - ); - - return [{ ...memo, ...fragmentData }, changed]; + if (__DEV__) { + if (mode === "migrate") { + return [ + addFieldAccessorWarnings( + memo, + data, + fragment.selectionSet, + path, + context + ), + true, + ]; + } } - return [ - addFieldAccessorWarnings( - memo, - data, - fragment.selectionSet, - path, - context - ), - true, - ]; + const [fragmentData, changed] = maskSelectionSet( + data, + fragment.selectionSet, + context, + path + ); + + return [{ ...memo, ...fragmentData }, changed]; } } }, From a5832d8885841f05695ea31bf908270a305928c3 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 24 Jul 2024 09:42:10 -0600 Subject: [PATCH 099/103] Use || instead of ?? --- src/core/masking.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/core/masking.ts b/src/core/masking.ts index f6b01c9fd3f..e816e7c0efe 100644 --- a/src/core/masking.ts +++ b/src/core/masking.ts @@ -235,7 +235,7 @@ function addFieldAccessorWarnings( if (Array.isArray(data)) { return data.map((item, index): unknown => { return addFieldAccessorWarnings( - memo[index] ?? Object.create(null), + memo[index] || Object.create(null), item, selectionSetNode, `${path}[${index}]`, @@ -258,7 +258,7 @@ function addFieldAccessorWarnings( if (childSelectionSet) { value = addFieldAccessorWarnings( - memo[keyName] ?? Object.create(null), + memo[keyName] || Object.create(null), data[keyName] as Record, childSelectionSet, `${path}.${keyName}`, From cccca9ce58e4bd5c21885039c0e9bb72ce199d05 Mon Sep 17 00:00:00 2001 From: jerelmiller Date: Wed, 24 Jul 2024 15:42:50 +0000 Subject: [PATCH 100/103] Clean up Prettier, Size-limit, and Api-Extractor --- .size-limits.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.size-limits.json b/.size-limits.json index 958cb2279b3..858ecbc3a30 100644 --- a/.size-limits.json +++ b/.size-limits.json @@ -1,4 +1,4 @@ { "dist/apollo-client.min.cjs": 40175, - "import { ApolloClient, InMemoryCache, HttpLink } from \"dist/index.js\" (production)": 33621 + "import { ApolloClient, InMemoryCache, HttpLink } from \"dist/index.js\" (production)": 33453 } From 3c4df15d112a9b87733845cc258dab9ebc6c985f Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 24 Jul 2024 09:49:17 -0600 Subject: [PATCH 101/103] Allow undefined for path --- src/core/masking.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/core/masking.ts b/src/core/masking.ts index e816e7c0efe..dd6f00e631a 100644 --- a/src/core/masking.ts +++ b/src/core/masking.ts @@ -119,7 +119,7 @@ function maskSelectionSet( data: any, selectionSet: SelectionSetNode, context: MaskingContext, - path: string = "" + path?: string | undefined ): [data: any, changed: boolean] { if (Array.isArray(data)) { let changed = false; @@ -129,7 +129,7 @@ function maskSelectionSet( item, selectionSet, context, - `${path}[${index}]` + `${path || ""}[${index}]` ); changed ||= itemChanged; @@ -153,7 +153,7 @@ function maskSelectionSet( data[keyName], childSelectionSet, context, - `${path}.${keyName}` + `${path || ""}.${keyName}` ); if (childChanged) { @@ -202,7 +202,7 @@ function maskSelectionSet( memo, data, fragment.selectionSet, - path, + path || "", context ), true, From 68504eb83f4afe2e92de04c9cd31a51e0fb460fd Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Wed, 24 Jul 2024 09:50:21 -0600 Subject: [PATCH 102/103] Only concat path in dev mode --- src/core/masking.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/core/masking.ts b/src/core/masking.ts index dd6f00e631a..8303215edd6 100644 --- a/src/core/masking.ts +++ b/src/core/masking.ts @@ -129,7 +129,7 @@ function maskSelectionSet( item, selectionSet, context, - `${path || ""}[${index}]` + __DEV__ ? `${path || ""}[${index}]` : void 0 ); changed ||= itemChanged; @@ -153,7 +153,7 @@ function maskSelectionSet( data[keyName], childSelectionSet, context, - `${path || ""}.${keyName}` + __DEV__ ? `${path || ""}.${keyName}` : void 0 ); if (childChanged) { From a924c0f8e8955c792c08b8906c35493bde85b590 Mon Sep 17 00:00:00 2001 From: jerelmiller Date: Wed, 24 Jul 2024 15:52:42 +0000 Subject: [PATCH 103/103] Clean up Prettier, Size-limit, and Api-Extractor --- .size-limits.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.size-limits.json b/.size-limits.json index 858ecbc3a30..310f0c33d3c 100644 --- a/.size-limits.json +++ b/.size-limits.json @@ -1,4 +1,4 @@ { - "dist/apollo-client.min.cjs": 40175, - "import { ApolloClient, InMemoryCache, HttpLink } from \"dist/index.js\" (production)": 33453 + "dist/apollo-client.min.cjs": 40154, + "import { ApolloClient, InMemoryCache, HttpLink } from \"dist/index.js\" (production)": 33429 }