Skip to content

Commit 8c72365

Browse files
committed
spike(core): enablelist for gql proxy queries
1 parent da1f486 commit 8c72365

File tree

7 files changed

+651
-35
lines changed

7 files changed

+651
-35
lines changed
Lines changed: 282 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,282 @@
1+
import { print } from 'graphql';
2+
import { graphql as gql, HttpResponse } from 'msw';
3+
import { NextRequest } from 'next/server';
4+
import { beforeEach, describe, expect, test } from 'vitest';
5+
6+
import { graphql } from '~/client/graphql';
7+
import { server } from '~/msw.server';
8+
9+
import { POST } from './route';
10+
11+
// Mock the API requests
12+
beforeEach(() => {
13+
server.use(
14+
gql.query('PaymentWalletWithInitializationDataQuery', () => {
15+
return HttpResponse.json({ data: { site: { paymentWalletWithInitializationData: null } } });
16+
}),
17+
gql.mutation('CreatePaymentWalletIntentMutation', () => {
18+
return HttpResponse.json({
19+
data: {
20+
payment: {
21+
paymentWallet: {
22+
createPaymentWalletIntent: {
23+
paymentWalletIntentData: {
24+
__typename: 'PayPalCommercePaymentWalletIntentData',
25+
orderId: '123',
26+
approvalUrl: 'https://example.com/approval',
27+
initializationEntityId: '456',
28+
},
29+
errors: [],
30+
},
31+
},
32+
},
33+
},
34+
});
35+
}),
36+
);
37+
});
38+
39+
describe('query validation', () => {
40+
test('route handler with valid query', async () => {
41+
const query = graphql(`
42+
query PaymentWalletWithInitializationDataQuery(
43+
$paymentWalletEntityId: String!
44+
$cartEntityId: String
45+
) {
46+
site {
47+
paymentWalletWithInitializationData(
48+
filter: { paymentWalletEntityId: $paymentWalletEntityId, cartEntityId: $cartEntityId }
49+
) {
50+
initializationData
51+
}
52+
}
53+
}
54+
`);
55+
56+
const request = new NextRequest('http://localhost:3000/api/wallets/graphql', {
57+
method: 'POST',
58+
body: JSON.stringify({
59+
query: print(query),
60+
variables: JSON.stringify({
61+
paymentWalletEntityId: '123',
62+
cartEntityId: '456',
63+
}),
64+
}),
65+
});
66+
67+
const response = await POST(request);
68+
const data: unknown = await response.json();
69+
70+
expect(data).toMatchObject({ site: { paymentWalletWithInitializationData: null } });
71+
});
72+
73+
test('route handler with invalid query name', async () => {
74+
const query = graphql(`
75+
query FooBar($paymentWalletEntityId: String!, $cartEntityId: String) {
76+
site {
77+
paymentWalletWithInitializationData(
78+
filter: { paymentWalletEntityId: $paymentWalletEntityId, cartEntityId: $cartEntityId }
79+
) {
80+
initializationData
81+
}
82+
}
83+
}
84+
`);
85+
86+
const request = new NextRequest('http://localhost:3000/api/wallets/graphql', {
87+
method: 'POST',
88+
body: JSON.stringify({
89+
query: print(query),
90+
variables: JSON.stringify({
91+
paymentWalletEntityId: '123',
92+
cartEntityId: '456',
93+
}),
94+
}),
95+
});
96+
97+
const response = await POST(request);
98+
const text = await response.text();
99+
100+
expect(response.status).toBe(403);
101+
expect(text).toBe('Operation not allowed');
102+
});
103+
104+
test('route handler with invalid query data', async () => {
105+
const query = graphql(`
106+
query PaymentWalletWithInitializationDataQuery {
107+
site {
108+
settings {
109+
storeName
110+
}
111+
}
112+
}
113+
`);
114+
115+
const request = new NextRequest('http://localhost:3000/api/wallets/graphql', {
116+
method: 'POST',
117+
body: JSON.stringify({
118+
query: print(query),
119+
}),
120+
});
121+
122+
const response = await POST(request);
123+
const text = await response.text();
124+
125+
expect(response.status).toBe(400);
126+
expect(text).toBe('Query is invalid');
127+
});
128+
});
129+
130+
describe('mutation validation', () => {
131+
test('route handler with valid mutation', async () => {
132+
const mutation = graphql(`
133+
mutation CreatePaymentWalletIntentMutation(
134+
$paymentWalletEntityId: String!
135+
$cartEntityId: String!
136+
) {
137+
payment {
138+
paymentWallet {
139+
createPaymentWalletIntent(
140+
input: { paymentWalletEntityId: $paymentWalletEntityId, cartEntityId: $cartEntityId }
141+
) {
142+
paymentWalletIntentData {
143+
__typename
144+
... on PayPalCommercePaymentWalletIntentData {
145+
orderId
146+
approvalUrl
147+
initializationEntityId
148+
}
149+
}
150+
errors {
151+
__typename
152+
... on CreatePaymentWalletIntentGenericError {
153+
message
154+
}
155+
... on Error {
156+
message
157+
}
158+
}
159+
}
160+
}
161+
}
162+
}
163+
`);
164+
165+
const request = new NextRequest('http://localhost:3000/api/wallets/graphql', {
166+
method: 'POST',
167+
body: JSON.stringify({
168+
query: print(mutation),
169+
variables: JSON.stringify({
170+
paymentWalletEntityId: '123',
171+
cartEntityId: '456',
172+
}),
173+
}),
174+
});
175+
176+
const response = await POST(request);
177+
const data: unknown = await response.json();
178+
179+
expect(data).toMatchObject({
180+
payment: {
181+
paymentWallet: {
182+
createPaymentWalletIntent: {
183+
errors: [],
184+
paymentWalletIntentData: {
185+
__typename: 'PayPalCommercePaymentWalletIntentData',
186+
approvalUrl: 'https://example.com/approval',
187+
initializationEntityId: '456',
188+
orderId: '123',
189+
},
190+
},
191+
},
192+
},
193+
});
194+
});
195+
196+
test('route handler with invalid mutation name', async () => {
197+
const mutation = graphql(`
198+
mutation FooBar($paymentWalletEntityId: String!, $cartEntityId: String!) {
199+
payment {
200+
paymentWallet {
201+
createPaymentWalletIntent(
202+
input: { paymentWalletEntityId: $paymentWalletEntityId, cartEntityId: $cartEntityId }
203+
) {
204+
paymentWalletIntentData {
205+
__typename
206+
... on PayPalCommercePaymentWalletIntentData {
207+
orderId
208+
approvalUrl
209+
initializationEntityId
210+
}
211+
}
212+
errors {
213+
__typename
214+
... on CreatePaymentWalletIntentGenericError {
215+
message
216+
}
217+
... on Error {
218+
message
219+
}
220+
}
221+
}
222+
}
223+
}
224+
}
225+
`);
226+
227+
const request = new NextRequest('http://localhost:3000/api/wallets/graphql', {
228+
method: 'POST',
229+
body: JSON.stringify({
230+
query: print(mutation),
231+
variables: JSON.stringify({
232+
paymentWalletEntityId: '123',
233+
cartEntityId: '456',
234+
}),
235+
}),
236+
});
237+
238+
const response = await POST(request);
239+
const text = await response.text();
240+
241+
expect(response.status).toBe(403);
242+
expect(text).toBe('Operation not allowed');
243+
});
244+
245+
test('route handler with invalid mutation data', async () => {
246+
const query = graphql(`
247+
mutation CreatePaymentWalletIntentMutation {
248+
logout {
249+
result
250+
}
251+
}
252+
`);
253+
254+
const request = new NextRequest('http://localhost:3000/api/wallets/graphql', {
255+
method: 'POST',
256+
body: JSON.stringify({
257+
query: print(query),
258+
}),
259+
});
260+
261+
const response = await POST(request);
262+
const text = await response.text();
263+
264+
expect(response.status).toBe(400);
265+
expect(text).toBe('Query is invalid');
266+
});
267+
});
268+
269+
test('route handler with invalid operation', async () => {
270+
const request = new NextRequest('http://localhost:3000/api/wallets/graphql', {
271+
method: 'POST',
272+
body: JSON.stringify({
273+
query: 'fragment Foo on Bar { baz }',
274+
}),
275+
});
276+
277+
const response = await POST(request);
278+
const text = await response.text();
279+
280+
expect(response.status).toBe(400);
281+
expect(text).toBe('No operation found');
282+
});

core/app/api/wallets/graphql/route.ts

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import { OperationDefinitionNode, parse, SelectionNode } from 'graphql';
2+
import { NextRequest } from 'next/server';
3+
import { z } from 'zod';
4+
5+
import { client } from '~/client';
6+
import { graphql } from '~/client/graphql';
7+
8+
const ENABLE_LIST = [
9+
{
10+
name: 'PaymentWalletWithInitializationDataQuery',
11+
type: 'query',
12+
allowedFields: {
13+
site: {
14+
paymentWalletWithInitializationData: true, // allow any subfields of paymentWallets
15+
},
16+
},
17+
},
18+
{
19+
name: 'CreatePaymentWalletIntentMutation',
20+
type: 'mutation',
21+
allowedFields: {
22+
payment: {
23+
paymentWallet: {
24+
createPaymentWalletIntent: {
25+
paymentWalletIntentData: true,
26+
errors: true,
27+
},
28+
},
29+
},
30+
},
31+
},
32+
];
33+
34+
export const POST = async (request: NextRequest) => {
35+
const body: unknown = await request.json();
36+
const { document, variables } = z
37+
.object({ document: z.string(), variables: z.string().optional() })
38+
.parse(body);
39+
40+
const ast = parse(document, { noLocation: true });
41+
42+
// Validate operation structure
43+
const operation = ast.definitions.find(
44+
(definition): definition is OperationDefinitionNode =>
45+
definition.kind === 'OperationDefinition',
46+
);
47+
48+
if (!operation) return new Response('No operation found', { status: 400 });
49+
50+
const config = ENABLE_LIST.find(
51+
(entry) => entry.name === operation.name?.value && entry.type === operation.operation,
52+
);
53+
54+
if (!config) return new Response('Operation not allowed', { status: 403 });
55+
56+
try {
57+
assertFieldsAllowed(operation.selectionSet.selections, config.allowedFields);
58+
} catch {
59+
return new Response('Query is invalid', { status: 400 });
60+
}
61+
62+
const response = await client.fetch({
63+
document: graphql(document),
64+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
65+
variables: variables ? JSON.parse(variables) : undefined,
66+
errorPolicy: 'ignore',
67+
});
68+
69+
return new Response(JSON.stringify(response.data), {
70+
status: 200,
71+
headers: { 'Content-Type': 'application/json' },
72+
});
73+
};
74+
75+
interface Allowed {
76+
[key: string]: Allowed | boolean | null | undefined;
77+
}
78+
79+
function assertFieldsAllowed(
80+
selections: readonly SelectionNode[],
81+
allowed: Allowed,
82+
path: string[] = ['site'],
83+
): asserts selections is readonly SelectionNode[] {
84+
selections.forEach((selection) => {
85+
if (selection.kind !== 'Field') return;
86+
87+
const fieldName = selection.name.value;
88+
const currentPath = [...path, fieldName];
89+
90+
if (!(fieldName in allowed)) {
91+
throw new Error(`Disallowed field: ${currentPath.join('.')}`);
92+
}
93+
94+
const allowedValue = allowed[fieldName];
95+
96+
if (allowedValue === true) {
97+
// All descendants allowed
98+
return;
99+
}
100+
101+
if (typeof allowedValue === 'object' && allowedValue !== null) {
102+
if (!selection.selectionSet) {
103+
throw new Error(`Expected subfields for: ${currentPath.join('.')}`);
104+
}
105+
106+
assertFieldsAllowed(selection.selectionSet.selections, allowedValue, currentPath);
107+
108+
return;
109+
}
110+
111+
if (typeof allowedValue !== 'object' && selection.selectionSet) {
112+
throw new Error(`Field ${currentPath.join('.')} does not allow subfields`);
113+
}
114+
// If allowedValue is falsy and no selectionSet, it's fine (already checked above)
115+
});
116+
}

0 commit comments

Comments
 (0)