Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Data masking] Ensure masking algorithm properly handles nulls #12034

Merged
merged 7 commits into from
Aug 28, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .size-limits.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{
"dist/apollo-client.min.cjs": 41255,
"import { ApolloClient, InMemoryCache, HttpLink } from \"dist/index.js\" (production)": 33943
"dist/apollo-client.min.cjs": 41282,
"import { ApolloClient, InMemoryCache, HttpLink } from \"dist/index.js\" (production)": 33969
}
213 changes: 213 additions & 0 deletions src/core/__tests__/masking.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,40 @@ describe("maskOperation", () => {
);
});

test("returns null when data is null", () => {
const query = gql`
query {
foo
...QueryFields
}

fragment QueryFields on Query {
bar
}
`;

const data = maskOperation(null, query, new InMemoryCache());

expect(data).toBe(null);
});

test("returns undefined when data is undefined", () => {
const query = gql`
query {
foo
...QueryFields
}

fragment QueryFields on Query {
bar
}
`;

const data = maskOperation(undefined, query, new InMemoryCache());

expect(data).toBe(undefined);
});

test("strips top-level fragment data from query", () => {
const query = gql`
query {
Expand Down Expand Up @@ -177,6 +211,83 @@ describe("maskOperation", () => {
});
});

test("handles nulls in child selection sets", () => {
const query = gql`
query {
user {
profile {
id
}
...UserFields
}
}
fragment UserFields on User {
profile {
id
fullName
}
}
`;

const nullUser = maskOperation(
deepFreeze({ user: null }),
query,
new InMemoryCache()
);
const nullProfile = maskOperation(
deepFreeze({ user: { __typename: "User", profile: null } }),
query,
new InMemoryCache()
);

expect(nullUser).toEqual({ user: null });
expect(nullProfile).toEqual({
user: { __typename: "User", profile: null },
});
});

test("handles nulls in arrays", () => {
const query = gql`
query {
users {
profile {
id
}
...UserFields
}
}
fragment UserFields on User {
profile {
id
fullName
}
}
`;

const data = maskOperation(
deepFreeze({
users: [
null,
{ __typename: "User", profile: null },
{
__typename: "User",
profile: { __typename: "Profile", id: "1", fullName: "Test User" },
},
],
}),
query,
new InMemoryCache()
);

expect(data).toEqual({
users: [
null,
{ __typename: "User", profile: null },
{ __typename: "User", profile: { __typename: "Profile", id: "1" } },
],
});
});

test("deep freezes the masked result if the original data is frozen", () => {
const query = gql`
query {
Expand Down Expand Up @@ -1710,6 +1821,39 @@ describe("maskOperation", () => {
});

describe("maskFragment", () => {
test("returns null when data is null", () => {
const fragment = gql`
fragment Foo on Query {
foo
...QueryFields
}

fragment QueryFields on Query {
bar
}
`;

const data = maskFragment(null, fragment, new InMemoryCache(), "Foo");

expect(data).toBe(null);
});

test("returns undefined when data is undefined", () => {
const fragment = gql`
fragment Foo on Query {
foo
...QueryFields
}

fragment QueryFields on Query {
bar
}
`;

const data = maskFragment(undefined, fragment, new InMemoryCache(), "Foo");

expect(data).toBe(undefined);
});
test("masks named fragments in fragment documents", () => {
const fragment = gql`
fragment UserFields on User {
Expand Down Expand Up @@ -1764,6 +1908,75 @@ describe("maskFragment", () => {
});
});

test("handles nulls in child selection sets", () => {
const fragment = gql`
fragment UserFields on User {
profile {
id
}
...ProfileFields
}
fragment ProfileFields on User {
profile {
id
fullName
}
}
`;

const data = maskFragment(
deepFreeze({ __typename: "User", profile: null }),
fragment,
new InMemoryCache(),
"UserFields"
);

expect(data).toEqual({ __typename: "User", profile: null });
});

test("handles nulls in arrays", () => {
const fragment = gql`
fragment UserFields on Query {
users {
profile {
id
}
...ProfileFields
}
}
fragment ProfileFields on User {
profile {
id
fullName
}
}
`;

const data = maskFragment(
deepFreeze({
users: [
null,
{ __typename: "User", profile: null },
{
__typename: "User",
profile: { __typename: "Profile", id: "1", fullName: "Test User" },
},
],
}),
fragment,
new InMemoryCache(),
"UserFields"
);

expect(data).toEqual({
users: [
null,
{ __typename: "User", profile: null },
{ __typename: "User", profile: { __typename: "Profile", id: "1" } },
],
});
});

test("deep freezes the masked result if the original data is frozen", () => {
const fragment = gql`
fragment UserFields on User {
Expand Down
16 changes: 15 additions & 1 deletion src/core/masking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@ export function maskOperation<TData = unknown>(
"Expected a parsed GraphQL document with a query, mutation, or subscription."
);

if (data == null) {
// Maintain the original `null` or `undefined` value
return data;
}

const context: MaskingContext = {
operationType: definition.operation,
operationName: definition.name?.value,
Expand Down Expand Up @@ -100,6 +105,11 @@ export function maskFragment<TData = unknown>(
fragmentName
);

if (data == null) {
// Maintain the original `null` or `undefined` value
return data;
}

const context: MaskingContext = {
operationType: "fragment",
operationName: fragment.name.value,
Expand Down Expand Up @@ -132,6 +142,10 @@ function maskSelectionSet(
let changed = false;

const masked = data.map((item, index) => {
if (item === null) {
return null;
}

const [masked, itemChanged] = maskSelectionSet(
item,
selectionSet,
Expand All @@ -155,7 +169,7 @@ function maskSelectionSet(

memo[keyName] = data[keyName];

if (childSelectionSet) {
if (childSelectionSet && data[keyName] !== null) {
const [masked, childChanged] = maskSelectionSet(
data[keyName],
childSelectionSet,
Expand Down