Skip to content

Commit 014cc7c

Browse files
MaxGhenisclaude
andcommitted
fix: Handle multipart file uploads in GraphQL proxy
The GraphQL API proxy routes were using JSON.stringify(req.body) which corrupts multipart form data for file uploads. Changes: - Disable Next.js body parsing to get raw request body - Detect multipart requests via Content-Type header - Forward multipart bodies as raw Buffer, not JSON stringified - Keep JSON request handling unchanged Fixes #11772 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent a7cf25e commit 014cc7c

File tree

4 files changed

+506
-18
lines changed

4 files changed

+506
-18
lines changed
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
/**
2+
* Tests for GraphQL v1 API proxy
3+
* See: https://github.com/opencollective/opencollective-frontend/issues/11772
4+
*/
5+
6+
import { Readable } from 'stream';
7+
8+
// Mock fetch globally
9+
global.fetch = jest.fn();
10+
11+
// Set required env vars
12+
process.env.API_URL = 'https://api.opencollective.com';
13+
process.env.API_KEY = 'test-api-key';
14+
15+
/**
16+
* Create a mock request that works as a stream (for bodyParser: false)
17+
*/
18+
function createMockRequest({ method, headers, body }) {
19+
const bodyBuffer = Buffer.isBuffer(body) ? body : Buffer.from(body || '');
20+
const stream = Readable.from([bodyBuffer]);
21+
22+
return Object.assign(stream, {
23+
method,
24+
headers: headers || {},
25+
});
26+
}
27+
28+
/**
29+
* Create a mock response
30+
*/
31+
function createMockResponse() {
32+
const res = {
33+
statusCode: 200,
34+
headers: {},
35+
body: null,
36+
setHeader(key, value) {
37+
this.headers[key] = value;
38+
return this;
39+
},
40+
json(data) {
41+
this.body = data;
42+
return this;
43+
},
44+
_getJSONData() {
45+
return this.body;
46+
},
47+
};
48+
return res;
49+
}
50+
51+
describe('pages/api/graphql/v1', () => {
52+
let handler;
53+
54+
beforeEach(() => {
55+
jest.resetModules();
56+
jest.clearAllMocks();
57+
// Import fresh for each test
58+
handler = require('../v1').default;
59+
});
60+
61+
describe('JSON requests', () => {
62+
it('should forward JSON requests with stringified body', async () => {
63+
const mockResponse = { data: { Collective: { id: '123' } } };
64+
global.fetch.mockResolvedValueOnce({
65+
json: () => Promise.resolve(mockResponse),
66+
});
67+
68+
const jsonBody = {
69+
query: '{ Collective(slug: "test") { id } }',
70+
variables: {},
71+
};
72+
73+
const req = createMockRequest({
74+
method: 'POST',
75+
headers: {
76+
'content-type': 'application/json',
77+
authorization: 'Bearer test-token',
78+
},
79+
body: JSON.stringify(jsonBody),
80+
});
81+
82+
const res = createMockResponse();
83+
84+
await handler(req, res);
85+
86+
expect(global.fetch).toHaveBeenCalledWith(
87+
expect.stringContaining('/graphql/v1'),
88+
expect.objectContaining({
89+
method: 'POST',
90+
body: JSON.stringify(jsonBody),
91+
}),
92+
);
93+
expect(res._getJSONData()).toEqual(mockResponse);
94+
});
95+
});
96+
97+
describe('multipart requests', () => {
98+
it('should forward multipart requests without JSON stringifying the body', async () => {
99+
const mockResponse = {
100+
data: {
101+
uploadFile: {
102+
file: { id: 'file-123', url: 'https://example.com/file.pdf' },
103+
},
104+
},
105+
};
106+
global.fetch.mockResolvedValueOnce({
107+
json: () => Promise.resolve(mockResponse),
108+
});
109+
110+
// Simulate multipart form data request
111+
const boundary = '----WebKitFormBoundary7MA4YWxkTrZu0gW';
112+
const multipartBody = [
113+
`--${boundary}`,
114+
'Content-Disposition: form-data; name="operations"',
115+
'',
116+
'{"query":"mutation Upload { upload { url } }","variables":{}}',
117+
`--${boundary}`,
118+
'Content-Disposition: form-data; name="map"',
119+
'',
120+
'{"0":["variables.file"]}',
121+
`--${boundary}`,
122+
'Content-Disposition: form-data; name="0"; filename="test.pdf"',
123+
'Content-Type: application/pdf',
124+
'',
125+
'PDF file content here',
126+
`--${boundary}--`,
127+
].join('\r\n');
128+
129+
const req = createMockRequest({
130+
method: 'POST',
131+
headers: {
132+
'content-type': `multipart/form-data; boundary=${boundary}`,
133+
authorization: 'Bearer test-token',
134+
},
135+
body: multipartBody,
136+
});
137+
138+
const res = createMockResponse();
139+
140+
await handler(req, res);
141+
142+
// The key assertion: multipart body should NOT be JSON stringified
143+
const fetchCall = global.fetch.mock.calls[0];
144+
const sentBody = fetchCall[1].body;
145+
146+
// For multipart, the body should be a Buffer (raw data), not a JSON string
147+
expect(Buffer.isBuffer(sentBody)).toBe(true);
148+
expect(sentBody.toString()).toBe(multipartBody);
149+
150+
// Verify it was NOT JSON stringified (would start with a quote)
151+
expect(sentBody.toString()).not.toMatch(/^"/);
152+
153+
expect(res._getJSONData()).toEqual(mockResponse);
154+
});
155+
156+
it('should preserve multipart content-type header with boundary', async () => {
157+
global.fetch.mockResolvedValueOnce({
158+
json: () => Promise.resolve({ data: {} }),
159+
});
160+
161+
const boundary = '----TestBoundary';
162+
const req = createMockRequest({
163+
method: 'POST',
164+
headers: {
165+
'content-type': `multipart/form-data; boundary=${boundary}`,
166+
authorization: 'Bearer test-token',
167+
},
168+
body: `--${boundary}--`,
169+
});
170+
171+
const res = createMockResponse();
172+
173+
await handler(req, res);
174+
175+
const fetchCall = global.fetch.mock.calls[0];
176+
const headers = fetchCall[1].headers;
177+
178+
expect(headers['content-type']).toContain('multipart/form-data');
179+
expect(headers['content-type']).toContain(boundary);
180+
});
181+
});
182+
183+
describe('config', () => {
184+
it('should export config with bodyParser disabled', () => {
185+
const { config } = require('../v1');
186+
expect(config).toEqual({
187+
api: {
188+
bodyParser: false,
189+
},
190+
});
191+
});
192+
});
193+
});
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
/**
2+
* Tests for GraphQL v2 API proxy
3+
* See: https://github.com/opencollective/opencollective-frontend/issues/11772
4+
*/
5+
6+
import { Readable } from 'stream';
7+
8+
// Mock fetch globally
9+
global.fetch = jest.fn();
10+
11+
// Set required env vars
12+
process.env.API_URL = 'https://api.opencollective.com';
13+
process.env.API_KEY = 'test-api-key';
14+
15+
/**
16+
* Create a mock request that works as a stream (for bodyParser: false)
17+
*/
18+
function createMockRequest({ method, headers, body }) {
19+
const bodyBuffer = Buffer.isBuffer(body) ? body : Buffer.from(body || '');
20+
const stream = Readable.from([bodyBuffer]);
21+
22+
return Object.assign(stream, {
23+
method,
24+
headers: headers || {},
25+
});
26+
}
27+
28+
/**
29+
* Create a mock response
30+
*/
31+
function createMockResponse() {
32+
const res = {
33+
statusCode: 200,
34+
headers: {},
35+
body: null,
36+
setHeader(key, value) {
37+
this.headers[key] = value;
38+
return this;
39+
},
40+
json(data) {
41+
this.body = data;
42+
return this;
43+
},
44+
_getJSONData() {
45+
return this.body;
46+
},
47+
};
48+
return res;
49+
}
50+
51+
describe('pages/api/graphql/v2', () => {
52+
let handler;
53+
54+
beforeEach(() => {
55+
jest.resetModules();
56+
jest.clearAllMocks();
57+
// Import fresh for each test
58+
handler = require('../v2').default;
59+
});
60+
61+
describe('JSON requests', () => {
62+
it('should forward JSON requests with stringified body', async () => {
63+
const mockResponse = { data: { account: { id: '123' } } };
64+
global.fetch.mockResolvedValueOnce({
65+
json: () => Promise.resolve(mockResponse),
66+
});
67+
68+
const jsonBody = {
69+
query: '{ account(slug: "test") { id } }',
70+
variables: {},
71+
};
72+
73+
const req = createMockRequest({
74+
method: 'POST',
75+
headers: {
76+
'content-type': 'application/json',
77+
authorization: 'Bearer test-token',
78+
},
79+
body: JSON.stringify(jsonBody),
80+
});
81+
82+
const res = createMockResponse();
83+
84+
await handler(req, res);
85+
86+
expect(global.fetch).toHaveBeenCalledWith(
87+
expect.stringContaining('/graphql/v2'),
88+
expect.objectContaining({
89+
method: 'POST',
90+
body: JSON.stringify(jsonBody),
91+
}),
92+
);
93+
expect(res._getJSONData()).toEqual(mockResponse);
94+
});
95+
});
96+
97+
describe('multipart requests', () => {
98+
it('should forward multipart requests without JSON stringifying the body', async () => {
99+
const mockResponse = {
100+
data: {
101+
uploadFile: {
102+
file: { id: 'file-123', url: 'https://example.com/file.pdf' },
103+
},
104+
},
105+
};
106+
global.fetch.mockResolvedValueOnce({
107+
json: () => Promise.resolve(mockResponse),
108+
});
109+
110+
// Simulate multipart form data request
111+
const boundary = '----WebKitFormBoundary7MA4YWxkTrZu0gW';
112+
const multipartBody = [
113+
`--${boundary}`,
114+
'Content-Disposition: form-data; name="operations"',
115+
'',
116+
'{"query":"mutation UploadFile($files: [UploadFileInput!]!) { uploadFile(files: $files) { file { id url } } }","variables":{"files":[{"kind":"EXPENSE_ITEM","file":null}]}}',
117+
`--${boundary}`,
118+
'Content-Disposition: form-data; name="map"',
119+
'',
120+
'{"0":["variables.files.0.file"]}',
121+
`--${boundary}`,
122+
'Content-Disposition: form-data; name="0"; filename="test.pdf"',
123+
'Content-Type: application/pdf',
124+
'',
125+
'PDF file content here',
126+
`--${boundary}--`,
127+
].join('\r\n');
128+
129+
const req = createMockRequest({
130+
method: 'POST',
131+
headers: {
132+
'content-type': `multipart/form-data; boundary=${boundary}`,
133+
authorization: 'Bearer test-token',
134+
},
135+
body: multipartBody,
136+
});
137+
138+
const res = createMockResponse();
139+
140+
await handler(req, res);
141+
142+
// The key assertion: multipart body should NOT be JSON stringified
143+
const fetchCall = global.fetch.mock.calls[0];
144+
const sentBody = fetchCall[1].body;
145+
146+
// For multipart, the body should be a Buffer (raw data), not a JSON string
147+
expect(Buffer.isBuffer(sentBody)).toBe(true);
148+
expect(sentBody.toString()).toBe(multipartBody);
149+
150+
// Verify it was NOT JSON stringified (would start with a quote)
151+
expect(sentBody.toString()).not.toMatch(/^"/);
152+
153+
expect(res._getJSONData()).toEqual(mockResponse);
154+
});
155+
156+
it('should preserve multipart content-type header with boundary', async () => {
157+
global.fetch.mockResolvedValueOnce({
158+
json: () => Promise.resolve({ data: {} }),
159+
});
160+
161+
const boundary = '----TestBoundary';
162+
const req = createMockRequest({
163+
method: 'POST',
164+
headers: {
165+
'content-type': `multipart/form-data; boundary=${boundary}`,
166+
authorization: 'Bearer test-token',
167+
},
168+
body: `--${boundary}--`,
169+
});
170+
171+
const res = createMockResponse();
172+
173+
await handler(req, res);
174+
175+
const fetchCall = global.fetch.mock.calls[0];
176+
const headers = fetchCall[1].headers;
177+
178+
expect(headers['content-type']).toContain('multipart/form-data');
179+
expect(headers['content-type']).toContain(boundary);
180+
});
181+
});
182+
183+
describe('config', () => {
184+
it('should export config with bodyParser disabled', () => {
185+
const { config } = require('../v2');
186+
expect(config).toEqual({
187+
api: {
188+
bodyParser: false,
189+
},
190+
});
191+
});
192+
});
193+
});

0 commit comments

Comments
 (0)