diff --git a/package-lock.json b/package-lock.json index c3319ebf..0a2d65aa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,6 +38,7 @@ "@types/lodash": "4.14.195", "@types/node": "18.16.19", "@types/sinon": "10.0.15", + "@types/supertest": "6.0.3", "@typescript-eslint/eslint-plugin": "^7.0.1", "chai": "4.3.7", "eslint-plugin-prettier": "^5.1.3", @@ -47,6 +48,7 @@ "nodemon": "3.0.1", "prettier": "3.0.0", "sinon": "15.2.0", + "supertest": "7.3.0", "ts-jest": "29.1.1", "ts-node": "10.9.1" } @@ -1765,6 +1767,19 @@ "@jridgewell/sourcemap-codec": "1.4.14" } }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1819,6 +1834,16 @@ "node": ">=8.0.0" } }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", + "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -2759,6 +2784,13 @@ "@types/node": "*" } }, + "node_modules/@types/cookiejar": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", + "integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/cors": { "version": "2.8.12", "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.12.tgz", @@ -2860,6 +2892,13 @@ "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz", "integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==" }, + "node_modules/@types/methods": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", + "integrity": "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/mime": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.2.tgz", @@ -2953,6 +2992,30 @@ "integrity": "sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==", "dev": true }, + "node_modules/@types/superagent": { + "version": "8.1.11", + "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.11.tgz", + "integrity": "sha512-KA7srSW/HENDtOw9DOqaFLgWuMqN9WgjEw62lh9dpvRaZDkhdOkazASd7X7i2eMUYLHa1U37ZttnePsH5zTDHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/cookiejar": "^2.1.5", + "@types/methods": "^1.1.4", + "@types/node": "*", + "form-data": "^4.0.0" + } + }, + "node_modules/@types/supertest": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-6.0.3.tgz", + "integrity": "sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/methods": "^1.1.4", + "@types/superagent": "^8.1.0" + } + }, "node_modules/@types/uuid": { "version": "9.0.8", "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz", @@ -3619,6 +3682,13 @@ "node": ">=8" } }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true, + "license": "MIT" + }, "node_modules/assertion-error": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", @@ -4366,6 +4436,16 @@ "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==" }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -4425,6 +4505,13 @@ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==" }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "dev": true, + "license": "MIT" + }, "node_modules/cors": { "version": "2.8.5", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", @@ -4476,11 +4563,12 @@ "integrity": "sha512-qTcEYLen3r7ojZNgVUaRggOI+KM7jrKxXeSHhogh/TWxYMeONEMqY+hmkobiYQozsGIyg9OYVzO4ZIfoB4I0pQ==" }, "node_modules/debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", "dependencies": { - "ms": "2.1.2" + "ms": "^2.1.3" }, "engines": { "node": ">=6.0" @@ -4641,6 +4729,17 @@ "node": ">=8" } }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dev": true, + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, "node_modules/dicer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/dicer/-/dicer-0.3.0.tgz", @@ -5360,6 +5459,13 @@ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "dev": true }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "dev": true, + "license": "MIT" + }, "node_modules/fastq": { "version": "1.13.0", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.13.0.tgz", @@ -5526,6 +5632,24 @@ "node": ">= 6" } }, + "node_modules/formidable": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -7944,9 +8068,10 @@ } }, "node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" }, "node_modules/natural-compare": { "version": "1.4.0", @@ -9058,12 +9183,6 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, - "node_modules/send/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, "node_modules/serve-static": { "version": "1.16.3", "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", @@ -9551,6 +9670,82 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/superagent": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.4.1.tgz", + "integrity": "sha512-PVMkMrhKrSTtFhUU8jiWuGh/gyRMKiyCTCmm0+qVzRNxlfntmRKJkDhlEXN62x3HIb33sQ4kmL8lcSdccWAchQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "component-emitter": "^1.3.1", + "cookiejar": "^2.1.4", + "debug": "^4.3.7", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.5", + "formidable": "^3.5.4", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.14.1" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/superagent/node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/superagent/node_modules/qs": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/supertest": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.3.0.tgz", + "integrity": "sha512-UwwmWq3xLhyU96c521wYNaBg7IqX78Wpj4FKs6Katp6vdklX42zU5ESvX3KeZRLFyO44RwFp4yvxYMNZzCcX9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "cookie-signature": "^1.2.2", + "methods": "^1.1.2", + "superagent": "^10.3.0" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/supertest/node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", diff --git a/package.json b/package.json index e6679851..adb6e6df 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "@types/lodash": "4.14.195", "@types/node": "18.16.19", "@types/sinon": "10.0.15", + "@types/supertest": "6.0.3", "@typescript-eslint/eslint-plugin": "^7.0.1", "chai": "4.3.7", "eslint-plugin-prettier": "^5.1.3", @@ -55,6 +56,7 @@ "nodemon": "3.0.1", "prettier": "3.0.0", "sinon": "15.2.0", + "supertest": "7.3.0", "ts-jest": "29.1.1", "ts-node": "10.9.1" } diff --git a/src/server/gateway.ts b/src/server/gateway.ts index 2910eb56..9e4f9e94 100644 --- a/src/server/gateway.ts +++ b/src/server/gateway.ts @@ -54,4 +54,6 @@ if (process.env.NODE_ENV === 'local') { // Initialize an ApolloGateway instance and pass it an array of // your implementing service names and URLs -export const getAppGateway = (): ApolloGateway => new ApolloGateway(options); +export const getAppGateway = ( + overrides: Partial = {}, +): ApolloGateway => new ApolloGateway({ ...options, ...overrides }); diff --git a/src/server/upload.spec.ts b/src/server/upload.spec.ts new file mode 100644 index 00000000..afd4604d --- /dev/null +++ b/src/server/upload.spec.ts @@ -0,0 +1,164 @@ +import http from 'http'; +import { AddressInfo } from 'net'; +import { buildSchema, graphql, parse } from 'graphql'; +import { composeServices } from '@apollo/composition'; +import { processRequest } from 'graphql-upload'; +import request from 'supertest'; +import sinon from 'sinon'; +import * as jwtUtils from '../jwtUtils'; + +// Mirrors curated-corpus-api's uploadApprovedCorpusItemImage mutation. +const typeDefs = ` + scalar Upload + type UploadedFile { + filename: String! + mimetype: String! + base64: String! + } + type Query { + ok: Boolean + } + type Mutation { + uploadApprovedCorpusItemImage(data: Upload!): UploadedFile + } +`; + +const readFile = async (upload): Promise> => { + const { filename, mimetype, createReadStream } = await upload.promise; + const chunks: Buffer[] = []; + for await (const chunk of createReadStream()) chunks.push(chunk); + return { + filename, + mimetype, + base64: Buffer.concat(chunks).toString('base64'), + }; +}; + +/** + * Stub subgraph that parses multipart requests with graphql-upload, like + * curated-corpus-api does, and echoes the received file back. + */ +const startStubSubgraph = async (): Promise => { + const schema = buildSchema(typeDefs); + const server = http.createServer(async (req, res) => { + let body; + if (req.headers['content-type']?.startsWith('multipart/form-data')) { + body = await processRequest(req, res); + } else { + const chunks: Buffer[] = []; + for await (const chunk of req) chunks.push(chunk); + body = JSON.parse(Buffer.concat(chunks).toString()); + } + const result = await graphql({ + schema, + source: body.query, + variableValues: body.variables, + rootValue: { + uploadApprovedCorpusItemImage: ({ data }) => + data?.promise ? readFile(data) : null, + }, + }); + res.setHeader('content-type', 'application/json'); + res.setHeader('connection', 'close'); + res.end(JSON.stringify(result)); + }); + await new Promise((resolve) => server.listen(0, resolve)); + return server; +}; + +describe('file uploads', () => { + let subgraph: http.Server; + let app; + + beforeAll(async () => { + subgraph = await startStubSubgraph(); + const { port } = subgraph.address() as AddressInfo; + const { supergraphSdl, errors } = composeServices([ + { + name: 'corpus', + url: `http://localhost:${port}/`, + typeDefs: parse( + `extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key"])\n` + + typeDefs, + ), + }, + ]); + if (errors) throw errors[0]; + + sinon.stub(jwtUtils, 'getSigningKeysFromServer').resolves({}); + sinon.stub(jwtUtils, 'validateAndGetAdminAPIUser').resolves({ + name: 'Test User', + groups: ['mozilliansorg_pocket_scheduled_surface_curator_full'], + username: 'test-user', + }); + + jest.doMock('./gateway', () => { + const actual = jest.requireActual('./gateway'); + return { getAppGateway: () => actual.getAppGateway({ supergraphSdl }) }; + }); + app = (await import('./main')).default; + }); + + afterAll(async () => { + sinon.restore(); + await new Promise((resolve) => subgraph.close(resolve)); + }); + + it('forwards the uploaded file to the subgraph byte for byte', async () => { + // Non-UTF-8 bytes catch any text re-encoding of the file stream. + const bytes = Buffer.from([ + 0x89, 0x50, 0x4e, 0x47, 0x00, 0xff, 0xfe, 0x0d, 0x0a, + ]); + const query = `mutation ($data: Upload!) { + uploadApprovedCorpusItemImage(data: $data) { filename mimetype base64 } + }`; + + // Retry until the gateway has finished loading the supergraph. + let res; + for (let i = 0; i < 50; i++) { + res = await request(app) + .post('/') + .set('authorization', 'Bearer test-jwt') + .set('apollo-require-preflight', 'true') + .field( + 'operations', + JSON.stringify({ query, variables: { data: null } }), + ) + .field('map', JSON.stringify({ 0: ['variables.data'] })) + .attach('0', bytes, { + filename: 'image.png', + contentType: 'image/png', + }); + if (res.status !== 404) break; + await new Promise((r) => setTimeout(r, 100)); + } + + expect(res.status).toBe(200); + expect(res.body.errors).toBeUndefined(); + expect(res.body.data.uploadApprovedCorpusItemImage).toEqual({ + filename: 'image.png', + mimetype: 'image/png', + base64: bytes.toString('base64'), + }); + }); + + it.each(['__proto__.polluted', 'constructor.prototype.polluted'])( + 'does not pollute Object.prototype via the upload path %s', + async (path) => { + const query = `mutation ($data: Upload!) { + uploadApprovedCorpusItemImage(data: $data) { filename } + }`; + const res = await request(app) + .post('/') + .set('authorization', 'Bearer test-jwt') + .set('apollo-require-preflight', 'true') + .field('operations', JSON.stringify({ query, variables: { data: {} } })) + .field('map', JSON.stringify({ 0: [`variables.data.${path}`] })) + .attach('0', Buffer.from('x'), 'x.png'); + + // The request reaches the subgraph, so the upload data source ran. + expect(res.status).toBe(200); + expect(({} as Record).polluted).toBeUndefined(); + }, + ); +});