Skip to content

Commit

Permalink
CORS fixes for 1.1.0+ (redwoodjs#5429)
Browse files Browse the repository at this point in the history
* Make cors mapping testable

* Just supply the cors object instead of doing a builder

* Add origins in config too

* Fix import

* Start adding tests for createGraphqlHandler

* Try with latest canary build

* Cleanup mocked lambda event

* Downgrade undici fetch | Set yoga cors to false if no redwood cors options supplied (still to be implemented in yoga)

* Downgrade cross-undici-fetch everywhere

* Bump yoga to 2.5.0
  • Loading branch information
dac09 authored and jtoar committed May 5, 2022
1 parent a5b6182 commit 014fd5b
Show file tree
Hide file tree
Showing 10 changed files with 392 additions and 67 deletions.
2 changes: 1 addition & 1 deletion packages/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
"dependencies": {
"@babel/runtime-corejs3": "7.16.7",
"@prisma/client": "3.13.0",
"cross-undici-fetch": "0.3.6",
"cross-undici-fetch": "0.1.27",
"crypto-js": "4.1.1",
"humanize-string": "2.1.0",
"jsonwebtoken": "8.5.1",
Expand Down
2 changes: 1 addition & 1 deletion packages/codemods/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
"@babel/runtime-corejs3": "7.16.7",
"@vscode/ripgrep": "1.14.2",
"core-js": "3.22.2",
"cross-undici-fetch": "0.3.6",
"cross-undici-fetch": "0.1.27",
"deepmerge": "4.2.2",
"fast-glob": "3.2.11",
"findup-sync": "5.0.0",
Expand Down
5 changes: 4 additions & 1 deletion packages/graphql-server/jest.config.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
module.exports = {}
module.exports = {
testMatch: ['**/__tests__/**/*.[jt]s?(x)', '**/*.test.[jt]s?(x)'],
testPathIgnorePatterns: ['fixtures', 'dist'],
}

process.env = Object.assign(process.env, {
WEBHOOK_SECRET: 'MY_VOICE_IS_MY_PASSPORT_VERIFY_ME',
Expand Down
4 changes: 2 additions & 2 deletions packages/graphql-server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,11 @@
"@graphql-tools/merge": "8.2.10",
"@graphql-tools/schema": "8.3.10",
"@graphql-tools/utils": "8.6.9",
"@graphql-yoga/common": "2.4.0",
"@graphql-yoga/common": "2.5.0",
"@prisma/client": "3.13.0",
"@redwoodjs/api": "1.3.0",
"core-js": "3.22.2",
"cross-undici-fetch": "0.3.6",
"cross-undici-fetch": "0.1.27",
"graphql": "16.4.0",
"graphql-scalars": "1.17.0",
"graphql-tag": "2.12.6",
Expand Down
219 changes: 219 additions & 0 deletions packages/graphql-server/src/__tests__/cors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
import type { APIGatewayProxyEvent, Context } from 'aws-lambda'

import { createLogger } from '@redwoodjs/api/logger'

import { createGraphQLHandler } from '../functions/graphql'

jest.mock('../makeMergedSchema/makeMergedSchema', () => {
const { makeExecutableSchema } = require('@graphql-tools/schema')
// Return executable schema
return {
makeMergedSchema: () =>
makeExecutableSchema({
typeDefs: /* GraphQL */ `
type Query {
me: User!
}
type Query {
forbiddenUser: User!
getUser(id: Int!): User!
}
type User {
id: ID!
name: String!
}
`,
resolvers: {
Query: {
me: () => {
return { _id: 1, firstName: 'Ba', lastName: 'Zinga' }
},
forbiddenUser: () => {
throw Error('You are forbidden')
},
getUser: (id) => {
return { id, firstName: 'Ba', lastName: 'Zinga' }
},
},
User: {
id: (u) => u._id,
name: (u) => `${u.firstName} ${u.lastName}`,
},
},
}),
}
})

jest.mock('../directives/makeDirectives', () => {
return {
makeDirectivesForPlugin: () => [],
}
})

const mockLambdaEvent = ({
headers,
body = null,
httpMethod,
...others
}): APIGatewayProxyEvent => {
return {
headers,
body,
httpMethod,
multiValueQueryStringParameters: null,
isBase64Encoded: false,
multiValueHeaders: {},
path: '',
pathParameters: null,
stageVariables: null,
queryStringParameters: null,
requestContext: null,
resource: null,
...others,
}
}

describe('CORS', () => {
it('Returns the origin correctly when configured in handler', async () => {
const handler = createGraphQLHandler({
loggerConfig: { logger: createLogger({}), options: {} },
sdls: {},
directives: {},
services: {},
cors: {
origin: 'https://web.redwoodjs.com',
},
onException: () => {},
})

const mockedEvent = mockLambdaEvent({
headers: {
origin: 'https://redwoodjs.com',
'Content-Type': 'application/json',
},
body: JSON.stringify({ query: '{ me { id, name } }' }),
httpMethod: 'POST',
})

const response = await handler(mockedEvent, {} as Context)

expect(response.statusCode).toBe(200)
expect(response.multiValueHeaders['access-control-allow-origin']).toEqual([
'https://web.redwoodjs.com',
])
})

it('Returns requestOrigin if cors origin set to true', async () => {
const handler = createGraphQLHandler({
loggerConfig: { logger: createLogger({}), options: {} },
sdls: {},
directives: {},
services: {},
cors: {
origin: true,
},
onException: () => {},
})

const mockedEvent = mockLambdaEvent({
headers: {
origin: 'https://someothersite.newjsframework.com',
'Content-Type': 'application/json',
},
body: JSON.stringify({ query: '{ me { id, name } }' }),
httpMethod: 'POST',
})

const response = await handler(mockedEvent, {} as Context)

expect(response.statusCode).toBe(200)
expect(response.multiValueHeaders['access-control-allow-origin']).toEqual([
'https://someothersite.newjsframework.com',
])
})

it('Returns the origin for OPTIONS requests', async () => {
const handler = createGraphQLHandler({
loggerConfig: { logger: createLogger({}), options: {} },
sdls: {},
directives: {},
services: {},
cors: {
origin: 'https://mycrossdomainsite.co.uk',
},
onException: () => {},
})

const mockedEvent = mockLambdaEvent({
headers: {
origin: 'https://someothersite.newjsframework.com',
'Content-Type': 'application/json',
},
httpMethod: 'OPTIONS',
})

const response = await handler(mockedEvent, {} as Context)

expect(response.statusCode).toBe(204)
expect(response.multiValueHeaders['access-control-allow-origin']).toEqual([
'https://mycrossdomainsite.co.uk',
])
})

it('Does not return cross origin headers if option not specified', async () => {
const handler = createGraphQLHandler({
loggerConfig: { logger: createLogger({}), options: {} },
sdls: {},
directives: {},
services: {},
onException: () => {},
})

const mockedEvent = mockLambdaEvent({
headers: {
origin: 'https://someothersite.newjsframework.com',
'Content-Type': 'application/json',
},
httpMethod: 'OPTIONS',
})

const response = await handler(mockedEvent, {} as Context)

expect(response.statusCode).toBe(204)
const responeHeaderKeys = Object.keys(response.multiValueHeaders)

expect(responeHeaderKeys).not.toContain('access-control-allow-origin')
expect(responeHeaderKeys).not.toContain('access-control-allow-credentials')
})

it('Returns the requestOrigin when moore than one origin supplied in config', async () => {
const handler = createGraphQLHandler({
loggerConfig: { logger: createLogger({}), options: {} },
sdls: {},
directives: {},
services: {},
cors: {
origin: ['https://site1.one', 'https://site2.two'],
},
onException: () => {},
})

const mockedEvent: APIGatewayProxyEvent = mockLambdaEvent({
headers: {
origin: 'https://site2.two',
'Content-Type': 'application/json',
},
body: JSON.stringify({ query: '{ me { id, name } }' }),
httpMethod: 'POST',
})

const response = await handler(mockedEvent, {} as Context)

expect(response.statusCode).toBe(200)
expect(response.multiValueHeaders['access-control-allow-origin']).toEqual([
'https://site2.two',
])
})
})
100 changes: 100 additions & 0 deletions packages/graphql-server/src/__tests__/mapRwCorsToYoga.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { mapRwCorsOptionsToYoga } from '../cors'

/** Yoga CORS Options looks like
*
* export interface CORSOptions {
origin?: string[];
methods?: string[];
allowedHeaders?: string[];
exposedHeaders?: string[];
credentials?: boolean;
maxAge?: number;
}
*
*/
describe('mapRwCorsOptionsToYoga', () => {
it('Handles single endpoint, headers and method', () => {
const output = mapRwCorsOptionsToYoga({
origin: 'http://localhost:8910',
allowedHeaders: 'X-Bazinga',
methods: 'PATCH',
credentials: true,
})

expect(output).toEqual({
credentials: true,
allowedHeaders: ['X-Bazinga'],
methods: ['PATCH'],
origin: ['http://localhost:8910'],
})
})

it('Handles options as an array', () => {
const output = mapRwCorsOptionsToYoga({
origin: ['http://localhost:8910'],
credentials: false,
allowedHeaders: ['X-Bazinga', 'X-Kittens', 'Authorization'],
methods: ['PATCH', 'PUT', 'POST'],
})

expect(output).toEqual({
origin: ['http://localhost:8910'],
methods: ['PATCH', 'PUT', 'POST'],
allowedHeaders: ['X-Bazinga', 'X-Kittens', 'Authorization'],
})
})

it('Handles multiple endpoints', () => {
const output = mapRwCorsOptionsToYoga({
origin: ['https://bazinga.com', 'https://softkitty.mew'],
credentials: true,
allowedHeaders: ['X-Bazinga', 'X-Kittens', 'Authorization'],
methods: ['PATCH', 'PUT', 'POST'],
})

expect(output).toEqual({
credentials: true,
origin: ['https://bazinga.com', 'https://softkitty.mew'],
methods: ['PATCH', 'PUT', 'POST'],
allowedHeaders: ['X-Bazinga', 'X-Kittens', 'Authorization'],
})
})

it('Returns the request origin, if cors origin is set to true', () => {
const output = mapRwCorsOptionsToYoga(
{
origin: true,
credentials: true,
allowedHeaders: ['Auth-Provider', 'X-Kittens', 'Authorization'],
methods: ['DELETE'],
},
'https://myapiside.redwood.com' // <-- this is the Request.headers.origin
)

expect(output).toEqual({
credentials: true,
origin: ['https://myapiside.redwood.com'],
methods: ['DELETE'],
allowedHeaders: ['Auth-Provider', 'X-Kittens', 'Authorization'],
})
})

it('Returns the *, if cors origin is set to true AND no request origin supplied', () => {
const output = mapRwCorsOptionsToYoga(
{
origin: true,
credentials: true,
allowedHeaders: ['Auth-Provider', 'X-Kittens', 'Authorization'],
methods: ['DELETE'],
},
undefined
)

expect(output).toEqual({
credentials: true,
origin: ['*'],
methods: ['DELETE'],
allowedHeaders: ['Auth-Provider', 'X-Kittens', 'Authorization'],
})
})
})
Loading

0 comments on commit 014fd5b

Please sign in to comment.