Skip to content

Commit b1fd88f

Browse files
committed
test(common): add coerceNullPrototypeObjects helper
This primarily intended to work around the problem of graphql-js using null prototype objects to represent maps. See: graphql/graphql-js#484 and related (This code is a bit ridiculous in implementation, but it lets callers deal with a simpler interface.)
1 parent 985bb94 commit b1fd88f

File tree

2 files changed

+127
-1
lines changed

2 files changed

+127
-1
lines changed

src/common/test.helpers.spec.ts

Lines changed: 87 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,92 @@
11
import { gql } from 'apollo-server-core';
2+
import * as fc from 'fast-check';
23
import * as graphql from 'graphql';
3-
import { execGraphQL } from './test.helpers';
4+
import {
5+
coerceNullPrototypeObjects,
6+
execGraphQL,
7+
hasNullPrototype,
8+
hasObjectPrototype,
9+
} from './test.helpers';
10+
11+
/** For `fc.anything`: everything except null prototype objects. */
12+
const withoutNullPrototype: fc.ObjectConstraints = {
13+
withBigInt: true,
14+
withBoxedValues: true,
15+
withDate: true,
16+
withMap: true,
17+
withObjectString: true,
18+
withNullPrototype: false,
19+
withSet: true,
20+
withTypedArray: true,
21+
};
22+
23+
describe('hasNullPrototype and hasObjectPrototype', () => {
24+
test('Object.create(null): hasNullPrototype, not hasObjectPrototype', () => {
25+
const o = Object.create(null) as { unknown: unknown };
26+
expect(hasNullPrototype(o)).toBe(true);
27+
expect(hasObjectPrototype(o)).toBe(false);
28+
});
29+
30+
test('plain objects: hasObjectPrototype, not hasNullPrototype', () => {
31+
fc.assert(
32+
fc.property(
33+
fc.object({ ...withoutNullPrototype, withNullPrototype: true }),
34+
o => {
35+
expect(hasObjectPrototype(o)).toBe(true);
36+
expect(hasNullPrototype(o)).toBe(false);
37+
},
38+
),
39+
);
40+
});
41+
42+
test('everything else: not hasNullPrototype', () => {
43+
fc.assert(
44+
fc.property(fc.anything(withoutNullPrototype), o => {
45+
expect(hasNullPrototype(o)).toBe(false);
46+
}),
47+
);
48+
});
49+
});
50+
51+
describe('coerceNullPrototypeObjects', () => {
52+
test('everything excluding null prototype objects', () => {
53+
fc.assert(
54+
fc.property(fc.anything(withoutNullPrototype), (input: unknown) => {
55+
const result = coerceNullPrototypeObjects(input);
56+
expect(result).toStrictEqual(input);
57+
}),
58+
);
59+
});
60+
61+
test('everything including null prototype objects', () => {
62+
// Helper: True if any objects or arrays in o contain a null prototype object.
63+
const containsNullPrototype = (o: unknown) =>
64+
hasNullPrototype(o) ||
65+
(hasObjectPrototype(o) && Object.values(o).some(containsNullPrototype)) ||
66+
(o instanceof Array && o.some(containsNullPrototype));
67+
68+
// Filter fc.anything() down to values that actually contain a null prototype object somewhere.
69+
const anythingContainingNullPrototypes = fc
70+
.anything({
71+
...withoutNullPrototype,
72+
withNullPrototype: true,
73+
})
74+
.filter(containsNullPrototype);
75+
76+
fc.assert(
77+
fc.property(anythingContainingNullPrototypes, input => {
78+
const output = coerceNullPrototypeObjects(input);
79+
// The input contains null prototypes, but the output should not.
80+
expect(containsNullPrototype(input)).toBe(true);
81+
expect(containsNullPrototype(output)).toBe(false);
82+
// The difference between input and output should fail toStrictEqual,
83+
// but still pass toEqual.
84+
expect(output).not.toStrictEqual(input);
85+
expect(output).toEqual(input);
86+
}),
87+
);
88+
});
89+
});
490

591
describe('execGraphQL', () => {
692
let schema: graphql.GraphQLSchema;

src/common/test.helpers.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,46 @@ import {
1212
printError,
1313
} from 'graphql';
1414

15+
/** True for objects with the given prototype. */
16+
const hasPrototype = (o, prototype): o is InstanceType<typeof prototype> =>
17+
typeof o === 'object' && o !== null && Object.getPrototypeOf(o) === prototype;
18+
19+
/**
20+
* True for null prototype objects, as returned by `Object.create(null)`).
21+
* @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/create#custom_and_null_objects
22+
*/
23+
export const hasNullPrototype = (
24+
o: unknown,
25+
): o is Record<keyof never, unknown> => hasPrototype(o, null);
26+
27+
/**
28+
* True for "plain" objects, but not null prototype objects.
29+
* @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/create#custom_and_null_objects
30+
*/
31+
export const hasObjectPrototype = (
32+
o: unknown,
33+
): o is Record<keyof never, unknown> => hasPrototype(o, Object.prototype);
34+
35+
/**
36+
* Return a mixed object tree with null prototype objects coerced to plain objects.
37+
*
38+
* This primarily intended to work around the problem of graphql-js using
39+
* null prototype objects to represent maps.
40+
*
41+
* @see https://github.com/graphql/graphql-js/issues/484
42+
*/
43+
export function coerceNullPrototypeObjects(o: unknown): unknown {
44+
if (hasNullPrototype(o) || hasObjectPrototype(o)) {
45+
return Object.fromEntries(
46+
Object.entries(o).map(([k, v]) => [k, coerceNullPrototypeObjects(v)]),
47+
);
48+
} else if (Array.isArray(o)) {
49+
return Array.from(o, v => coerceNullPrototypeObjects(v));
50+
} else {
51+
return o;
52+
}
53+
}
54+
1555
/**
1656
* Like {@link graphql }, but accept a {@link DocumentNode DocumentNode} as query,
1757
* and throw on error.

0 commit comments

Comments
 (0)