diff --git a/__mocks__/node-fetch.js b/__mocks__/node-fetch.js new file mode 100644 index 000000000..7e53f3a77 --- /dev/null +++ b/__mocks__/node-fetch.js @@ -0,0 +1,31 @@ +/* + * Copyright 2022 American Express Travel Related Services Company, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,either express + * or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ + +const fetch = jest.createMockFromModule('node-fetch'); +fetch.mockReturnJsonOnce = (obj) => { + if (obj instanceof Error) { + return fetch.mockImplementationOnce(() => Promise.reject(obj)); + } + + return fetch.mockImplementationOnce(() => Promise.resolve({ body: JSON.stringify(obj) })); +}; + +fetch.mockReturnFileOnce = (body) => { + if (body instanceof Error) { + return fetch.mockImplementationOnce(() => Promise.reject(body)); + } + + return fetch.mockImplementationOnce(() => Promise.resolve({ body, statusCode: 200 })); +}; +module.exports = fetch; diff --git a/__tests__/client/initClient.spec.js b/__tests__/client/initClient.spec.js index cfc860de4..bcc088b87 100644 --- a/__tests__/client/initClient.spec.js +++ b/__tests__/client/initClient.spec.js @@ -17,11 +17,10 @@ import { fromJS } from 'immutable'; -jest.mock('@americanexpress/one-app-router', () => { - const reactRouter = jest.requireActual('@americanexpress/one-app-router'); - jest.spyOn(reactRouter, 'matchPromise'); - return reactRouter; -}); +jest.mock('@americanexpress/one-app-router', () => ({ + ...jest.requireActual('@americanexpress/one-app-router'), + matchPromise: jest.fn(), +})); jest.mock('../../src/client/prerender', () => { const prerender = jest.requireActual('../../src/client/prerender'); diff --git a/__tests__/integration/__snapshots__/one-app.spec.js.snap b/__tests__/integration/__snapshots__/one-app.spec.js.snap index 34e155979..9a563ad39 100644 --- a/__tests__/integration/__snapshots__/one-app.spec.js.snap +++ b/__tests__/integration/__snapshots__/one-app.spec.js.snap @@ -50,6 +50,8 @@ exports[`Tests that require Docker setup one-app successfully started metrics ha "nodejs_active_handles_total", "nodejs_active_requests", "nodejs_active_requests_total", + "nodejs_active_resources", + "nodejs_active_resources_total", "nodejs_eventloop_lag_max_seconds", "nodejs_eventloop_lag_mean_seconds", "nodejs_eventloop_lag_min_seconds", diff --git a/__tests__/integration/one-app.spec.js b/__tests__/integration/one-app.spec.js index 1c8e05334..981070021 100644 --- a/__tests__/integration/one-app.spec.js +++ b/__tests__/integration/one-app.spec.js @@ -128,10 +128,10 @@ describe('Tests that require Docker setup', () => { headers: { origin: 'test.example.com', }, + body: JSON.stringify({}), }); const rawHeaders = response.headers.raw(); expect(response.status).toBe(200); - expect(rawHeaders).not.toHaveProperty('access-control-allow-origin'); expect(rawHeaders).not.toHaveProperty('access-control-expose-headers'); expect(rawHeaders).not.toHaveProperty('access-control-allow-credentials'); }); @@ -210,9 +210,9 @@ describe('Tests that require Docker setup', () => { headers: { origin: 'test.example.com', }, - body: { + body: JSON.stringify({ message: 'Hello!', - }, + }), } ); const rawHeaders = response.headers.raw(); @@ -1335,9 +1335,6 @@ describe('Tests that require Docker setup', () => { date: [ expect.any(String), ], - etag: [ - expect.any(String), - ], 'one-app-version': [ expect.any(String), ], @@ -1348,14 +1345,23 @@ describe('Tests that require Docker setup', () => { 'max-age=15552000; includeSubDomains', ], vary: [ - 'Accept-Encoding', + 'Accept-Encoding, accept-encoding', ], 'x-content-type-options': [ 'nosniff', ], + 'x-dns-prefetch-control': [ + 'off', + ], + 'x-download-options': [ + 'noopen', + ], 'x-frame-options': [ 'DENY', ], + 'x-permitted-cross-domain-policies': [ + 'none', + ], 'x-xss-protection': [ '1; mode=block', ], @@ -1369,9 +1375,11 @@ describe('Tests that require Docker setup', () => { headers: { origin: 'test.example.com', }, + body: {}, }); expect(response.headers.raw()).toEqual({ + vary: ['Accept-Encoding'], connection: [ 'close', ], @@ -1385,7 +1393,7 @@ describe('Tests that require Docker setup', () => { expect.any(String), ], 'referrer-policy': [ - 'no-referrer', + 'same-origin', ], 'strict-transport-security': [ 'max-age=15552000; includeSubDomains', @@ -1400,13 +1408,13 @@ describe('Tests that require Docker setup', () => { 'noopen', ], 'x-frame-options': [ - 'SAMEORIGIN', + 'DENY', ], 'x-permitted-cross-domain-policies': [ 'none', ], 'x-xss-protection': [ - '0', + '1; mode=block', ], }); expect(response.status).toBe(204); @@ -1421,6 +1429,7 @@ describe('Tests that require Docker setup', () => { headers: { origin: 'test.example.com', }, + body: {}, }); expect(response.headers.raw()).toEqual({ @@ -1439,20 +1448,17 @@ describe('Tests that require Docker setup', () => { date: [ expect.any(String), ], - etag: [ - expect.any(String), - ], 'one-app-version': [ expect.any(String), ], 'referrer-policy': [ - 'no-referrer', + 'same-origin', ], 'strict-transport-security': [ 'max-age=15552000; includeSubDomains', ], vary: [ - 'Accept-Encoding', + 'Accept-Encoding, accept-encoding', ], 'x-content-type-options': [ 'nosniff', @@ -1464,13 +1470,13 @@ describe('Tests that require Docker setup', () => { 'noopen', ], 'x-frame-options': [ - 'SAMEORIGIN', + 'DENY', ], 'x-permitted-cross-domain-policies': [ 'none', ], 'x-xss-protection': [ - '0', + '1; mode=block', ], }); expect(response.status).toBe(415); @@ -1485,8 +1491,11 @@ describe('Tests that require Docker setup', () => { origin: 'test.example.com', 'content-type': 'application/json', }, + body: JSON.stringify({}), }); + // expect(response.status).toBe(204); + expect(await response.text()).toBe(''); expect(response.headers.raw()).toEqual({ connection: [ 'close', @@ -1497,18 +1506,16 @@ describe('Tests that require Docker setup', () => { date: [ expect.any(String), ], - etag: [ - expect.any(String), - ], 'one-app-version': [ expect.any(String), ], 'referrer-policy': [ - 'no-referrer', + 'same-origin', ], 'strict-transport-security': [ 'max-age=15552000; includeSubDomains', ], + vary: ['Accept-Encoding'], 'x-content-type-options': [ 'nosniff', ], @@ -1519,17 +1526,15 @@ describe('Tests that require Docker setup', () => { 'noopen', ], 'x-frame-options': [ - 'SAMEORIGIN', + 'DENY', ], 'x-permitted-cross-domain-policies': [ 'none', ], 'x-xss-protection': [ - '0', + '1; mode=block', ], }); - expect(response.status).toBe(204); - expect(await response.text()).toBe(''); }); test('Request: /foo/invalid.json', async () => { @@ -1542,6 +1547,7 @@ describe('Tests that require Docker setup', () => { }); expect(response.status).toBe(404); + expect(await response.text()).toBe('Not found'); expect(response.headers.raw()).toEqual({ 'cache-control': [ 'no-store', @@ -1561,8 +1567,8 @@ describe('Tests that require Docker setup', () => { date: [ expect.any(String), ], - etag: [ - expect.any(String), + 'expect-ct': [ + 'max-age=0', ], 'one-app-version': [ expect.any(String), @@ -1577,7 +1583,7 @@ describe('Tests that require Docker setup', () => { 'max-age=15552000; includeSubDomains', ], vary: [ - 'Accept-Encoding', + 'Accept-Encoding, accept-encoding', ], 'x-content-type-options': [ 'nosniff', @@ -1655,9 +1661,9 @@ describe('Tests that can run against either local Docker setup or remote One App headers: { origin: 'test.example.com', }, - body: { + body: JSON.stringify({ message: 'Hello!', - }, + }), } ); expect(response.status).toBe(200); @@ -1669,11 +1675,10 @@ describe('Tests that can run against either local Docker setup or remote One App const response = await fetch(`${appInstanceUrls.fetchUrl}/success`, { ...defaultFetchOpts, method: 'POST', + body: {}, }); const pageHtml = await response.text(); - expect(pageHtml.includes('Hello! One App is successfully rendering its Modules!')).toBe( - true - ); + expect(pageHtml).toContain('Hello! One App is successfully rendering its Modules!'); }); test('app passes vitruvius data to modules', async () => { @@ -1702,7 +1707,7 @@ describe('Tests that can run against either local Docker setup or remote One App method: 'GET', originalUrl: '/vitruvius', params: { - 0: '/vitruvius', + '*': 'vitruvius', }, protocol: expect.stringMatching(/^https?$/), query: {}, @@ -1732,6 +1737,7 @@ describe('Tests that can run against either local Docker setup or remote One App sendingData: 'in POSTs', }); }); + test('app passes urlencoded POST data to modules via vitruvius', async () => { const response = await fetch(`${appInstanceUrls.fetchUrl}/vitruvius`, { ...defaultFetchOpts, diff --git a/__tests__/server/config/env/runTime.spec.js b/__tests__/server/config/env/runTime.spec.js index 4f8df3e0c..2582b834f 100644 --- a/__tests__/server/config/env/runTime.spec.js +++ b/__tests__/server/config/env/runTime.spec.js @@ -426,7 +426,7 @@ describe('runTime', () => { const referrerPolicyOverride = getEnvVarConfig('ONE_REFERRER_POLICY_OVERRIDE'); it('default value', () => { - expect(referrerPolicyOverride.defaultValue()).toEqual('same-origin'); + expect(referrerPolicyOverride.defaultValue()).toEqual(''); }); it('validates approved policy', () => { diff --git a/__tests__/server/devHolocronCDN.spec.js b/__tests__/server/devHolocronCDN.spec.js index 9dad9db44..21f2930b4 100644 --- a/__tests__/server/devHolocronCDN.spec.js +++ b/__tests__/server/devHolocronCDN.spec.js @@ -15,13 +15,11 @@ */ /* eslint-disable global-require */ -import path from 'path'; import fs from 'fs'; -import findUp from 'find-up'; import '../../src/server/devHolocronCDN'; jest.mock('cors', () => jest.fn(() => (req, res, next) => next())); -jest.mock('@americanexpress/one-app-dev-cdn', () => jest.fn(() => (req, res, next) => next())); +jest.mock('../../src/server/utils/devCdnFactory', () => jest.fn(() => (req, res, next) => next())); jest.spyOn(fs, 'existsSync').mockImplementation(() => true); describe('devHolocronCDN', () => { @@ -36,7 +34,6 @@ describe('devHolocronCDN', () => { }); describe('setup', () => { - let cors; let oneAppDevCdn; beforeEach(() => { @@ -44,8 +41,7 @@ describe('devHolocronCDN', () => { }); async function load() { - cors = require('cors'); - oneAppDevCdn = require('@americanexpress/one-app-dev-cdn'); + oneAppDevCdn = require('../../src/server/utils/devCdnFactory'); const devHolocronCDN = require('../../src/server/devHolocronCDN').default; @@ -54,127 +50,9 @@ describe('devHolocronCDN', () => { return fastify; } - it('should add cors to the app', async () => { - await load(); - expect(cors).toHaveBeenCalled(); - }); - - it('should add @americanexpress/one-app-dev-cdn to the static route', async () => { + it('should add one-app-dev-cdn to the static route', async () => { await load(); expect(oneAppDevCdn).toHaveBeenCalled(); }); - - it('should give @americanexpress/one-app-dev-cdn the path to the static directory', async () => { - expect.assertions(1); - const moduleMapUrl = 'https://example.com/module-map.json'; - process.argv = [ - '', - '', - '--module-map-url', - moduleMapUrl, - ]; - - await load(); - - return findUp('package.json') - .then((filepath) => { - expect(oneAppDevCdn).toHaveBeenCalledWith({ - localDevPublicPath: path.join(path.dirname(filepath), 'static'), - remoteModuleMapUrl: moduleMapUrl, - useLocalModules: true, - }); - }); - }); - - it('does not require useLocalModules when no static module-map', async () => { - expect.assertions(1); - fs.existsSync.mockImplementationOnce(() => false); - const moduleMapUrl = 'https://example.com/module-map.json'; - process.argv = [ - '', - '', - '--module-map-url', - moduleMapUrl, - ]; - - await load(); - - return findUp('package.json') - .then((filepath) => { - expect(oneAppDevCdn).toHaveBeenCalledWith({ - localDevPublicPath: path.join(path.dirname(filepath), 'static'), - remoteModuleMapUrl: moduleMapUrl, - useLocalModules: false, - }); - }); - }); - }); - - describe('routing', () => { - let corsMiddleware; - let oneAppDevCdnMiddleware; - let devHolocronCDN; - let cors; - let oneAppDevCdn; - - beforeEach(() => { - jest.resetModules(); - }); - - async function load() { - cors = require('cors'); - oneAppDevCdn = require('@americanexpress/one-app-dev-cdn'); - corsMiddleware = jest.fn((req, res, next) => next()); - cors.mockReturnValue(corsMiddleware); - oneAppDevCdnMiddleware = jest.fn((req, res, next) => next()); - oneAppDevCdn.mockReturnValue(oneAppDevCdnMiddleware); - devHolocronCDN = require('../../src/server/devHolocronCDN').default; - - return devHolocronCDN(); - } - - it('should hit the cors middleware first', async () => { - expect.assertions(3); - const instance = await load(); - - corsMiddleware.mockImplementationOnce((req, res) => res.sendStatus(204)); - - const response = await instance.inject({ - method: 'GET', - url: '/static/anything.json', - }); - - expect(corsMiddleware).toHaveBeenCalledTimes(1); - expect(oneAppDevCdnMiddleware).not.toHaveBeenCalled(); - expect(response.statusCode).toBe(204); - }); - - it('should hit the one-app-dev-cdn middleware after cors', async () => { - expect.assertions(2); - - const instance = await load(); - - await instance.inject({ - method: 'GET', - url: '/static/anything.json', - }); - - expect(corsMiddleware).toHaveBeenCalledTimes(1); - expect(oneAppDevCdnMiddleware).toHaveBeenCalledTimes(1); - }); - - it('should miss the one-app-dev-cdn middleware if not a static route', async () => { - expect.assertions(2); - - const instance = await load(); - - await instance.inject({ - method: 'GET', - url: '/not-static.json', - }); - - expect(corsMiddleware).toHaveBeenCalledTimes(1); - expect(oneAppDevCdnMiddleware).not.toHaveBeenCalled(); - }); }); }); diff --git a/__tests__/server/middleware/__snapshots__/clientErrorLogger.spec.js.snap b/__tests__/server/middleware/__snapshots__/clientErrorLogger.spec.js.snap deleted file mode 100644 index c8b264466..000000000 --- a/__tests__/server/middleware/__snapshots__/clientErrorLogger.spec.js.snap +++ /dev/null @@ -1,13 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`clientErrorLogger production should log a warning when given an array of not objects 1`] = ` -[ - "dropping an error report, wrong interface (string)", -] -`; - -exports[`clientErrorLogger production should log a warning when not given an array 1`] = ` -[ - "dropping an error report group, wrong interface (string)", -] -`; diff --git a/__tests__/server/middleware/__snapshots__/csp.spec.js.snap b/__tests__/server/middleware/__snapshots__/csp.spec.js.snap deleted file mode 100644 index bae738a72..000000000 --- a/__tests__/server/middleware/__snapshots__/csp.spec.js.snap +++ /dev/null @@ -1,5 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`csp middleware adds ip and localhost to csp in development 1`] = `"default-src 'none'; script-src 'nonce-00000000-0000-0000-0000-000000000000' 0.0.0.0:* localhost:* ws://localhost:* 'self'; connect-src 0.0.0.0:* localhost:* ws://localhost:* 'self';"`; - -exports[`csp updateCSP updates cspCache with given csp 1`] = `"default-src 'self';"`; diff --git a/__tests__/server/middleware/__snapshots__/cspViolation.spec.js.snap b/__tests__/server/middleware/__snapshots__/cspViolation.spec.js.snap deleted file mode 100644 index 7c3d4b874..000000000 --- a/__tests__/server/middleware/__snapshots__/cspViolation.spec.js.snap +++ /dev/null @@ -1,7 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`cspViolation development should log the request body 1`] = ` -[ - "CSP Violation: http://analytics.example.com:5:54 on page http://localhost:3000/start violated the script-src policy via eval", -] -`; diff --git a/__tests__/server/middleware/__snapshots__/sendHtml.spec.js.snap b/__tests__/server/middleware/__snapshots__/sendHtml.spec.js.snap deleted file mode 100644 index c55de1dd3..000000000 --- a/__tests__/server/middleware/__snapshots__/sendHtml.spec.js.snap +++ /dev/null @@ -1,15 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`sendHtml renderModuleScripts adds cache busting clientCacheRevision from module map to each module script src if NODE_ENV is production 1`] = `""`; - -exports[`sendHtml renderModuleScripts does not add cache busting clientCacheRevision from module map to each module script src if NODE_ENV is development 1`] = `""`; - -exports[`sendHtml renderModuleScripts does not add cache busting clientCacheRevision if not present 1`] = `""`; - -exports[`sendHtml renderModuleScripts send a rendered page keeping correctly ordered modules 1`] = `""`; - -exports[`sendHtml renderModuleScripts send a rendered page with correctly ordered modules 1`] = `""`; - -exports[`sendHtml renderModuleScripts send a rendered page with module script tags with integrity attribute if NODE_ENV is production 1`] = `""`; - -exports[`sendHtml renderModuleScripts sends a rendered page with cross origin scripts 1`] = `""`; diff --git a/__tests__/server/middleware/__snapshots__/setAppVersionHeader.spec.js.snap b/__tests__/server/middleware/__snapshots__/setAppVersionHeader.spec.js.snap deleted file mode 100644 index febfc42b1..000000000 --- a/__tests__/server/middleware/__snapshots__/setAppVersionHeader.spec.js.snap +++ /dev/null @@ -1,7 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`setAppVersionHeader should set the app version header 1`] = ` -{ - "one-app-version": "x.0", -} -`; diff --git a/__tests__/server/middleware/addCacheHeaders.spec.js b/__tests__/server/middleware/addCacheHeaders.spec.js deleted file mode 100644 index c52023832..000000000 --- a/__tests__/server/middleware/addCacheHeaders.spec.js +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Copyright 2019 American Express Travel Related Services Company, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express - * or implied. See the License for the specific language governing - * permissions and limitations under the License. - */ - -import addCacheHeaders from '../../../src/server/middleware/addCacheHeaders'; - -describe('addCacheHeaders', () => { - it('should add all expected cache headers', () => { - const req = { get: jest.fn(), headers: {} }; - const res = { set: jest.fn((key, value) => value) }; - const next = jest.fn(); - addCacheHeaders(req, res, next); - - const cacheHeaders = { - 'Cache-Control': 'no-store', - Pragma: 'no-cache', - }; - expect(res.set.mock.calls.length).toEqual(Object.keys(cacheHeaders).length); - Object.keys(cacheHeaders).forEach( - (header) => expect(res.set).toBeCalledWith(header, cacheHeaders[header]) - ); - expect(next).toBeCalled(); - }); -}); diff --git a/__tests__/server/middleware/addFrameOptionsHeader.spec.js b/__tests__/server/middleware/addFrameOptionsHeader.spec.js deleted file mode 100644 index 2d7a43bab..000000000 --- a/__tests__/server/middleware/addFrameOptionsHeader.spec.js +++ /dev/null @@ -1,60 +0,0 @@ -/* - * Copyright 2019 American Express Travel Related Services Company, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express - * or implied. See the License for the specific language governing - * permissions and limitations under the License. - */ - -import addFrameOptionsHeader from '../../../src/server/middleware/addFrameOptionsHeader'; - -jest.mock('../../../src/server/middleware/csp', () => ({ - getCSP: () => ({ - 'frame-ancestors': ['valid.example.com'], - }), -})); - -describe('addFrameOptionsHeader', () => { - let req; - - const set = jest.fn((key, value) => value); - const res = { set }; - const next = jest.fn(); - - beforeEach(() => { - set.mockClear(); - next.mockClear(); - }); - - it('should add X-Frame-Options ALLOW-FROM header on approved ancestor', () => { - req = { - get: jest.fn(() => 'https://valid.example.com/embedded'), - }; - addFrameOptionsHeader(req, res, next); - - expect(req.get).toHaveBeenCalledWith('Referer'); - expect(res.set).toBeCalledWith( - 'X-Frame-Options', - 'ALLOW-FROM https://valid.example.com/embedded' - ); - expect(next).toBeCalled(); - }); - - it('should not add X-Frame-Options ALLOW-FROM header on unapproved ancestor', () => { - req = { - get: jest.fn(() => 'https://example.com/embedded'), - }; - addFrameOptionsHeader(req, res, next); - - expect(res.set).not.toHaveBeenCalled(); - expect(next).toBeCalled(); - }); -}); diff --git a/__tests__/server/middleware/addSecurityHeaders.spec.js b/__tests__/server/middleware/addSecurityHeaders.spec.js deleted file mode 100644 index a3227d5a7..000000000 --- a/__tests__/server/middleware/addSecurityHeaders.spec.js +++ /dev/null @@ -1,52 +0,0 @@ -/* - * Copyright 2019 American Express Travel Related Services Company, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express - * or implied. See the License for the specific language governing - * permissions and limitations under the License. - */ - -import addSecurityHeaders from '../../../src/server/middleware/addSecurityHeaders'; - -describe('addSecurityHeaders', () => { - it('should add all expected security headers', () => { - const req = { get: jest.fn(), headers: {} }; - const res = { set: jest.fn((key, value) => value) }; - const next = jest.fn(); - addSecurityHeaders(req, res, next); - - const securityHeaders = { - 'X-Frame-Options': 'DENY', - 'X-Content-Type-Options': 'nosniff', - 'Strict-Transport-Security': 'max-age=15552000; includeSubDomains', - 'X-XSS-Protection': '1; mode=block', - 'Referrer-Policy': 'same-origin', - }; - expect(res.set.mock.calls.length).toEqual(Object.keys(securityHeaders).length); - Object.keys(securityHeaders).forEach( - (header) => expect(res.set).toBeCalledWith(header, securityHeaders[header]) - ); - expect(next).toBeCalled(); - }); - - describe('Referrer-Policy', () => { - it('default can be overridden', () => { - const req = { get: jest.fn(), headers: {} }; - const res = { set: jest.fn((key, value) => value) }; - process.env.ONE_REFERRER_POLICY_OVERRIDE = 'no-referrer'; - - addSecurityHeaders(req, res, jest.fn()); - expect(res.set).toBeCalledWith('Referrer-Policy', 'no-referrer'); - - delete process.env.ONE_REFERRER_POLICY_OVERRIDE; - }); - }); -}); diff --git a/__tests__/server/middleware/checkStateForRedirect.spec.js b/__tests__/server/middleware/checkStateForRedirect.spec.js deleted file mode 100644 index 4ec6502ad..000000000 --- a/__tests__/server/middleware/checkStateForRedirect.spec.js +++ /dev/null @@ -1,43 +0,0 @@ -/* - * Copyright 2019 American Express Travel Related Services Company, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express - * or implied. See the License for the specific language governing - * permissions and limitations under the License. - */ - -import { fromJS } from 'immutable'; - -import checkStateForRedirect from '../../../src/server/middleware/checkStateForRedirect'; - -describe('checkStateForRedirect', () => { - const destination = 'http://example.com/'; - let state = fromJS({ redirection: { destination: null } }); - const req = { store: { getState: () => state } }; - const res = { redirect: jest.fn() }; - const next = jest.fn(); - - beforeEach(() => jest.clearAllMocks()); - - it('should redirect if there is a destination', () => { - state = fromJS({ redirection: { destination } }); - checkStateForRedirect(req, res, next); - expect(res.redirect).toHaveBeenCalledWith(302, destination); - expect(next).not.toHaveBeenCalled(); - }); - - it('should got to the next middleware if there is no destination', () => { - state = fromJS({ redirection: { destination: null } }); - checkStateForRedirect(req, res, next); - expect(res.redirect).not.toHaveBeenCalled(); - expect(next).toHaveBeenCalled(); - }); -}); diff --git a/__tests__/server/middleware/checkStateForStatusCode.spec.js b/__tests__/server/middleware/checkStateForStatusCode.spec.js deleted file mode 100644 index f0c14001e..000000000 --- a/__tests__/server/middleware/checkStateForStatusCode.spec.js +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Copyright 2019 American Express Travel Related Services Company, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express - * or implied. See the License for the specific language governing - * permissions and limitations under the License. - */ - -import { fromJS } from 'immutable'; - -import checkStateForStatusCode from '../../../src/server/middleware/checkStateForStatusCode'; - -describe('checkStateForStatusCode', () => { - let state = fromJS({ error: { code: null } }); - const req = { store: { getState: () => state } }; - - const res = jest.fn(); - res.status = jest.fn(() => res); - - const next = jest.fn(); - - beforeEach(() => jest.clearAllMocks()); - - it('should not set the status and go to the next middleware if an error code is not present', () => { - checkStateForStatusCode(req, res, next); - expect(res.status).not.toHaveBeenCalled(); - expect(next).toHaveBeenCalled(); - }); - - it('should not set the status and go to the next middleware if an error is not present', () => { - state = fromJS({ error: null }); - checkStateForStatusCode(req, res, next); - expect(res.status).not.toHaveBeenCalled(); - expect(next).toHaveBeenCalled(); - }); - - it('should set the response status if an error code is present in the state and then go to the next middleware', () => { - state = fromJS({ error: { code: '500' } }); - checkStateForStatusCode(req, res, next); - expect(res.status).toHaveBeenCalledWith('500'); - expect(next).toHaveBeenCalled(); - }); -}); diff --git a/__tests__/server/middleware/clientErrorLogger.spec.js b/__tests__/server/middleware/clientErrorLogger.spec.js deleted file mode 100644 index dca62ade6..000000000 --- a/__tests__/server/middleware/clientErrorLogger.spec.js +++ /dev/null @@ -1,190 +0,0 @@ -/* - * Copyright 2019 American Express Travel Related Services Company, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express - * or implied. See the License for the specific language governing - * permissions and limitations under the License. - */ - -import { createRequest, createResponse } from 'node-mocks-http'; - -describe('clientErrorLogger', () => { - let clientErrorLogger; - jest.spyOn(console, 'error').mockImplementation(() => {}); - jest.spyOn(console, 'warn').mockImplementation(() => {}); - - function load(nodeEnv) { - if (typeof nodeEnv !== 'string') { - delete process.env.NODE_ENV; - } else { - process.env.NODE_ENV = nodeEnv; - } - - jest.resetModules(); - clientErrorLogger = require('../../../src/server/middleware/clientErrorLogger').default; - } - - describe('development', () => { - it('should send 200 status with thank you', () => { - load('development'); - const req = createRequest({ headers: { 'content-type': 'content type headers' } }); - const res = createResponse({ req }); - clientErrorLogger(req, res); - expect(res.statusCode).toBe(204); - expect(res.finished).toBe(true); - }); - }); - - describe('production', () => { - it('should send status 415 for invalid content-type in production', () => { - load(); - const req = createRequest({ headers: { 'content-type': 'content type headers' } }); - const res = createResponse({ req }); - clientErrorLogger(req, res); - expect(res.statusCode).toBe(415); - expect(res.finished).toBe(true); - }); - - it('should send 200 when using application/json as content-type', () => { - load(); - const req = createRequest({ - headers: { - 'content-type': 'application/json', - 'correlation-id': '123', - }, - // expect the middleware to parse the JSON - body: {}, - }); - const res = createResponse({ req }); - - console.error.mockClear(); - clientErrorLogger(req, res); - expect(res.statusCode).toBe(204); - expect(res.finished).toBe(true); - }); - - it('should log the errors and send 200 when using application/json as content-type', () => { - load(); - const req = createRequest({ - // expect the middleware to parse the JSON - body: [{ - msg: 'something broke', - stack: 'Error: something broke\n at methodA (resource-a.js:1:1)\n at methodB (resource-b.js:1:1)\n', - href: 'https://example.com/page-the/error/occurred-on', - otherData: { - moduleID: 'dynamic-layout', - code: '500', - collectionMethod: 'applicationError', - }, - }], - headers: { - 'content-type': 'application/json', - 'correlation-id': '123abc', - 'user-agent': 'Browser/9.0 (Computer; Hardware Software 19_4_0) PackMule/537.36 (UHTML, like Salamander) Browser/99.2.5.0 Browser/753.36', - }, - }); - const res = createResponse({ req }); - - console.error.mockClear(); - clientErrorLogger(req, res); - expect(console.error).toHaveBeenCalledTimes(1); - const logged = console.error.mock.calls[0][0]; - expect(logged).toBeInstanceOf(Error); - expect(logged).toHaveProperty('name', 'ClientReportedError'); - expect(logged).toHaveProperty('stack', 'Error: something broke\n at methodA (resource-a.js:1:1)\n at methodB (resource-b.js:1:1)\n'); - expect(logged).toHaveProperty('userAgent', 'Browser/9.0 (Computer; Hardware Software 19_4_0) PackMule/537.36 (UHTML, like Salamander) Browser/99.2.5.0 Browser/753.36'); - expect(logged).toHaveProperty('uri', 'https://example.com/page-the/error/occurred-on'); - expect(logged).toHaveProperty('metaData'); - expect(logged.metaData).toEqual({ - moduleID: 'dynamic-layout', - code: '500', - collectionMethod: 'applicationError', - correlationId: '123abc', - }); - expect(res.statusCode).toBe(204); - expect(res.finished).toBe(true); - }); - - it('should not log the report when not given an array', () => { - load(); - const req = createRequest({ - // expect the middleware to parse the JSON - body: 'oh, hello', - headers: { - 'content-type': 'application/json', - 'correlation-id': '123', - }, - }); - const res = createResponse({ req }); - - console.error.mockClear(); - clientErrorLogger(req, res); - expect(console.error).not.toHaveBeenCalled(); - expect(res.statusCode).toBe(204); - expect(res.finished).toBe(true); - }); - - it('should log a warning when not given an array', () => { - load(); - const req = createRequest({ - // expect the middleware to parse the JSON - body: 'oh, hello', - headers: { - 'content-type': 'application/json', - 'correlation-id': '123', - }, - }); - const res = createResponse({ req }); - - console.warn.mockClear(); - clientErrorLogger(req, res); - expect(console.warn).toHaveBeenCalledTimes(1); - expect(console.warn.mock.calls[0]).toMatchSnapshot(); - }); - - it('should not log the report when given an array of not objects', () => { - load(); - const req = createRequest({ - // expect the middleware to parse the JSON - body: [null, 'oh, hello', 12], - headers: { - 'content-type': 'application/json', - 'correlation-id': '123', - }, - }); - const res = createResponse({ req }); - - console.error.mockClear(); - clientErrorLogger(req, res); - expect(console.error).not.toHaveBeenCalled(); - expect(res.statusCode).toBe(204); - expect(res.finished).toBe(true); - }); - - it('should log a warning when given an array of not objects', () => { - load(); - const req = createRequest({ - // expect the middleware to parse the JSON - body: ['oh, hello'], - headers: { - 'content-type': 'application/json', - 'correlation-id': '123', - }, - }); - const res = createResponse({ req }); - - console.warn.mockClear(); - clientErrorLogger(req, res); - expect(console.warn).toHaveBeenCalledTimes(1); - expect(console.warn.mock.calls[0]).toMatchSnapshot(); - }); - }); -}); diff --git a/__tests__/server/middleware/conditionallyAllowCors.spec.js b/__tests__/server/middleware/conditionallyAllowCors.spec.js deleted file mode 100644 index 89218ad93..000000000 --- a/__tests__/server/middleware/conditionallyAllowCors.spec.js +++ /dev/null @@ -1,85 +0,0 @@ -/* - * Copyright 2019 American Express Travel Related Services Company, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express - * or implied. See the License for the specific language governing - * permissions and limitations under the License. - */ - -// Dangling underscores are part of the HTTP mocks API -/* eslint-disable no-underscore-dangle */ -import httpMocks from 'node-mocks-http'; -import { fromJS } from 'immutable'; - -describe('conditionallyAllowCors', () => { - const { NODE_ENV } = process.env; - let conditionallyAllowCors; - let setCorsOrigins; - let req; - let res; - const next = jest.fn(); - let state = fromJS({ rendering: {} }); - - const setup = ({ renderPartialOnly, origin }) => { - conditionallyAllowCors = require('../../../src/server/middleware/conditionallyAllowCors').default; - setCorsOrigins = require('../../../src/server/middleware/conditionallyAllowCors').setCorsOrigins; - state = state.update('rendering', (rendering) => rendering.set('renderPartialOnly', renderPartialOnly)); - req = httpMocks.createRequest({ - headers: { - Origin: origin, - }, - }); - req.store = { getState: () => state }; - res = httpMocks.createResponse({ req }); - }; - - beforeEach(() => { - jest.clearAllMocks(); - jest.resetModules(); - }); - afterAll(() => { process.env.NODE_ENV = NODE_ENV; }); - - it('allows CORS for HTML partials', () => { - setup({ renderPartialOnly: true, origin: 'test.example.com' }); - setCorsOrigins([/\.example.com$/]); - conditionallyAllowCors(req, res, next); - expect(next).toHaveBeenCalled(); - const headers = res._getHeaders(); - expect(headers).toHaveProperty('access-control-allow-origin'); - }); - - it('allows CORS for localhost in development', () => { - process.env.NODE_ENV = 'development'; - setup({ renderPartialOnly: true, origin: 'localhost:8000' }); - setCorsOrigins([/\.example.com$/]); - conditionallyAllowCors(req, res, next); - expect(next).toHaveBeenCalled(); - const headers = res._getHeaders(); - expect(headers).toHaveProperty('access-control-allow-origin'); - }); - - it('does not allow CORS for localhost in production', () => { - process.env.NODE_ENV = 'production'; - setup({ renderPartialOnly: true, origin: 'localhost:8000' }); - conditionallyAllowCors(req, res, next); - expect(next).toHaveBeenCalled(); - const headers = res._getHeaders(); - expect(headers).not.toHaveProperty('access-control-allow-origin'); - }); - - it('does not allow CORS non-partial requests', () => { - setup({ renderPartialOnly: false, origin: 'test.example.com' }); - conditionallyAllowCors(req, res, next); - expect(next).toHaveBeenCalled(); - const headers = res._getHeaders(); - expect(headers).not.toHaveProperty('access-control-allow-origin'); - }); -}); diff --git a/__tests__/server/middleware/createRequestHtmlFragment.spec.js b/__tests__/server/middleware/createRequestHtmlFragment.spec.js deleted file mode 100644 index 453302d57..000000000 --- a/__tests__/server/middleware/createRequestHtmlFragment.spec.js +++ /dev/null @@ -1,356 +0,0 @@ -/* - * Copyright 2019 American Express Travel Related Services Company, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express - * or implied. See the License for the specific language governing - * permissions and limitations under the License. - */ - -import url from 'url'; -import { browserHistory, matchPromise } from '@americanexpress/one-app-router'; -import { Map as iMap, fromJS } from 'immutable'; -import { composeModules } from 'holocron'; -// getBreaker is only added in the mock -/* eslint-disable-next-line import/named */ -import { getBreaker } from '../../../src/server/utils/createCircuitBreaker'; - -import * as reactRendering from '../../../src/server/utils/reactRendering'; - -jest.mock('@americanexpress/one-app-router', () => { - const reactRouter = jest.requireActual('@americanexpress/one-app-router'); - jest.spyOn(reactRouter, 'matchPromise'); - return reactRouter; -}); - -jest.mock('holocron', () => ({ - composeModules: jest.fn(() => 'composeModules'), - getModule: () => () => 0, -})); - -jest.mock('../../../src/server/utils/createCircuitBreaker', () => { - const breaker = jest.fn(); - const mockCreateCircuitBreaker = (asyncFunctionThatMightFail) => { - breaker.fire = jest.fn((...args) => { - asyncFunctionThatMightFail(...args); - return false; - }); - return breaker; - }; - mockCreateCircuitBreaker.getBreaker = () => breaker; - return mockCreateCircuitBreaker; -}); - -const renderForStringSpy = jest.spyOn(reactRendering, 'renderForString'); -const renderForStaticMarkupSpy = jest.spyOn(reactRendering, 'renderForStaticMarkup'); - -describe('createRequestHtmlFragment', () => { - jest.spyOn(console, 'error').mockImplementation(() => {}); - - let req; - let res; - let next; - let createRoutes; - const dispatch = jest.fn((x) => x); - const getState = jest.fn(() => fromJS({ - rendering: iMap({}), - })); - - beforeAll(() => { - matchPromise.mockImplementation(({ routes, location }) => Promise.resolve({ - redirectLocation: undefined, - renderProps: { - routes, - components: [() => 'hi'], - // history: browserHistory, - location: url.parse(location), - router: browserHistory, - params: { - hi: 'there?', - }, - }, - })); - }); - - beforeEach(() => { - jest.clearAllMocks(); - req = jest.fn(); - req.headers = {}; - - res = jest.fn(); - res.status = jest.fn(() => res); - res.sendStatus = jest.fn(() => res); - res.redirect = jest.fn(() => res); - res.end = jest.fn(() => res); - req.url = 'http://example.com/request'; - req.store = { dispatch, getState }; - - next = jest.fn(); - - createRoutes = jest.fn(() => [{ path: '/', moduleName: 'root' }]); - - renderForStringSpy.mockClear(); - renderForStaticMarkupSpy.mockClear(); - }); - - it('returns a function', () => { - const createRequestHtmlFragment = require( - '../../../src/server/middleware/createRequestHtmlFragment' - ).default; - const middleware = createRequestHtmlFragment({ createRoutes }); - expect(middleware).toBeInstanceOf(Function); - }); - - it('should preload data for matched route components', () => { - expect.assertions(4); - const createRequestHtmlFragment = require( - '../../../src/server/middleware/createRequestHtmlFragment' - ).default; - const middleware = createRequestHtmlFragment({ createRoutes }); - return middleware(req, res, next) - .then(() => { - expect(getBreaker().fire).toHaveBeenCalled(); - expect(composeModules).toHaveBeenCalled(); - expect(composeModules.mock.calls[0][0]).toMatchSnapshot(); - expect(dispatch).toHaveBeenCalledWith('composeModules'); - }); - }); - - it('should add app HTML to the request object', () => { - expect.assertions(5); - const createRequestHtmlFragment = require( - '../../../src/server/middleware/createRequestHtmlFragment' - ).default; - const middleware = createRequestHtmlFragment({ createRoutes }); - return middleware(req, res, next) - .then(() => { - expect(next).toHaveBeenCalled(); - expect(createRoutes).toHaveBeenCalledWith(req.store); - expect(renderForStringSpy).toHaveBeenCalled(); - expect(renderForStaticMarkupSpy).not.toHaveBeenCalled(); - expect(typeof req.appHtml).toBe('string'); - }); - }); - - it('should add app HTML as static markup to the request object when scripts are disabled', () => { - expect.assertions(5); - const createRequestHtmlFragment = require( - '../../../src/server/middleware/createRequestHtmlFragment' - ).default; - const middleware = createRequestHtmlFragment({ createRoutes }); - getState.mockImplementationOnce(() => fromJS({ - rendering: iMap({ disableScripts: true }), - })); - return middleware(req, res, next) - .then(() => { - expect(next).toHaveBeenCalled(); - expect(createRoutes).toHaveBeenCalledWith(req.store); - expect(renderForStringSpy).not.toHaveBeenCalled(); - expect(renderForStaticMarkupSpy).toHaveBeenCalled(); - expect(typeof req.appHtml).toBe('string'); - }); - }); - - it('should set the custom HTTP status', () => { - expect.assertions(4); - const createRequestHtmlFragment = require( - '../../../src/server/middleware/createRequestHtmlFragment' - ).default; - - createRoutes = jest.fn(() => [{ path: '/', httpStatus: 400 }]); - const middleware = createRequestHtmlFragment({ createRoutes }); - return middleware(req, res, next) - .then(() => { - expect(next).toHaveBeenCalled(); - expect(createRoutes).toHaveBeenCalledWith(req.store); - expect(res.status).toHaveBeenCalledWith(400); - expect(typeof req.appHtml).toBe('string'); - }); - }); - - it('does not generate HTML when no route is matched', () => { - expect.assertions(5); - const createRequestHtmlFragment = require( - '../../../src/server/middleware/createRequestHtmlFragment' - ).default; - - matchPromise.mockImplementationOnce(() => ({ - redirectLocation: undefined, - // omit renderProps - })); - - const middleware = createRequestHtmlFragment({ createRoutes }); - // eslint-disable-next-line no-console - console.error = jest.fn(); - return middleware(req, res, next) - .then(() => { - // eslint-disable-next-line no-console - expect(console.error).toHaveBeenCalled(); - expect(next).toHaveBeenCalled(); - expect(res.sendStatus).toHaveBeenCalledWith(404); - expect(createRoutes).toHaveBeenCalledWith(req.store); - expect(req.appHtml).toBe(undefined); - }); - }); - - it('redirects when a relative redirect route is matched', () => { - expect.assertions(3); - const createRequestHtmlFragment = require( - '../../../src/server/middleware/createRequestHtmlFragment' - ).default; - - matchPromise.mockImplementationOnce(() => ({ - redirectLocation: { - pathname: '/redirect', - search: '', - }, - })); - - const middleware = createRequestHtmlFragment({ createRoutes }); - return middleware(req, res, next) - .then(() => { - expect(createRoutes).toHaveBeenCalledWith(req.store); - expect(res.redirect).toHaveBeenCalledWith(302, '/redirect'); - expect(req.appHtml).toBe(undefined); - }); - }); - - it('redirects when an absolute redirect route is matched', () => { - expect.assertions(3); - const createRequestHtmlFragment = require( - '../../../src/server/middleware/createRequestHtmlFragment' - ).default; - - matchPromise.mockImplementationOnce(() => ({ - redirectLocation: { - state: url.parse('https://example.com/redirect'), - }, - })); - - const middleware = createRequestHtmlFragment({ createRoutes }); - return middleware(req, res, next) - .then(() => { - expect(createRoutes).toHaveBeenCalledWith(req.store); - expect(res.redirect).toHaveBeenCalledWith(302, 'https://example.com/redirect'); - expect(req.appHtml).toBe(undefined); - }); - }); - - it('should catch any errors and call the next middleware', () => { - expect.assertions(3); - const createRequestHtmlFragment = require( - '../../../src/server/middleware/createRequestHtmlFragment' - ).default; - - const createRoutesError = new Error('failed to create routes'); - const brokenCreateRoutes = () => { throw createRoutesError; }; - - const middleware = createRequestHtmlFragment({ createRoutes: brokenCreateRoutes }); - /* eslint-disable no-console */ - console.error = jest.fn(); - middleware(req, res, next); - expect(console.error).toHaveBeenCalled(); - expect(console.error.mock.calls[0]).toEqual(['error creating request HTML fragment for http://example.com/request', createRoutesError]); - expect(next).toHaveBeenCalled(); - /* eslint-enable no-console */ - }); - - it('should use a circuit breaker', async () => { - expect.assertions(6); - const createRequestHtmlFragment = require( - '../../../src/server/middleware/createRequestHtmlFragment' - ).default; - const middleware = createRequestHtmlFragment({ createRoutes }); - await middleware(req, res, next); - expect(next).toHaveBeenCalled(); - expect(getBreaker().fire).toHaveBeenCalled(); - expect(composeModules).toHaveBeenCalled(); - expect(renderForStringSpy).toHaveBeenCalled(); - expect(renderForStaticMarkupSpy).not.toHaveBeenCalled(); - expect(req.appHtml).toBe('hi'); - }); - - it('should fall back when the circuit opens', async () => { - expect.assertions(5); - const createRequestHtmlFragment = require( - '../../../src/server/middleware/createRequestHtmlFragment' - ).default; - const breaker = getBreaker(); - breaker.fire.mockReturnValueOnce(true); - const middleware = createRequestHtmlFragment({ createRoutes }); - await middleware(req, res, next); - expect(next).toHaveBeenCalled(); - expect(getBreaker().fire).toHaveBeenCalled(); - expect(renderForStringSpy).not.toHaveBeenCalled(); - expect(renderForStaticMarkupSpy).not.toHaveBeenCalled(); - expect(req.appHtml).toBe(''); - }); - - it('should not use the circuit breaker for partials', async () => { - expect.assertions(6); - const createRequestHtmlFragment = require( - '../../../src/server/middleware/createRequestHtmlFragment' - ).default; - const middleware = createRequestHtmlFragment({ createRoutes }); - getState.mockImplementationOnce(() => fromJS({ - rendering: { - renderPartialOnly: true, - }, - })); - await middleware(req, res, next); - expect(next).toHaveBeenCalled(); - expect(getBreaker().fire).not.toHaveBeenCalled(); - expect(composeModules).toHaveBeenCalled(); - expect(renderForStringSpy).not.toHaveBeenCalled(); - expect(renderForStaticMarkupSpy).toHaveBeenCalled(); - expect(req.appHtml).toBe('hi'); - }); - - it('should not use the circuit breaker when scripts are disabled', async () => { - expect.assertions(6); - const createRequestHtmlFragment = require( - '../../../src/server/middleware/createRequestHtmlFragment' - ).default; - const middleware = createRequestHtmlFragment({ createRoutes }); - getState.mockImplementationOnce(() => fromJS({ - rendering: { - disableScripts: true, - }, - })); - await middleware(req, res, next); - expect(next).toHaveBeenCalled(); - expect(getBreaker().fire).not.toHaveBeenCalled(); - expect(composeModules).toHaveBeenCalled(); - expect(renderForStringSpy).not.toHaveBeenCalled(); - expect(renderForStaticMarkupSpy).toHaveBeenCalled(); - expect(req.appHtml).toBe('hi'); - }); - - it('should not use the circuit breaker when rendering text only', async () => { - expect.assertions(6); - const createRequestHtmlFragment = require( - '../../../src/server/middleware/createRequestHtmlFragment' - ).default; - const middleware = createRequestHtmlFragment({ createRoutes }); - getState.mockImplementationOnce(() => fromJS({ - rendering: { - renderTextOnly: true, - renderTextOnlyOptions: { htmlTagReplacement: '', allowedHtmlTags: [] }, - }, - })); - await middleware(req, res, next); - expect(next).toHaveBeenCalled(); - expect(getBreaker().fire).not.toHaveBeenCalled(); - expect(composeModules).toHaveBeenCalled(); - expect(renderForStringSpy).not.toHaveBeenCalled(); - expect(renderForStaticMarkupSpy).toHaveBeenCalled(); - expect(req.appHtml).toBe('hi'); - }); -}); diff --git a/__tests__/server/middleware/csp.spec.js b/__tests__/server/middleware/csp.spec.js deleted file mode 100644 index 666a69ddf..000000000 --- a/__tests__/server/middleware/csp.spec.js +++ /dev/null @@ -1,184 +0,0 @@ -/* -* Copyright 2019 American Express Travel Related Services Company, Inc. -* -* Licensed under the Apache License, Version 2.0 (the "License"); -* you may not use this file except in compliance with the License. -* You may obtain a copy of the License at -* -* http://www.apache.org/licenses/LICENSE-2.0 -* -* Unless required by applicable law or agreed to in writing, software -* distributed under the License is distributed on an "AS IS" BASIS, -* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express -* or implied. See the License for the specific language governing -* permissions and limitations under the License. -*/ -/* eslint-disable global-require */ - -import httpMocks from 'node-mocks-http'; - -const sanitizeCspString = (cspString) => cspString -// replaces dynamic ip to prevent snapshot failures - // eslint-disable-next-line unicorn/better-regex -- conflicts with unsafe-regex - .replace(/\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}/g, '0.0.0.0'); - -describe('csp', () => { - jest.spyOn(console, 'error').mockImplementation(() => {}); - - function requireCSP() { - return require('../../../src/server/middleware/csp'); - } - - beforeEach(() => { - jest.resetModules(); - process.env.ONE_DANGEROUSLY_DISABLE_CSP = 'false'; - }); - - describe('middleware', () => { - const req = () => 0; - let res; - const next = jest.fn(); - - beforeEach(() => { - res = httpMocks.createResponse(); - next.mockClear(); - }); - - it('uses the same csp enforcement in development as production to reduce surprises', () => { - process.env.NODE_ENV = 'development'; - const cspMiddleware = requireCSP().default; - cspMiddleware()(req, res, next); - // eslint-disable-next-line no-underscore-dangle - const headers = res._getHeaders(); - expect(headers).toHaveProperty('content-security-policy'); - expect(headers).not.toHaveProperty('content-security-policy-report-only'); - }); - - it('sets a csp header in development', () => { - process.env.NODE_ENV = 'development'; - const cspMiddleware = requireCSP().default; - cspMiddleware()(req, res, next); - // eslint-disable-next-line no-underscore-dangle - const headers = res._getHeaders(); - expect(headers).toHaveProperty('content-security-policy'); - expect(headers).not.toHaveProperty('content-security-policy-report-only'); - }); - - it('does not set csp header if ONE_DANGEROUSLY_DISABLE_CSP is present', () => { - process.env.ONE_DANGEROUSLY_DISABLE_CSP = 'true'; - const cspMiddleware = requireCSP().default; - cspMiddleware()(req, res, next); - // eslint-disable-next-line no-underscore-dangle - const headers = res._getHeaders(); - expect(headers).not.toHaveProperty('Content-Security-Policy'); - }); - - it('defaults to production csp', () => { - delete process.env.NODE_ENV; - const cspMiddleware = requireCSP().default; - cspMiddleware()(req, res, next); - // eslint-disable-next-line no-underscore-dangle - const headers = res._getHeaders(); - expect(headers).toHaveProperty('content-security-policy'); - expect(headers).not.toHaveProperty('content-security-policy-report-only'); - }); - - it('adds ip and localhost to csp in development', () => { - process.env.NODE_ENV = 'development'; - process.env.HTTP_ONE_APP_DEV_CDN_PORT = 5000; - process.env.HTTP_ONE_APP_DEV_PROXY_SERVER_PORT = 3001; - const requiredCsp = requireCSP(); - const cspMiddleware = requiredCsp.default; - const { updateCSP } = requiredCsp; - updateCSP("default-src 'none'; script-src 'self'; connect-src 'self';"); - cspMiddleware()(req, res, next); - // eslint-disable-next-line no-underscore-dangle - const headers = res._getHeaders(); - expect(headers).toHaveProperty('content-security-policy'); - const cspString = headers['content-security-policy']; - expect(sanitizeCspString(cspString)).toMatchSnapshot(); - }); - - it('does not add ip and localhost to csp in production', () => { - process.env.NODE_ENV = 'production'; - delete process.env.HTTP_ONE_APP_DEV_CDN_PORT; - delete process.env.HTTP_ONE_APP_DEV_PROXY_SERVER_PORT; - const requiredCsp = requireCSP(); - const cspMiddleware = requiredCsp.default; - const { updateCSP } = requiredCsp; - updateCSP("default-src 'none'; script-src 'self'; connect-src 'self';"); - cspMiddleware()(req, res, next); - // eslint-disable-next-line no-underscore-dangle - const headers = res._getHeaders(); - expect(headers).toHaveProperty('content-security-policy'); - const cspString = headers['content-security-policy']; - // eslint-disable-next-line unicorn/better-regex - const ipFound = cspString.match(/\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}/); - expect(ipFound).toBeNull(); - const localhostFound = cspString.match(/localhost/); - expect(localhostFound).toBeNull(); - }); - - it('sets script nonce', () => { - const requiredCsp = requireCSP(); - const cspMiddleware = requiredCsp.default; - const { updateCSP } = requiredCsp; - updateCSP("default-src 'none'; script-src 'self';"); - cspMiddleware()(req, res, next); - // eslint-disable-next-line no-underscore-dangle - const headers = res._getHeaders(); - const { scriptNonce } = res; - expect(headers).toHaveProperty('content-security-policy'); - expect(headers['content-security-policy'].includes(scriptNonce)).toBe(true); - }); - - it('does not set the script nonce if this has been disabled in development', () => { - process.env.NODE_ENV = 'development'; - process.env.ONE_CSP_ALLOW_INLINE_SCRIPTS = 'true'; - - const requiredCsp = requireCSP(); - const cspMiddleware = requiredCsp.default; - const { updateCSP } = requiredCsp; - updateCSP("default-src 'none'; script-src 'self';"); - cspMiddleware()(req, res, next); - expect(res.scriptNonce).toBeUndefined(); - }); - }); - - describe('policy', () => { - it('should be a constant string and not function so it cannot be regenerated per request', () => { - const { cspCache: { policy } } = requireCSP(); - expect(policy).toEqual(expect.any(String)); - }); - }); - - describe('updateCSP', () => { - it('should accept an empty string', () => { - const { updateCSP, getCSP } = requireCSP(); - updateCSP(''); - expect(getCSP()).toEqual({}); - }); - - it('updates cspCache with given csp', () => { - const { updateCSP, cspCache } = requireCSP(); - const originalPolicy = cspCache.policy; - updateCSP("default-src 'self';"); - const { policy } = require('../../../src/server/middleware/csp').cspCache; - - expect(console.error).not.toHaveBeenCalled(); - expect(policy).not.toEqual(originalPolicy); - expect(policy).toMatchSnapshot(); - }); - }); - - describe('getCSP', () => { - it('returns parsed CSP', () => { - const { updateCSP, getCSP } = requireCSP(); - updateCSP("default-src 'none' 'self'; block-all-mixed-content"); - expect(getCSP()).toEqual({ - 'default-src': ["'none'", "'self'"], - 'block-all-mixed-content': true, - }); - }); - }); -}); diff --git a/__tests__/server/middleware/cspViolation.spec.js b/__tests__/server/middleware/cspViolation.spec.js deleted file mode 100644 index a5455bbb0..000000000 --- a/__tests__/server/middleware/cspViolation.spec.js +++ /dev/null @@ -1,133 +0,0 @@ -/* - * Copyright 2019 American Express Travel Related Services Company, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express - * or implied. See the License for the specific language governing - * permissions and limitations under the License. - */ - -jest.mock('yargs', () => ({ argv: {} })); - -describe('cspViolation', () => { - const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); - const end = jest.fn(); - const res = { status: jest.fn(() => ({ end })) }; - - function load(development) { - jest.resetModules(); - - if (development) { - process.env.NODE_ENV = 'development'; - } else { - process.env.NODE_ENV = 'production'; - } - - return require('../../../src/server/middleware/cspViolation').default; - } - - afterEach(() => { - jest.clearAllMocks(); - }); - describe('development', () => { - it('should respond with a 204 No Content', () => { - const cspViolation = load(true); - cspViolation({ data: 'hello' }, res); - expect(res.status).toHaveBeenCalledWith(204); - }); - - it('should end the request', () => { - const cspViolation = load(true); - cspViolation({ body: 'hello' }, res); - expect(end).toHaveBeenCalled(); - }); - - it('should log the request body', () => { - const sampleViolationReport = { - 'csp-report': { - 'document-uri': 'http://localhost:3000/start', - referrer: '', - 'violated-directive': 'script-src', - 'effective-directive': 'script-src', - 'original-policy': "default-src 'self'", - disposition: 'enforce', - 'blocked-uri': 'eval', - 'line-number': 5, - 'column-number': 54, - 'source-file': 'http://analytics.example.com', - 'status-code': 200, - 'script-sample': '', - }, - }; - const cspViolation = load(true); - cspViolation({ body: sampleViolationReport }, res); - expect(consoleSpy).toHaveBeenCalledTimes(1); - expect(consoleSpy.mock.calls[0]).toMatchSnapshot(); - }); - - it('log message about missing request body when not provided in request', () => { - const cspViolation = load(true); - cspViolation({}, res); - const expected = 'CSP Violation reported, but no data received'; - expect(consoleSpy).toHaveBeenCalledWith(expected); - expect(consoleSpy).toHaveBeenCalledTimes(1); - expect(end).toHaveBeenCalled(); - }); - }); - - describe('production', () => { - it('should respond with a 204 No Content', () => { - const cspViolation = load(); - cspViolation({ body: 'hello' }, res); - expect(res.status).toHaveBeenCalledWith(204); - }); - - it('should end the request', () => { - const cspViolation = load(); - cspViolation({ body: 'hello' }, res); - expect(end).toHaveBeenCalled(); - }); - - it('should log the request body', () => { - const sampleViolationReport = { - 'csp-report': { - 'document-uri': 'http://localhost:3000/start', - referrer: '', - 'violated-directive': 'script-src', - 'effective-directive': 'script-src', - 'original-policy': "default-src 'self'", - disposition: 'enforce', - 'blocked-uri': 'eval', - 'line-number': 5, - 'column-number': 54, - 'source-file': 'http://analytics.example.com', - 'status-code': 200, - 'script-sample': '', - }, - }; - const cspViolation = load(); - const expectBody = JSON.stringify(sampleViolationReport, null, 2); - cspViolation({ body: sampleViolationReport }, res); - const expected = `CSP Violation: ${expectBody}`; - expect(consoleSpy).toHaveBeenCalledWith(expected); - expect(res.status).toHaveBeenCalledWith(204); - expect(end).toHaveBeenCalled(); - }); - - it('log message about missing request body when not provided in request', () => { - const cspViolation = load(); - cspViolation({}, res); - const expected = 'CSP Violation: No data received!'; - expect(consoleSpy).toHaveBeenCalledWith(expected); - expect(consoleSpy).toHaveBeenCalledTimes(1); - expect(end).toHaveBeenCalled(); - }); - }); -}); diff --git a/__tests__/server/middleware/ensureCorrelationId.spec.js b/__tests__/server/middleware/ensureCorrelationId.spec.js deleted file mode 100644 index a11ff0958..000000000 --- a/__tests__/server/middleware/ensureCorrelationId.spec.js +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Copyright 2019 American Express Travel Related Services Company, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express - * or implied. See the License for the specific language governing - * permissions and limitations under the License. - */ - -const ensureCorrelationId = require('../../../src/server/middleware/ensureCorrelationId').default; - -describe('ensureCorrelationId', () => { - function generateSamples() { - const req = { - headers: {}, - }; - const res = null; - const next = jest.fn(); - return { req, res, next }; - } - - it('uses correlation_id for correlation-id on a request without correlation-id', () => { - const { req, res, next } = generateSamples(); - delete req.headers['correlation-id']; - req.headers.correlation_id = 'cloudy'; - ensureCorrelationId(req, res, next); - expect(req.headers).toHaveProperty('correlation-id', 'cloudy'); - }); - - it('uses unique_id for correlation-id on a request without correlation-id or correlation_id', () => { - const { req, res, next } = generateSamples(); - delete req.headers['correlation-id']; - delete req.headers.correlation_id; - req.headers.unique_id = 'thunderstorms'; - ensureCorrelationId(req, res, next); - expect(req.headers).toHaveProperty('correlation-id', 'thunderstorms'); - }); - - it('adds a correlation-id header to a request without anything', () => { - const { req, res, next } = generateSamples(); - delete req.headers['correlation-id']; - ensureCorrelationId(req, res, next); - expect(req.headers).toHaveProperty('correlation-id'); - expect(typeof req.headers['correlation-id']).toBe('string'); - }); - - it('does not change an existing correlation-id header on a request', () => { - const { req, res, next } = generateSamples(); - req.headers['correlation-id'] = 'exists'; - ensureCorrelationId(req, res, next); - expect(req.headers).toHaveProperty('correlation-id', 'exists'); - }); - - it('calls the next middleware', () => { - const { req, res, next } = generateSamples(); - expect(next).not.toHaveBeenCalled(); - ensureCorrelationId(req, res, next); - expect(next).toHaveBeenCalledTimes(1); - }); -}); diff --git a/__tests__/server/middleware/pwa/offline.spec.jsx b/__tests__/server/middleware/pwa/offline.spec.jsx deleted file mode 100644 index 71df81649..000000000 --- a/__tests__/server/middleware/pwa/offline.spec.jsx +++ /dev/null @@ -1,122 +0,0 @@ -/** - * @jest-environment node - */ - -/* - * Copyright 2020 American Express Travel Related Services Company, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express - * or implied. See the License for the specific language governing - * permissions and limitations under the License. - */ - -import React from 'react'; -import { Helmet } from 'react-helmet'; -import { registerModule } from 'holocron'; -import { createRequest, createResponse } from 'node-mocks-http'; - -import oneApp from '../../../../src/universal'; -import { setStateConfig } from '../../../../src/server/utils/stateConfig'; -import sendHtml from '../../../../src/server/middleware/sendHtml'; -import { configurePWA } from '../../../../src/server/middleware/pwa/config'; - -import createOfflineMiddleware from '../../../../src/server/middleware/pwa/offline'; - -jest.mock('yargs', () => ({ argv: {} })); // we are importing yargs in stateConfig and needs to be mocked -jest.mock('../../../../src/server/middleware/sendHtml', () => jest.fn()); -jest.mock('fs', () => ({ - existsSync: () => false, - readFileSync: (filePath) => Buffer.from(filePath.endsWith('noop.js') ? '[service-worker-noop-script]' : '[service-worker-script]'), -})); - -describe('offline middleware', () => { - beforeAll(() => { - process.env.ONE_SERVICE_WORKER = true; - }); - - beforeEach(() => { - jest.clearAllMocks(); - - global.fetch = jest.fn(() => Promise.resolve({})); - - const rootModuleName = 'root-module'; - registerModule(rootModuleName, () => React.createElement('p', null, 'Hi there')); - setStateConfig({ - rootModuleName: { - server: rootModuleName, - client: rootModuleName, - }, - }); - configurePWA({ - serviceWorker: true, - scope: '/', - webManifest: { - name: 'One App Test', - }, - }); - }); - - test('does nothing if service worker is not enabled', async () => { - expect.assertions(3); - - const middleware = createOfflineMiddleware(oneApp); - const next = jest.fn(); - const req = createRequest(); - const res = createResponse(); - - configurePWA({ serviceWorker: false }); - - await expect(middleware(req, res, next)).resolves.toBeUndefined(); - expect(next).toHaveBeenCalledTimes(1); - expect(sendHtml).not.toHaveBeenCalled(); - }); - - test('calls the stack of middleware and finishes with "sendHtml" middleware', async () => { - expect.assertions(5); - - const middleware = createOfflineMiddleware(oneApp); - const next = jest.fn(); - const req = createRequest(); - const res = createResponse(); - - await expect(middleware(req, res, next)).resolves.toBeUndefined(); - expect(next).not.toHaveBeenCalled(); - expect(req.appHtml).toEqual('

Hi there

'); - expect(sendHtml).toHaveBeenCalledTimes(1); - expect(sendHtml).toHaveBeenCalledWith(req, res, next); - }); - - test('renders "appHtml", "helmetInfo" and sets the "renderMode"', async () => { - expect.assertions(5); - - const middleware = createOfflineMiddleware(oneApp); - const req = createRequest(); - const res = createResponse(); - registerModule('root-module', () => ( - - - - -

- hello -

-
- )); - - await expect(middleware(req, res)).resolves.toBeUndefined(); - expect(req.appHtml).toEqual('

hello

'); - expect(req.renderMode).toEqual('render'); - expect(req.helmetInfo).toBeDefined(); - expect(req.helmetInfo.link.toString()).toEqual( - '' - ); - }); -}); diff --git a/__tests__/server/middleware/pwa/service-worker.spec.js b/__tests__/server/middleware/pwa/service-worker.spec.js deleted file mode 100644 index e2efd014f..000000000 --- a/__tests__/server/middleware/pwa/service-worker.spec.js +++ /dev/null @@ -1,156 +0,0 @@ -/* - * Copyright 2020 American Express Travel Related Services Company, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express - * or implied. See the License for the specific language governing - * permissions and limitations under the License. - */ - -import serviceWorkerMiddleware from '../../../../src/server/middleware/pwa/service-worker'; -import { getServerPWAConfig } from '../../../../src/server/middleware/pwa/config'; -import { getClientModuleMapCache } from '../../../../src/server/utils/clientModuleMapCache'; - -jest.mock('../../../../src/server/middleware/pwa/config'); -jest.mock('../../../../src/server/utils/clientModuleMapCache'); - -const serviceWorkerStandardScript = '[service-worker-script]'; -const serviceWorkerRecoveryScript = '[service-worker-recovery-script]'; -const serviceWorkerEscapeHatchScript = '[service-worker-escape-hatch-script]'; -function createServiceWorkerConfig({ type, scope } = {}) { - let serviceWorker = false; - let serviceWorkerScope = null; - let serviceWorkerScript = null; - if (type === 'standard') serviceWorkerScript = serviceWorkerStandardScript; - else if (type === 'recovery') serviceWorkerScript = serviceWorkerRecoveryScript; - else if (type === 'escape-hatch') serviceWorkerScript = serviceWorkerEscapeHatchScript; - if (type) { - serviceWorker = true; - serviceWorkerScope = scope || '/'; - } - return { - serviceWorker, - serviceWorkerScope, - serviceWorkerScript, - }; -} - -beforeAll(() => { - getClientModuleMapCache.mockImplementation(() => ({ - browser: { modules: {} }, - })); -}); - -describe('service worker middleware', () => { - test('middleware factory returns function', () => { - expect(serviceWorkerMiddleware()).toBeInstanceOf(Function); - }); - - test('middleware calls next when disabled', () => { - getServerPWAConfig.mockImplementationOnce(() => createServiceWorkerConfig()); - - const middleware = serviceWorkerMiddleware(); - const next = jest.fn(); - expect(middleware(null, null, next)).toBeUndefined(); - expect(next).toHaveBeenCalledTimes(1); - }); - - test('middleware responds with service worker script', () => { - getServerPWAConfig.mockImplementationOnce(() => createServiceWorkerConfig({ type: 'standard' })); - - const middleware = serviceWorkerMiddleware(); - const next = jest.fn(); - const res = {}; - res.send = jest.fn(() => res); - res.set = jest.fn(() => res); - res.type = jest.fn(() => res); - - expect(middleware(null, res, next)).toBe(res); - - expect(res.send).toHaveBeenCalledTimes(1); - expect(res.type).toHaveBeenCalledTimes(1); - expect(res.set).toHaveBeenCalledTimes(2); - expect(next).not.toHaveBeenCalled(); - expect(res.type).toHaveBeenCalledWith('js'); - expect(res.set).toHaveBeenCalledWith('Service-Worker-Allowed', '/'); - expect(res.set).toHaveBeenCalledWith('Cache-Control', 'no-store, no-cache'); - expect(res.send).toHaveBeenCalledWith(Buffer.from(serviceWorkerStandardScript)); - }); - - test('middleware responds with service worker noop script', () => { - getServerPWAConfig.mockImplementationOnce(() => createServiceWorkerConfig({ type: 'recovery' })); - - const middleware = serviceWorkerMiddleware(); - const next = jest.fn(); - const res = {}; - res.send = jest.fn(() => res); - res.set = jest.fn(() => res); - res.type = jest.fn(() => res); - - expect(middleware(null, res, next)).toBe(res); - - expect(res.send).toHaveBeenCalledTimes(1); - expect(res.type).toHaveBeenCalledTimes(1); - expect(res.set).toHaveBeenCalledTimes(2); - expect(next).not.toHaveBeenCalled(); - expect(res.type).toHaveBeenCalledWith('js'); - expect(res.set).toHaveBeenCalledWith('Service-Worker-Allowed', '/'); - expect(res.set).toHaveBeenCalledWith('Cache-Control', 'no-store, no-cache'); - expect(res.send).toHaveBeenCalledWith(Buffer.from(serviceWorkerRecoveryScript)); - }); - - test('middleware responds with service worker escape hatch script', () => { - getServerPWAConfig.mockImplementationOnce(() => createServiceWorkerConfig({ type: 'escape-hatch' })); - - const middleware = serviceWorkerMiddleware(); - const next = jest.fn(); - const res = {}; - res.send = jest.fn(() => res); - res.set = jest.fn(() => res); - res.type = jest.fn(() => res); - - expect(middleware(null, res, next)).toBe(res); - - expect(res.send).toHaveBeenCalledTimes(1); - expect(res.type).toHaveBeenCalledTimes(1); - expect(res.set).toHaveBeenCalledTimes(2); - expect(next).not.toHaveBeenCalled(); - expect(res.type).toHaveBeenCalledWith('js'); - expect(res.set).toHaveBeenCalledWith('Service-Worker-Allowed', '/'); - expect(res.set).toHaveBeenCalledWith('Cache-Control', 'no-store, no-cache'); - expect(res.send).toHaveBeenCalledWith(Buffer.from(serviceWorkerEscapeHatchScript)); - }); - - test('replaces HOLOCRON_MODULE_MAP in service worker script', () => { - getServerPWAConfig.mockImplementationOnce(() => { - const config = createServiceWorkerConfig({ type: 'standard' }); - config.serviceWorkerScript = 'process.env.HOLOCRON_MODULE_MAP'; - return config; - }); - - const middleware = serviceWorkerMiddleware(); - const next = jest.fn(); - const res = {}; - res.send = jest.fn(() => res); - res.set = jest.fn(() => res); - res.type = jest.fn(() => res); - - expect(middleware(null, res, next)).toBe(res); - - expect(res.send).toHaveBeenCalledTimes(1); - expect(res.type).toHaveBeenCalledTimes(1); - expect(res.set).toHaveBeenCalledTimes(2); - expect(next).not.toHaveBeenCalled(); - expect(res.type).toHaveBeenCalledWith('js'); - expect(res.set).toHaveBeenCalledWith('Service-Worker-Allowed', '/'); - expect(res.set).toHaveBeenCalledWith('Cache-Control', 'no-store, no-cache'); - expect(res.send).toHaveBeenCalledWith(Buffer.from(`'${JSON.stringify(getClientModuleMapCache().browser)}'`)); - }); -}); diff --git a/__tests__/server/middleware/serverError.spec.js b/__tests__/server/middleware/serverError.spec.js deleted file mode 100644 index 2ed00f04a..000000000 --- a/__tests__/server/middleware/serverError.spec.js +++ /dev/null @@ -1,45 +0,0 @@ -/* - * Copyright 2020 American Express Travel Related Services Company, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express - * or implied. See the License for the specific language governing - * permissions and limitations under the License. - */ - -import serverError from '../../../src/server/middleware/serverError'; - -// existing coverage in ssrServer.spec.js - -jest.spyOn(console, 'error').mockImplementation(() => {}); - -describe('serverError', () => { - it('handles req with no headers however unlikely', () => { - const reqWithNoHeaders = {}; - const res = { status: jest.fn(), send: jest.fn() }; - const next = jest.fn(); - - const callServerError = () => serverError('some error', reqWithNoHeaders, res, next); - expect(callServerError).not.toThrowError(); - }); - - it('does not handle error', () => { - const error = new Error('Testing'); - const req = {}; - const res = { - headersSent: true, - }; - const next = jest.fn(); - - serverError(error, req, res, next); - - expect(next).toHaveBeenCalledWith(error); - }); -}); diff --git a/__tests__/server/plugins/__snapshots__/csp.spec.js.snap b/__tests__/server/plugins/__snapshots__/csp.spec.js.snap new file mode 100644 index 000000000..56d576c91 --- /dev/null +++ b/__tests__/server/plugins/__snapshots__/csp.spec.js.snap @@ -0,0 +1,5 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`csp adds ip and localhost to csp in development 1`] = `"default-src 'none'; script-src 'nonce-00000000-0000-0000-0000-000000000000' 0.0.0.0:* localhost:* ws://localhost:* 'self'; connect-src 0.0.0.0:* localhost:* ws://localhost:* 'self';"`; + +exports[`csp updateCSP updates cspCache with given csp 1`] = `"default-src 'self';"`; diff --git a/__tests__/server/plugins/addCacheHeaders.spec.js b/__tests__/server/plugins/addCacheHeaders.spec.js new file mode 100644 index 000000000..38d0ba37b --- /dev/null +++ b/__tests__/server/plugins/addCacheHeaders.spec.js @@ -0,0 +1,63 @@ +/* + * Copyright 2019 American Express Travel Related Services Company, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +import addCacheHeaders from '../../../src/server/plugins/addCacheHeaders'; + +describe('addCacheHeaders', () => { + it('adds cache headers', () => { + const request = { + method: 'get', + }; + const reply = { + header: jest.fn(), + }; + const fastify = { + addHook: jest.fn(async (_hook, cb) => { + await cb(request, reply); + }), + }; + const done = jest.fn(); + + addCacheHeaders(fastify, null, done); + + expect(fastify.addHook).toHaveBeenCalled(); + expect(done).toHaveBeenCalled(); + expect(reply.header).toHaveBeenCalledTimes(2); + expect(reply.header).toHaveBeenCalledWith('Cache-Control', 'no-store'); + expect(reply.header).toHaveBeenCalledWith('Pragma', 'no-cache'); + }); + + it('does not add cache headers', () => { + const request = { + method: 'post', + }; + const reply = { + header: jest.fn(), + }; + const fastify = { + addHook: jest.fn(async (_hook, cb) => { + await cb(request, reply); + }), + }; + const done = jest.fn(); + + addCacheHeaders(fastify, null, done); + + expect(fastify.addHook).toHaveBeenCalled(); + expect(done).toHaveBeenCalled(); + expect(reply.header).not.toHaveBeenCalled(); + }); +}); diff --git a/__tests__/server/plugins/addFrameOptionsHeader.spec.js b/__tests__/server/plugins/addFrameOptionsHeader.spec.js new file mode 100644 index 000000000..0497edc63 --- /dev/null +++ b/__tests__/server/plugins/addFrameOptionsHeader.spec.js @@ -0,0 +1,94 @@ +/* + * Copyright 2019 American Express Travel Related Services Company, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +import addFrameOptionsHeader from '../../../src/server/plugins/addFrameOptionsHeader'; +import { updateCSP } from '../../../src/server/plugins/csp'; + +beforeEach(() => { + updateCSP(''); +}); + +describe('empty frame-ancestors', () => { + it('does not add frame options header', () => { + const request = { + headers: {}, + }; + const reply = { + header: jest.fn(), + }; + const fastify = { + addHook: jest.fn(async (_hook, cb) => { + await cb(request, reply); + }), + }; + const done = jest.fn(); + + addFrameOptionsHeader(fastify, null, done); + + expect(fastify.addHook).toHaveBeenCalled(); + expect(done).toHaveBeenCalled(); + expect(reply.header).not.toHaveBeenCalled(); + }); +}); + +describe('no matching domains', () => { + it('does not add frame options header', () => { + updateCSP('frame-ancestors americanexpress.com;'); + const request = { + headers: {}, + }; + const reply = { + header: jest.fn(), + }; + const fastify = { + addHook: jest.fn(async (_hook, cb) => { + await cb(request, reply); + }), + }; + const done = jest.fn(); + + addFrameOptionsHeader(fastify, null, done); + + expect(fastify.addHook).toHaveBeenCalled(); + expect(done).toHaveBeenCalled(); + expect(reply.header).not.toHaveBeenCalled(); + }); +}); + +it('adds frame options header', () => { + updateCSP('frame-ancestors americanexpress.com;'); + const request = { + headers: { + Referer: 'https://americanexpress.com/testing', + }, + }; + const reply = { + header: jest.fn(), + }; + const fastify = { + addHook: jest.fn(async (_hook, cb) => { + await cb(request, reply); + }), + }; + const done = jest.fn(); + + addFrameOptionsHeader(fastify, null, done); + + expect(fastify.addHook).toHaveBeenCalled(); + expect(done).toHaveBeenCalled(); + expect(reply.header).toHaveBeenCalledTimes(1); + expect(reply.header).toHaveBeenCalledWith('X-Frame-Options', 'ALLOW-FROM https://americanexpress.com/testing'); +}); diff --git a/__tests__/server/plugins/addSecurityHeaders.spec.js b/__tests__/server/plugins/addSecurityHeaders.spec.js new file mode 100644 index 000000000..e99d306a3 --- /dev/null +++ b/__tests__/server/plugins/addSecurityHeaders.spec.js @@ -0,0 +1,155 @@ +/* + * Copyright 2019 American Express Travel Related Services Company, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +import addSecurityHeaders from '../../../src/server/plugins/addSecurityHeaders'; + +describe('addSecurityHeaders', () => { + beforeEach(() => { + delete process.env.ONE_REFERRER_POLICY_OVERRIDE; + }); + + it('adds security headers', () => { + const request = { + headers: {}, + method: 'GET', + }; + const reply = { + header: jest.fn(), + }; + const fastify = { + addHook: jest.fn(async (_hook, cb) => { + await cb(request, reply); + }), + }; + const done = jest.fn(); + + addSecurityHeaders(fastify, {}, done); + + expect(fastify.addHook).toHaveBeenCalled(); + expect(done).toHaveBeenCalled(); + expect(reply.header).toHaveBeenCalledTimes(9); + expect(reply.header).toHaveBeenCalledWith('vary', 'Accept-Encoding'); + expect(reply.header).toHaveBeenCalledWith('Strict-Transport-Security', 'max-age=15552000; includeSubDomains'); + expect(reply.header).toHaveBeenCalledWith('x-dns-prefetch-control', 'off'); + expect(reply.header).toHaveBeenCalledWith('x-download-options', 'noopen'); + expect(reply.header).toHaveBeenCalledWith('x-permitted-cross-domain-policies', 'none'); + expect(reply.header).toHaveBeenCalledWith('X-Content-Type-Options', 'nosniff'); + expect(reply.header).toHaveBeenCalledWith('X-Frame-Options', 'SAMEORIGIN'); + expect(reply.header).toHaveBeenCalledWith('X-XSS-Protection', '0'); + expect(reply.header).toHaveBeenCalledWith('Referrer-Policy', 'no-referrer'); + }); + + it('adds strict security headers to specific GET requests', () => { + const request = { + headers: {}, + method: 'GET', + url: '/testing', + }; + const reply = { + header: jest.fn(), + }; + const fastify = { + addHook: jest.fn(async (_hook, cb) => { + await cb(request, reply); + }), + }; + const done = jest.fn(); + + addSecurityHeaders(fastify, { + matchGetRoutes: [ + '/testing', + ], + }, done); + + expect(fastify.addHook).toHaveBeenCalled(); + expect(done).toHaveBeenCalled(); + expect(reply.header).toHaveBeenCalledTimes(9); + expect(reply.header).toHaveBeenCalledWith('vary', 'Accept-Encoding'); + expect(reply.header).toHaveBeenCalledWith('Strict-Transport-Security', 'max-age=15552000; includeSubDomains'); + expect(reply.header).toHaveBeenCalledWith('x-dns-prefetch-control', 'off'); + expect(reply.header).toHaveBeenCalledWith('x-download-options', 'noopen'); + expect(reply.header).toHaveBeenCalledWith('x-permitted-cross-domain-policies', 'none'); + expect(reply.header).toHaveBeenCalledWith('X-Content-Type-Options', 'nosniff'); + expect(reply.header).toHaveBeenCalledWith('X-Frame-Options', 'DENY'); + expect(reply.header).toHaveBeenCalledWith('X-XSS-Protection', '1; mode=block'); + expect(reply.header).toHaveBeenCalledWith('Referrer-Policy', 'same-origin'); + }); + + it('adds strict security headers to POST requests', () => { + const request = { + headers: {}, + method: 'POST', + url: '/testing', + }; + const reply = { + header: jest.fn(), + }; + const fastify = { + addHook: jest.fn(async (_hook, cb) => { + await cb(request, reply); + }), + }; + const done = jest.fn(); + + addSecurityHeaders(fastify, {}, done); + + expect(fastify.addHook).toHaveBeenCalled(); + expect(done).toHaveBeenCalled(); + expect(reply.header).toHaveBeenCalledTimes(9); + expect(reply.header).toHaveBeenCalledWith('vary', 'Accept-Encoding'); + expect(reply.header).toHaveBeenCalledWith('Strict-Transport-Security', 'max-age=15552000; includeSubDomains'); + expect(reply.header).toHaveBeenCalledWith('x-dns-prefetch-control', 'off'); + expect(reply.header).toHaveBeenCalledWith('x-download-options', 'noopen'); + expect(reply.header).toHaveBeenCalledWith('x-permitted-cross-domain-policies', 'none'); + expect(reply.header).toHaveBeenCalledWith('X-Content-Type-Options', 'nosniff'); + expect(reply.header).toHaveBeenCalledWith('X-Frame-Options', 'DENY'); + expect(reply.header).toHaveBeenCalledWith('X-XSS-Protection', '1; mode=block'); + expect(reply.header).toHaveBeenCalledWith('Referrer-Policy', 'same-origin'); + }); + + it('adds security headers with custom referrer policy', () => { + process.env.ONE_REFERRER_POLICY_OVERRIDE = 'origin-when-cross-origin'; + + const request = { + headers: {}, + method: 'GET', + }; + const reply = { + header: jest.fn(), + }; + const fastify = { + addHook: jest.fn(async (_hook, cb) => { + await cb(request, reply); + }), + }; + const done = jest.fn(); + + addSecurityHeaders(fastify, undefined, done); + + expect(fastify.addHook).toHaveBeenCalled(); + expect(done).toHaveBeenCalled(); + expect(reply.header).toHaveBeenCalledTimes(9); + expect(reply.header).toHaveBeenCalledWith('vary', 'Accept-Encoding'); + expect(reply.header).toHaveBeenCalledWith('Strict-Transport-Security', 'max-age=15552000; includeSubDomains'); + expect(reply.header).toHaveBeenCalledWith('x-dns-prefetch-control', 'off'); + expect(reply.header).toHaveBeenCalledWith('x-download-options', 'noopen'); + expect(reply.header).toHaveBeenCalledWith('x-permitted-cross-domain-policies', 'none'); + expect(reply.header).toHaveBeenCalledWith('X-Content-Type-Options', 'nosniff'); + expect(reply.header).toHaveBeenCalledWith('X-Frame-Options', 'SAMEORIGIN'); + expect(reply.header).toHaveBeenCalledWith('X-XSS-Protection', '0'); + expect(reply.header).toHaveBeenCalledWith('Referrer-Policy', 'origin-when-cross-origin'); + }); +}); diff --git a/__tests__/server/plugins/conditionallyAllowCors.spec.js b/__tests__/server/plugins/conditionallyAllowCors.spec.js new file mode 100644 index 000000000..5c0f59599 --- /dev/null +++ b/__tests__/server/plugins/conditionallyAllowCors.spec.js @@ -0,0 +1,110 @@ +/* + * Copyright 2019 American Express Travel Related Services Company, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +// Dangling underscores are part of the HTTP mocks API +/* eslint-disable no-underscore-dangle */ +import httpMocks from 'node-mocks-http'; +import fastifyCors from '@fastify/cors'; +import { fromJS } from 'immutable'; +import conditionallyAllowCors, { setCorsOrigins } from '../../../src/server/plugins/conditionallyAllowCors'; + +const { NODE_ENV } = process.env; +let state = fromJS({ rendering: {} }); +let request; + +const setup = ({ renderPartialOnly, origin }) => { + state = state.update('rendering', (rendering) => rendering.set('renderPartialOnly', renderPartialOnly)); + request = httpMocks.createRequest({ + headers: { + Origin: origin, + }, + }); + request.store = { getState: () => state }; +}; + +describe('conditionallyAllowCors', () => { + afterAll(() => { + process.env.NODE_ENV = NODE_ENV; + }); + + it('allows CORS for HTML partials', async () => { + delete process.env.NODE_ENV; + setup({ renderPartialOnly: true, origin: 'test.example.com' }); + setCorsOrigins([/\.example.com$/]); + + const callback = jest.fn(); + const fastify = { + register: jest.fn((_plugin, { delegator }) => { + delegator(request, callback); + }), + }; + + await conditionallyAllowCors(fastify); + + expect(fastify.register).toHaveBeenCalledTimes(1); + expect(fastify.register).toHaveBeenCalledWith(fastifyCors, { hook: 'preHandler', delegator: expect.any(Function) }); + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith(null, { origin: [/\.example.com$/] }); + }); + + it('allows CORS for localhost in development', async () => { + process.env.NODE_ENV = 'development'; + setup({ renderPartialOnly: true, origin: 'localhost:8000' }); + setCorsOrigins([/\.example.com$/]); + + const callback = jest.fn(); + const fastify = { + register: jest.fn((_plugin, { delegator }) => { + delegator(request, callback); + }), + }; + + await conditionallyAllowCors(fastify); + + expect(callback).toHaveBeenCalledWith(null, { origin: [/\.example.com$/, /localhost:\d{1,5}/] }); + }); + + it('does not allow CORS for localhost in production', async () => { + process.env.NODE_ENV = 'production'; + setup({ renderPartialOnly: true, origin: 'localhost:8000' }); + + const callback = jest.fn(); + const fastify = { + register: jest.fn((_plugin, { delegator }) => { + delegator(request, callback); + }), + }; + + await conditionallyAllowCors(fastify); + + expect(callback).toHaveBeenCalledWith(null, { origin: [/\.example.com$/, /localhost:\d{1,5}/] }); + }); + + it('does not allow CORS non-partial requests', async () => { + setup({ renderPartialOnly: false, origin: 'test.example.com' }); + + const callback = jest.fn(); + const fastify = { + register: jest.fn((_plugin, { delegator }) => { + delegator(request, callback); + }), + }; + + await conditionallyAllowCors(fastify); + + expect(callback).toHaveBeenCalledWith(null, { origin: false }); + }); +}); diff --git a/__tests__/server/plugins/csp.spec.js b/__tests__/server/plugins/csp.spec.js new file mode 100644 index 000000000..a02100fd2 --- /dev/null +++ b/__tests__/server/plugins/csp.spec.js @@ -0,0 +1,175 @@ +/* +* Copyright 2019 American Express Travel Related Services Company, Inc. +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +* or implied. See the License for the specific language governing +* permissions and limitations under the License. +*/ +/* eslint-disable global-require */ + +import Fastify from 'fastify'; +import csp, { updateCSP, getCSP, cspCache } from '../../../src/server/plugins/csp'; + +const sanitizeCspString = (cspString) => cspString + // replaces dynamic ip to prevent snapshot failures + // eslint-disable-next-line unicorn/better-regex -- conflicts with unsafe-regex + .replace(/\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}/g, '0.0.0.0'); + +const buildApp = async () => { + const app = Fastify(); + + app.register(csp); + + app.get('/', () => 'test'); + + return app; +}; + +jest.spyOn(console, 'error').mockImplementation(() => {}); + +describe('csp', () => { + beforeEach(() => { + process.env.ONE_DANGEROUSLY_DISABLE_CSP = 'false'; + }); + + it('sets a csp header in development', async () => { + const response = await (await buildApp()).inject({ + method: 'GET', + url: '/', + }); + const { headers } = response; + + expect(headers).toHaveProperty('content-security-policy'); + expect(headers).not.toHaveProperty('content-security-policy-report-only'); + }); + + it('does not set csp header if ONE_DANGEROUSLY_DISABLE_CSP is present', async () => { + process.env.ONE_DANGEROUSLY_DISABLE_CSP = 'true'; + const response = await (await buildApp()).inject({ + method: 'GET', + url: '/', + }); + const { headers } = response; + + expect(headers).not.toHaveProperty('Content-Security-Policy'); + }); + + it('defaults to production csp', async () => { + delete process.env.NODE_ENV; + + const response = await (await buildApp()).inject({ + method: 'GET', + url: '/', + }); + const { headers } = response; + + expect(headers).toHaveProperty('content-security-policy'); + expect(headers).not.toHaveProperty('content-security-policy-report-only'); + }); + + it('adds ip and localhost to csp in development', async () => { + process.env.NODE_ENV = 'development'; + process.env.HTTP_ONE_APP_DEV_CDN_PORT = 5000; + process.env.HTTP_ONE_APP_DEV_PROXY_SERVER_PORT = 3001; + + updateCSP("default-src 'none'; script-src 'self'; connect-src 'self';"); + + const response = await (await buildApp()).inject({ + method: 'GET', + url: '/', + }); + const { headers } = response; + + expect(headers).toHaveProperty('content-security-policy'); + + const cspString = headers['content-security-policy']; + + expect(sanitizeCspString(cspString)).toMatchSnapshot(); + }); + + it('does not add ip and localhost to csp in production', async () => { + process.env.NODE_ENV = 'production'; + delete process.env.HTTP_ONE_APP_DEV_CDN_PORT; + delete process.env.HTTP_ONE_APP_DEV_PROXY_SERVER_PORT; + + updateCSP("default-src 'none'; script-src 'self'; connect-src 'self';"); + const response = await (await buildApp()).inject({ + method: 'GET', + url: '/', + }); + const { headers } = response; + + expect(headers).toHaveProperty('content-security-policy'); + const cspString = headers['content-security-policy']; + // eslint-disable-next-line unicorn/better-regex + const ipFound = cspString.match(/\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}/); + expect(ipFound).toBeNull(); + const localhostFound = cspString.match(/localhost/); + expect(localhostFound).toBeNull(); + }); + + it('sets script nonce', async () => { + updateCSP("default-src 'none'; script-src 'self';"); + const response = await (await buildApp()).inject({ + method: 'GET', + url: '/', + }); + const { headers } = response; + expect(headers).toHaveProperty('content-security-policy'); + expect(headers['content-security-policy'].includes('nonce-')).toBe(true); + }); + + it('does not set the script nonce if this has been disabled in development', async () => { + process.env.NODE_ENV = 'development'; + process.env.ONE_CSP_ALLOW_INLINE_SCRIPTS = 'true'; + + updateCSP("default-src 'none'; script-src 'self';"); + + const response = await (await buildApp()).inject({ + method: 'GET', + url: '/', + }); + + expect(response.headers['content-security-policy'].includes('nonce-')).toBe(false); + }); + + describe('policy', () => { + it('should be a constant string and not function so it cannot be regenerated per request', () => { + expect(cspCache.policy).toEqual(expect.any(String)); + }); + }); + + describe('updateCSP', () => { + it('should accept an empty string', () => { + updateCSP(''); + expect(getCSP()).toEqual({}); + }); + + it('updates cspCache with given csp', () => { + const originalPolicy = cspCache.policy; + updateCSP("default-src 'self';"); + + expect(console.error).not.toHaveBeenCalled(); + expect(cspCache.policy).not.toEqual(originalPolicy); + expect(cspCache.policy).toMatchSnapshot(); + }); + }); + + describe('getCSP', () => { + it('returns parsed CSP', () => { + updateCSP("default-src 'none' 'self'; block-all-mixed-content"); + expect(getCSP()).toEqual({ + 'default-src': ["'none'", "'self'"], + 'block-all-mixed-content': true, + }); + }); + }); +}); diff --git a/__tests__/server/plugins/ensureCorrelationId.spec.js b/__tests__/server/plugins/ensureCorrelationId.spec.js new file mode 100644 index 000000000..336b4c21a --- /dev/null +++ b/__tests__/server/plugins/ensureCorrelationId.spec.js @@ -0,0 +1,65 @@ +/* + * Copyright 2019 American Express Travel Related Services Company, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +import ensureCorrelationId from '../../../src/server/plugins/ensureCorrelationId'; + +describe('ensureCorrelationId', () => { + function generateSamples() { + const request = { + headers: {}, + }; + + const fastify = { + addHook: jest.fn((_hookName, cb) => { + cb(request); + }), + }; + + return { fastify, request }; + } + + it('uses correlation_id for correlation-id on a request without correlation-id', () => { + const { fastify, request } = generateSamples(); + delete request.headers['correlation-id']; + request.headers.correlation_id = 'cloudy'; + ensureCorrelationId(fastify, null, jest.fn()); + expect(request.headers).toHaveProperty('correlation-id', 'cloudy'); + }); + + it('uses unique_id for correlation-id on a request without correlation-id or correlation_id', () => { + const { fastify, request } = generateSamples(); + delete request.headers['correlation-id']; + delete request.headers.correlation_id; + request.headers.unique_id = 'thunderstorms'; + ensureCorrelationId(fastify, null, jest.fn()); + expect(request.headers).toHaveProperty('correlation-id', 'thunderstorms'); + }); + + it('adds a unique correlation-id to the request object', () => { + const { fastify, request } = generateSamples(); + delete request.headers['correlation-id']; + ensureCorrelationId(fastify, null, jest.fn()); + expect(request.headers).toHaveProperty('correlation-id'); + expect(typeof request.headers['correlation-id']).toBe('string'); + }); + + it('does not change an existing correlation-id header on a request', () => { + const { fastify, request } = generateSamples(); + request.headers['correlation-id'] = 'exists'; + ensureCorrelationId(fastify, null, jest.fn()); + expect(request.headers).toHaveProperty('correlation-id', 'exists'); + }); +}); diff --git a/__tests__/server/middleware/forwardedHeaderParser.spec.js b/__tests__/server/plugins/forwardedHeaderParser.spec.js similarity index 50% rename from __tests__/server/middleware/forwardedHeaderParser.spec.js rename to __tests__/server/plugins/forwardedHeaderParser.spec.js index 2fdfe43dc..0a7c4a9bc 100644 --- a/__tests__/server/middleware/forwardedHeaderParser.spec.js +++ b/__tests__/server/plugins/forwardedHeaderParser.spec.js @@ -14,32 +14,37 @@ * permissions and limitations under the License. */ -import forwardedHeaderParser from '../../../src/server/middleware/forwardedHeaderParser'; +import forwardedHeaderParser from '../../../src/server/plugins/forwardedHeaderParser'; describe('forwardedHeaderParser', () => { function generateSamples() { - const req = { + const request = { headers: {}, }; - const res = null; - const next = jest.fn(); - return { req, res, next }; + + const fastify = { + addHook: jest.fn((_hookName, cb) => { + cb(request); + }), + }; + + return { fastify, request }; } - it('no req.forwarded when no forwarded header is present', () => { - const { req, res, next } = generateSamples(); - delete req.headers.forwarded; - forwardedHeaderParser(req, res, next); - expect(req.forwarded).not.toBeDefined(); + it('no request.forwarded when no forwarded header is present', () => { + const { fastify, request } = generateSamples(); + delete request.headers.forwarded; + forwardedHeaderParser(fastify, null, jest.fn()); + expect(request.forwarded).not.toBeDefined(); }); it('forwarded header string is converted into an object - forwarded.host does not exist', () => { - const { req, res, next } = generateSamples(); - delete req.headers.forwarded; - req.headers.forwarded = 'by=testby;for=testfor;proto=testproto'; - forwardedHeaderParser(req, res, next); - expect(req.forwarded).toBeDefined(); - expect(req.forwarded).toEqual({ + const { fastify, request } = generateSamples(); + delete request.headers.forwarded; + request.headers.forwarded = 'by=testby;for=testfor;proto=testproto'; + forwardedHeaderParser(fastify, null, jest.fn()); + expect(request.forwarded).toBeDefined(); + expect(request.forwarded).toEqual({ by: 'testby', for: 'testfor', proto: 'testproto', @@ -47,12 +52,12 @@ describe('forwardedHeaderParser', () => { }); it('forwarded header string is converted into an object - forwarded.host exists', () => { - const { req, res, next } = generateSamples(); - delete req.headers.forwarded; - req.headers.forwarded = 'by=testby;for=testfor;host=testhost;proto=testproto'; - forwardedHeaderParser(req, res, next); - expect(req.forwarded).toBeDefined(); - expect(req.forwarded).toEqual({ + const { fastify, request } = generateSamples(); + delete request.headers.forwarded; + request.headers.forwarded = 'by=testby;for=testfor;host=testhost;proto=testproto'; + forwardedHeaderParser(fastify, null, jest.fn()); + expect(request.forwarded).toBeDefined(); + expect(request.forwarded).toEqual({ by: 'testby', for: 'testfor', host: 'testhost', diff --git a/__tests__/server/middleware/__snapshots__/createRequestHtmlFragment.spec.js.snap b/__tests__/server/plugins/reactHtml/__snapshots__/createRequestHtmlFragment.spec.jsx.snap similarity index 100% rename from __tests__/server/middleware/__snapshots__/createRequestHtmlFragment.spec.js.snap rename to __tests__/server/plugins/reactHtml/__snapshots__/createRequestHtmlFragment.spec.jsx.snap diff --git a/__tests__/server/middleware/__snapshots__/createRequestStore.spec.js.snap b/__tests__/server/plugins/reactHtml/__snapshots__/createRequestStore.spec.js.snap similarity index 100% rename from __tests__/server/middleware/__snapshots__/createRequestStore.spec.js.snap rename to __tests__/server/plugins/reactHtml/__snapshots__/createRequestStore.spec.js.snap diff --git a/__tests__/server/plugins/reactHtml/__snapshots__/index.spec.jsx.snap b/__tests__/server/plugins/reactHtml/__snapshots__/index.spec.jsx.snap new file mode 100644 index 000000000..8b65840b1 --- /dev/null +++ b/__tests__/server/plugins/reactHtml/__snapshots__/index.spec.jsx.snap @@ -0,0 +1,15 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`reactHtml renderModuleScripts adds cache busting clientCacheRevision from module map to each module script src if NODE_ENV is production 1`] = `""`; + +exports[`reactHtml renderModuleScripts does not add cache busting clientCacheRevision from module map to each module script src if NODE_ENV is development 1`] = `""`; + +exports[`reactHtml renderModuleScripts does not add cache busting clientCacheRevision if not present 1`] = `""`; + +exports[`reactHtml renderModuleScripts send a rendered page keeping correctly ordered modules 1`] = `""`; + +exports[`reactHtml renderModuleScripts send a rendered page with correctly ordered modules 1`] = `""`; + +exports[`reactHtml renderModuleScripts send a rendered page with module script tags with integrity attribute if NODE_ENV is production 1`] = `""`; + +exports[`reactHtml renderModuleScripts sends a rendered page with cross origin scripts 1`] = `""`; diff --git a/__tests__/server/plugins/reactHtml/createRequestHtmlFragment.spec.jsx b/__tests__/server/plugins/reactHtml/createRequestHtmlFragment.spec.jsx new file mode 100644 index 000000000..98d1cbe18 --- /dev/null +++ b/__tests__/server/plugins/reactHtml/createRequestHtmlFragment.spec.jsx @@ -0,0 +1,292 @@ +/* + * Copyright 2019 American Express Travel Related Services Company, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +import url from 'url'; +import { browserHistory, matchPromise } from '@americanexpress/one-app-router'; +import { Map as iMap, fromJS } from 'immutable'; +import { composeModules } from 'holocron'; +// getBreaker is only added in the mock +/* eslint-disable-next-line import/named */ +import { getBreaker } from '../../../../src/server/utils/createCircuitBreaker'; + +import * as reactRendering from '../../../../src/server/utils/reactRendering'; + +jest.mock('@americanexpress/one-app-router', () => ({ + ...jest.requireActual('@americanexpress/one-app-router'), + matchPromise: jest.fn(), +})); + +jest.mock('holocron', () => ({ + composeModules: jest.fn(() => 'composeModules'), + getModule: () => () => 0, +})); + +jest.mock('../../../../src/server/utils/createCircuitBreaker', () => { + const breaker = jest.fn(); + const mockCreateCircuitBreaker = (asyncFunctionThatMightFail) => { + breaker.fire = jest.fn((...args) => { + asyncFunctionThatMightFail(...args); + return false; + }); + return breaker; + }; + mockCreateCircuitBreaker.getBreaker = () => breaker; + return mockCreateCircuitBreaker; +}); + +const renderForStringSpy = jest.spyOn(reactRendering, 'renderForString'); +const renderForStaticMarkupSpy = jest.spyOn(reactRendering, 'renderForStaticMarkup'); + +describe('createRequestHtmlFragment', () => { + jest.spyOn(console, 'error').mockImplementation(() => {}); + + let req; + let res; + let createRoutes; + const dispatch = jest.fn((x) => x); + const getState = jest.fn(() => fromJS({ + rendering: iMap({}), + })); + + beforeAll(() => { + matchPromise.mockImplementation(({ routes, location }) => Promise.resolve({ + redirectLocation: undefined, + renderProps: { + routes, + components: [() => 'hi'], + location: url.parse(location), + router: browserHistory, + params: { + hi: 'there?', + }, + }, + })); + }); + + beforeEach(() => { + jest.clearAllMocks(); + req = jest.fn(); + req.headers = {}; + + res = jest.fn(); + res.status = jest.fn(() => res); + res.sendStatus = jest.fn(() => res); + res.redirect = jest.fn(() => res); + res.end = jest.fn(() => res); + res.code = jest.fn(); + req.url = 'http://example.com/request'; + req.store = { dispatch, getState }; + + createRoutes = jest.fn(() => [{ path: '/', moduleName: 'root' }]); + + renderForStringSpy.mockClear(); + renderForStaticMarkupSpy.mockClear(); + console.error.mockClear(); + }); + + const requireCreateRequestHtmlFragment = (...args) => require( + '../../../../src/server/plugins/reactHtml/createRequestHtmlFragment' + ).default(...args); + + it('should preload data for matched route components', async () => { + expect.assertions(4); + + await requireCreateRequestHtmlFragment(req, res, { createRoutes }); + + expect(getBreaker().fire).toHaveBeenCalled(); + expect(composeModules).toHaveBeenCalled(); + expect(composeModules.mock.calls[0][0]).toMatchSnapshot(); + expect(dispatch).toHaveBeenCalledWith('composeModules'); + }); + + it('should add app HTML to the request object', async () => { + expect.assertions(4); + + await requireCreateRequestHtmlFragment(req, res, { createRoutes }); + + expect(createRoutes).toHaveBeenCalledWith(req.store); + expect(renderForStringSpy).toHaveBeenCalled(); + expect(renderForStaticMarkupSpy).not.toHaveBeenCalled(); + expect(typeof req.appHtml).toBe('string'); + }); + + it('should add app HTML as static markup to the request object when scripts are disabled', async () => { + expect.assertions(4); + + getState.mockImplementationOnce(() => fromJS({ + rendering: iMap({ disableScripts: true }), + })); + + await requireCreateRequestHtmlFragment(req, res, { createRoutes }); + + expect(createRoutes).toHaveBeenCalledWith(req.store); + expect(renderForStringSpy).not.toHaveBeenCalled(); + expect(renderForStaticMarkupSpy).toHaveBeenCalled(); + expect(typeof req.appHtml).toBe('string'); + }); + + it('should set the custom HTTP status', async () => { + expect.assertions(3); + + createRoutes = jest.fn(() => [{ path: '/', httpStatus: 400 }]); + await requireCreateRequestHtmlFragment(req, res, { createRoutes }); + + expect(createRoutes).toHaveBeenCalledWith(req.store); + expect(res.code).toHaveBeenCalledWith(400); + expect(typeof req.appHtml).toBe('string'); + }); + + it('does not generate HTML when no route is matched', async () => { + expect.assertions(4); + + matchPromise.mockImplementationOnce(() => ({ + redirectLocation: undefined, + // omit renderProps + })); + + await requireCreateRequestHtmlFragment(req, res, { createRoutes }); + + expect(console.error).toHaveBeenCalledWith('error creating request HTML fragment for http://example.com/request', expect.any(Error)); + expect(res.code).toHaveBeenCalledWith(404); + expect(createRoutes).toHaveBeenCalledWith(req.store); + expect(req.appHtml).toBe(undefined); + }); + + it('redirects when a relative redirect route is matched', async () => { + expect.assertions(3); + + matchPromise.mockImplementationOnce(() => ({ + redirectLocation: { + pathname: '/redirect', + search: '', + }, + })); + + await requireCreateRequestHtmlFragment(req, res, { createRoutes }); + + expect(createRoutes).toHaveBeenCalledWith(req.store); + expect(res.redirect).toHaveBeenCalledWith(302, '/redirect'); + expect(req.appHtml).toBe(undefined); + }); + + it('redirects when an absolute redirect route is matched', async () => { + expect.assertions(3); + + matchPromise.mockImplementationOnce(() => ({ + redirectLocation: { + state: url.parse('https://example.com/redirect'), + }, + })); + + await requireCreateRequestHtmlFragment(req, res, { createRoutes }); + + expect(createRoutes).toHaveBeenCalledWith(req.store); + expect(res.redirect).toHaveBeenCalledWith(302, 'https://example.com/redirect'); + expect(req.appHtml).toBe(undefined); + }); + + it('should catch any errors and call the next middleware', async () => { + expect.assertions(2); + + const createRoutesError = new Error('failed to create routes'); + const brokenCreateRoutes = () => { throw createRoutesError; }; + + await requireCreateRequestHtmlFragment(req, res, { createRoutes: brokenCreateRoutes }); + + expect(console.error).toHaveBeenCalled(); + expect(console.error.mock.calls[0]).toEqual(['error creating request HTML fragment for http://example.com/request', createRoutesError]); + }); + + it('should use a circuit breaker', async () => { + expect.assertions(5); + + await requireCreateRequestHtmlFragment(req, res, { createRoutes }); + + expect(getBreaker().fire).toHaveBeenCalled(); + expect(composeModules).toHaveBeenCalled(); + expect(renderForStringSpy).toHaveBeenCalled(); + expect(renderForStaticMarkupSpy).not.toHaveBeenCalled(); + expect(req.appHtml).toBe('hi'); + }); + + it('should fall back when the circuit opens', async () => { + expect.assertions(4); + const breaker = getBreaker(); + breaker.fire.mockReturnValueOnce(true); + + await requireCreateRequestHtmlFragment(req, res, { createRoutes }); + + expect(getBreaker().fire).toHaveBeenCalled(); + expect(renderForStringSpy).not.toHaveBeenCalled(); + expect(renderForStaticMarkupSpy).not.toHaveBeenCalled(); + expect(req.appHtml).toBe(''); + }); + + it('should not use the circuit breaker for partials', async () => { + expect.assertions(5); + + getState.mockImplementationOnce(() => fromJS({ + rendering: { + renderPartialOnly: true, + }, + })); + + await requireCreateRequestHtmlFragment(req, res, { createRoutes }); + + expect(getBreaker().fire).not.toHaveBeenCalled(); + expect(composeModules).toHaveBeenCalled(); + expect(renderForStringSpy).not.toHaveBeenCalled(); + expect(renderForStaticMarkupSpy).toHaveBeenCalled(); + expect(req.appHtml).toBe('hi'); + }); + + it('should not use the circuit breaker when scripts are disabled', async () => { + expect.assertions(5); + + getState.mockImplementationOnce(() => fromJS({ + rendering: { + disableScripts: true, + }, + })); + + await requireCreateRequestHtmlFragment(req, res, { createRoutes }); + + expect(getBreaker().fire).not.toHaveBeenCalled(); + expect(composeModules).toHaveBeenCalled(); + expect(renderForStringSpy).not.toHaveBeenCalled(); + expect(renderForStaticMarkupSpy).toHaveBeenCalled(); + expect(req.appHtml).toBe('hi'); + }); + + it('should not use the circuit breaker when rendering text only', async () => { + expect.assertions(5); + + getState.mockImplementationOnce(() => fromJS({ + rendering: { + renderTextOnly: true, + renderTextOnlyOptions: { htmlTagReplacement: '', allowedHtmlTags: [] }, + }, + })); + + await requireCreateRequestHtmlFragment(req, res, { createRoutes }); + + expect(getBreaker().fire).not.toHaveBeenCalled(); + expect(composeModules).toHaveBeenCalled(); + expect(renderForStringSpy).not.toHaveBeenCalled(); + expect(renderForStaticMarkupSpy).toHaveBeenCalled(); + expect(req.appHtml).toBe('hi'); + }); +}); diff --git a/__tests__/server/middleware/createRequestStore.spec.js b/__tests__/server/plugins/reactHtml/createRequestStore.spec.js similarity index 67% rename from __tests__/server/middleware/createRequestStore.spec.js rename to __tests__/server/plugins/reactHtml/createRequestStore.spec.js index 3b19781b4..5d857c741 100644 --- a/__tests__/server/middleware/createRequestStore.spec.js +++ b/__tests__/server/plugins/reactHtml/createRequestStore.spec.js @@ -16,13 +16,12 @@ import combineReducers from '@americanexpress/vitruvius/immutable'; import holocron from 'holocron'; -import httpMocks from 'node-mocks-http'; import { fromJS } from 'immutable'; -import { renderStaticErrorPage } from '../../../src/server/middleware/sendHtml'; -import createRequestStore from '../../../src/server/middleware/createRequestStore'; -import { setClientModuleMapCache } from '../../../src/server/utils/clientModuleMapCache'; +import renderStaticErrorPage from '../../../../src/server/plugins/reactHtml/staticErrorPage'; +import createRequestStore from '../../../../src/server/plugins/reactHtml/createRequestStore'; +import { setClientModuleMapCache } from '../../../../src/server/utils/clientModuleMapCache'; -jest.mock('../../../src/server/middleware/sendHtml'); +jest.mock('../../../../src/server/plugins/reactHtml/staticErrorPage'); jest.mock('holocron', () => { const actualHolocron = jest.requireActual('holocron'); return { @@ -58,9 +57,8 @@ describe('createRequestStore', () => { jest.spyOn(console, 'log').mockImplementation(() => {}); jest.spyOn(console, 'error').mockImplementation(() => {}); - let req; - let res; - let next; + let request; + let reply; let reducers; beforeAll(() => { @@ -74,19 +72,20 @@ describe('createRequestStore', () => { beforeEach(() => { jest.clearAllMocks(); - req = httpMocks.createRequest({ + + request = { + decorateRequest: jest.fn(), headers: { 'correlation-id': 'abc123', }, - }); - res = httpMocks.createResponse({ req }); + url: '/', + raw: {}, + method: 'get', + }; - res.status = jest.fn(res.status); - res.send = jest.fn(res.send); - res.end = jest.fn(res.end); - res.cookie = jest.fn(res.cookie); - - next = jest.fn(); + reply = { + raw: {}, + }; reducers = jest.fn( (...args) => combineReducers({ @@ -97,68 +96,61 @@ describe('createRequestStore', () => { reducers.buildInitialState = jest.fn(() => fromJS({ appReducer: 'fizzy' })); }); - it('returns a function', () => { - const middleware = createRequestStore({ reducers }); - expect(middleware).toBeInstanceOf(Function); - }); - it('should add a store to the request object', () => { - const middleware = createRequestStore({ reducers }); - middleware(req, res, next); - expect(req.store).toBeTruthy(); - expect(next).toHaveBeenCalled(); + createRequestStore(request, reply, { reducers }); + + expect(console.error).not.toHaveBeenCalled(); + expect(request.store).toBeTruthy(); }); it('should add the client holocron module map cache to the request object', () => { - const middleware = createRequestStore({ reducers }); - middleware(req, res, next); - expect(req.clientModuleMapCache).toMatchSnapshot(); - expect(next).toHaveBeenCalled(); + createRequestStore(request, reply, { reducers }); + + expect(request.clientModuleMapCache).toMatchSnapshot(); }); it('should send the static error page when there is an error', () => { - const middleware = createRequestStore({ reducers: null }); - middleware(req, res, next); - // eslint-disable-next-line no-console + createRequestStore(request, reply, { reducers: null }); + expect(console.error).toHaveBeenCalled(); - expect(renderStaticErrorPage).toHaveBeenCalledWith(res); + expect(renderStaticErrorPage).toHaveBeenCalledWith(request, reply); }); describe('fetch', () => { it('should set the store up with fetchClient', async () => { - const middleware = createRequestStore({ reducers }); - middleware(req, res, next); + createRequestStore(request, reply, { reducers }); const { extraThunkArguments: { fetchClient }, } = holocron.createHolocronStore.mock.calls[0][0]; + expect(fetchClient).toBeInstanceOf(Function); }); }); describe('useBodyForBuildingTheInitialState', () => { it('uses the request body as the locals for initial state when useBodyForBuildingTheInitialState is true', () => { - const middleware = createRequestStore( - { reducers }, - { useBodyForBuildingTheInitialState: true } - ); - req.body = { some: 'form data', that: 'was posted' }; - middleware(req, res, next); + request.method = 'post'; + request.body = { some: 'form data', that: 'was posted' }; + + createRequestStore(request, reply, { reducers }); + expect(reducers.buildInitialState).toHaveBeenCalledTimes(1); expect(reducers.buildInitialState.mock.calls[0][0]).toHaveProperty('req.body.some', 'form data'); expect(reducers.buildInitialState.mock.calls[0][0]).toHaveProperty('req.body.that', 'was posted'); }); it('does not use the request body as the locals for initial state when useBodyForBuildingTheInitialState is not given', () => { - const middleware = createRequestStore({ reducers }); - req.body = { some: 'other form data', that: 'was acquired' }; - middleware(req, res, next); + request.body = { some: 'other form data', that: 'was acquired' }; + createRequestStore(request, reply, { reducers }); + expect(reducers.buildInitialState).toHaveBeenCalledTimes(1); expect(reducers.buildInitialState.mock.calls[0][0]).not.toHaveProperty('req.body'); }); }); it('should pass enhancedFetch into createHolocronStore', () => { - createRequestStore({ reducers })(req, res, next); + createRequestStore(request, reply, { reducers }); + expect(holocron.createHolocronStore.mock.calls[0][0]).toHaveProperty('extraThunkArguments.fetchClient'); }); }); diff --git a/__tests__/server/middleware/sendHtml.spec.js b/__tests__/server/plugins/reactHtml/index.spec.jsx similarity index 51% rename from __tests__/server/middleware/sendHtml.spec.js rename to __tests__/server/plugins/reactHtml/index.spec.jsx index 3b367f6c8..8f8c9671b 100644 --- a/__tests__/server/middleware/sendHtml.spec.js +++ b/__tests__/server/plugins/reactHtml/index.spec.jsx @@ -14,42 +14,52 @@ * permissions and limitations under the License. */ +import Fastify from 'fastify'; import { fromJS } from 'immutable'; -import sendHtml, { - renderStaticErrorPage, +import reactHtml, { + sendHtml, renderModuleScripts, - safeSend, - setErrorPage, -} from '../../../src/server/middleware/sendHtml'; + checkStateForRedirectAndStatusCode, +} from '../../../../src/server/plugins/reactHtml'; // _client is a method to control the mock // eslint-disable-next-line import/named -import { getClientStateConfig } from '../../../src/server/utils/stateConfig'; +import { getClientStateConfig } from '../../../../src/server/utils/stateConfig'; // _setVars is a method to control the mock // eslint-disable-next-line import/named -import transit from '../../../src/universal/utils/transit'; -import { setClientModuleMapCache, getClientModuleMapCache } from '../../../src/server/utils/clientModuleMapCache'; -import { getClientPWAConfig } from '../../../src/server/middleware/pwa/config'; +import transit from '../../../../src/universal/utils/transit'; +import { setClientModuleMapCache, getClientModuleMapCache } from '../../../../src/server/utils/clientModuleMapCache'; +import { getClientPWAConfig, getServerPWAConfig } from '../../../../src/server/pwa/config'; +import createRequestStoreHook from '../../../../src/server/plugins/reactHtml/createRequestStore'; +import createRequestHtmlFragmentHook from '../../../../src/server/plugins/reactHtml/createRequestHtmlFragment'; +import conditionallyAllowCors from '../../../../src/server/plugins/conditionallyAllowCors'; jest.mock('react-helmet'); -jest.mock('holocron', () => ({ - getModule: () => { - const module = () => 0; - module.ssrStyles = {}; - module.ssrStyles.getFullSheet = () => '.class { background: red; }'; - return module; - }, -})); +jest.mock('holocron', () => { + const actualHolocron = jest.requireActual('holocron'); + + return { + ...actualHolocron, + getModule: () => { + const module = () => 0; + module.ssrStyles = {}; + module.ssrStyles.getFullSheet = () => '.class { background: red; }'; + return module; + }, + }; +}); + jest.mock('@americanexpress/fetch-enhancers', () => ({ createTimeoutFetch: jest.fn( (timeout) => (next) => (url) => next(url) - .then((res) => { - res.timeout = timeout; - return res; + .then((reply) => { + // eslint-disable-next-line no-param-reassign + reply.timeout = timeout; + return reply; }) ), })); -jest.mock('../../../src/server/utils/stateConfig'); -jest.mock('../../../src/server/utils/readJsonFile', () => (filePath) => { +jest.mock('../../../../src/server/utils/stateConfig'); +jest.mock('../../../../src/server/utils/readJsonFile', () => (filePath) => { switch (filePath) { case '../../../.build-meta.json': return { @@ -92,7 +102,7 @@ jest.mock('../../../src/server/utils/readJsonFile', () => (filePath) => { throw new Error('Couldn\'t find JSON file to read'); } }); -jest.mock('../../../src/server/middleware/pwa/config', () => ({ +jest.mock('../../../../src/server/pwa/config', () => ({ getClientPWAConfig: jest.fn(() => ({ serviceWorker: false, serviceWorkerScope: null, @@ -100,22 +110,43 @@ jest.mock('../../../src/server/middleware/pwa/config', () => ({ webManifestUrl: false, offlineUrl: false, })), + getServerPWAConfig: jest.fn(() => ({ + serviceWorker: false, + })), })); -jest.mock('../../../src/universal/ducks/config'); -jest.mock('../../../src/universal/utils/transit', () => ({ +jest.mock('../../../../src/universal/ducks/config'); +jest.mock('../../../../src/universal/utils/transit', () => ({ toJSON: jest.fn(() => 'serialized in a string'), })); +jest.mock('../../../../src/server/utils/createCircuitBreaker', () => { + const breaker = jest.fn(); + const mockCreateCircuitBreaker = (asyncFunctionThatMightFail) => { + breaker.fire = jest.fn((...args) => { + asyncFunctionThatMightFail(...args); + return false; + }); + return breaker; + }; + mockCreateCircuitBreaker.getBreaker = () => breaker; + return mockCreateCircuitBreaker; +}); + +jest.mock('../../../../src/server/plugins/reactHtml/createRequestStore'); +jest.mock('../../../../src/server/plugins/reactHtml/createRequestHtmlFragment'); +jest.mock('../../../../src/server/plugins/conditionallyAllowCors'); jest.spyOn(console, 'info').mockImplementation(() => {}); jest.spyOn(console, 'log').mockImplementation(() => {}); jest.spyOn(console, 'error').mockImplementation(() => {}); jest.spyOn(console, 'warn').mockImplementationOnce(() => {}); -describe('sendHtml', () => { +global.fetch = () => Promise.resolve({ data: 'data' }); + +describe('reactHtml', () => { const appHtml = '

Why, hello!

'; - let req; - let res; + let request; + let reply; const setFullMap = () => { setClientModuleMapCache({ @@ -178,7 +209,7 @@ describe('sendHtml', () => { }, }, }); - req.clientModuleMapCache = getClientModuleMapCache(); + request.clientModuleMapCache = getClientModuleMapCache(); }; beforeEach(() => { @@ -203,19 +234,14 @@ describe('sendHtml', () => { }); jest.resetModules(); jest.clearAllMocks(); - req = jest.fn(); - req.headers = { + + request = jest.fn(); + request.headers = { // we need a legitimate user-agent string here to test between modern and legacy browsers 'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/77.0.3865.90 Safari/537.36', }; - - res = jest.fn(); - res.status = jest.fn(() => res); - res.send = jest.fn(() => res); - res.redirect = jest.fn(() => res); - res.end = jest.fn(() => res); - req.url = 'http://example.com/request'; - req.store = { + request.url = 'http://example.com/request'; + request.store = { dispatch: jest.fn(), getState: jest.fn(() => fromJS({ holocron: fromJS({ @@ -225,8 +251,18 @@ describe('sendHtml', () => { rendering: fromJS({}), })), }; - req.clientModuleMapCache = getClientModuleMapCache(); - req.appHtml = appHtml; + request.clientModuleMapCache = getClientModuleMapCache(); + request.appHtml = appHtml; + + reply = jest.fn(); + reply.status = jest.fn(() => reply); + reply.code = jest.fn(() => reply); + reply.send = jest.fn(() => reply); + reply.redirect = jest.fn(() => reply); + reply.type = jest.fn(() => reply); + reply.code = jest.fn(() => reply); + reply.header = jest.fn(() => reply); + reply.request = request; getClientStateConfig.mockImplementation(() => ({ cdnUrl: '/cdnUrl/', @@ -245,59 +281,66 @@ describe('sendHtml', () => { ); } - describe('middleware', () => { + describe('sendHtml', () => { it('sends a rendered page', () => { - sendHtml(req, res); - expect(res.send).toHaveBeenCalledTimes(1); - expect(res.send.mock.calls[0][0]).toContain(''); - expect(res.send.mock.calls[0][0]).toContain('One App'); + sendHtml(request, reply); + + expect(console.error).not.toHaveBeenCalled(); + + expect(reply.send).toHaveBeenCalledTimes(1); + expect(reply.send.mock.calls[0][0]).toContain(''); + expect(reply.send.mock.calls[0][0]).toContain('One App'); - expect(res.send.mock.calls[0][0]).toContain(appHtml); - expect(res.send.mock.calls[0][0]).toContain( + expect(reply.send.mock.calls[0][0]).toContain(appHtml); + expect(reply.send.mock.calls[0][0]).toContain( 'window.__webpack_public_path__ = "/cdnUrl/app/1.2.3-rc.4-abc123/";' ); - expect(res.send.mock.calls[0][0]).toContain( + expect(reply.send.mock.calls[0][0]).toContain( 'window.__holocron_module_bundle_type__ = \'browser\';' ); - expect(res.send.mock.calls[0][0]).toContain( + expect(reply.send.mock.calls[0][0]).toContain( 'window.__CLIENT_HOLOCRON_MODULE_MAP__ = {"modules":{"test-root":{"baseUrl":"https://example.com/cdn/test-root/2.2.2/","browser":{"url":"https://example.com/cdn/test-root/2.2.2/test-root.browser.js","integrity":"nggdfhr34"}}}};' ); - expect(removeInitialState(res.send.mock.calls[0][0])).not.toContain('undefined'); + expect(removeInitialState(reply.send.mock.calls[0][0])).not.toContain('undefined'); }); it('sends a rendered page with the __holocron_module_bundle_type__ global set according to the user agent and the client module map that only includes the relevant details', () => { // MSIE indicates legacy IE - req.headers['user-agent'] = 'Browser/5.0 (compatible; MSIE 100.0; Doors TX 81.4; Layers/1.0)'; - sendHtml(req, res); - expect(res.send).toHaveBeenCalledTimes(1); - expect(res.send.mock.calls[0][0]).toContain(''); - expect(res.send.mock.calls[0][0]).toContain('One App'); - - expect(res.send.mock.calls[0][0]).toContain(appHtml); - expect(res.send.mock.calls[0][0]).toContain( + request.headers['user-agent'] = 'Browser/5.0 (compatible; MSIE 100.0; Doors TX 81.4; Layers/1.0)'; + + sendHtml(request, reply); + + expect(console.error).not.toHaveBeenCalled(); + + expect(reply.send).toHaveBeenCalledTimes(1); + expect(reply.send.mock.calls[0][0]).toContain(''); + expect(reply.send.mock.calls[0][0]).toContain('One App'); + + expect(reply.send.mock.calls[0][0]).toContain(appHtml); + expect(reply.send.mock.calls[0][0]).toContain( 'window.__holocron_module_bundle_type__ = \'legacyBrowser\';' ); - expect(res.send.mock.calls[0][0]).toContain( + expect(reply.send.mock.calls[0][0]).toContain( 'window.__CLIENT_HOLOCRON_MODULE_MAP__ = {"modules":{"test-root":{"baseUrl":"https://example.com/cdn/test-root/2.2.2/","legacyBrowser":{"url":"https://example.com/cdn/test-root/2.2.2/test-root.legacy.browser.js","integrity":"7567ee"}}}};' ); - expect(removeInitialState(res.send.mock.calls[0][0])).not.toContain('undefined'); + expect(removeInitialState(reply.send.mock.calls[0][0])).not.toContain('undefined'); }); it('sends a rendered page with defaults', () => { getClientStateConfig.mockImplementation(() => ({})); - sendHtml(req, res); - expect(res.send).toHaveBeenCalledTimes(1); - expect(res.send.mock.calls[0][0]).toContain(''); - expect(res.send.mock.calls[0][0]).toContain('One App'); - expect(res.send.mock.calls[0][0]).toContain(appHtml); - expect(res.send.mock.calls[0][0]).toContain( + sendHtml(request, reply); + expect(reply.send).toHaveBeenCalledTimes(1); + expect(reply.send.mock.calls[0][0]).toContain(''); + expect(reply.send.mock.calls[0][0]).toContain('One App'); + expect(reply.send.mock.calls[0][0]).toContain(appHtml); + expect(reply.send.mock.calls[0][0]).toContain( 'window.__webpack_public_path__ = "/_/static/app/1.2.3-rc.4-abc123/";' ); - expect(removeInitialState(res.send.mock.calls[0][0])).not.toContain('undefined'); + expect(removeInitialState(reply.send.mock.calls[0][0])).not.toContain('undefined'); }); it('sends a rendered page with helmet info', () => { - req.helmetInfo = { + request.helmetInfo = { htmlAttributes: { toString: jest.fn(() => 'htmlAttributes') }, bodyAttributes: { toString: jest.fn(() => 'bodyAttributes') }, title: { toString: jest.fn(() => 'title') }, @@ -307,49 +350,49 @@ describe('sendHtml', () => { link: { toString: jest.fn(() => '') }, base: { toString: jest.fn(() => '') }, }; - sendHtml(req, res); - expect(res.send).toHaveBeenCalledTimes(1); - expect(res.send.mock.calls[0][0]).toContain(''); - expect(res.send.mock.calls[0][0]).toContain(''); - - expect(res.send.mock.calls[0][0]).not.toContain('One App'); - expect(res.send.mock.calls[0][0]).toContain('title'); - - expect(res.send.mock.calls[0][0]).toContain(''); - expect(res.send.mock.calls[0][0]).toContain(''); - expect(res.send.mock.calls[0][0]).toContain(''); - expect(res.send.mock.calls[0][0]).toContain(''); - expect(res.send.mock.calls[0][0]).toContain(''); - expect(res.send.mock.calls[0][0]).toContain(''); - - expect(removeInitialState(res.send.mock.calls[0][0])).not.toContain('undefined'); + sendHtml(request, reply); + expect(reply.send).toHaveBeenCalledTimes(1); + expect(reply.send.mock.calls[0][0]).toContain(''); + expect(reply.send.mock.calls[0][0]).toContain(''); + + expect(reply.send.mock.calls[0][0]).not.toContain('One App'); + expect(reply.send.mock.calls[0][0]).toContain('title'); + + expect(reply.send.mock.calls[0][0]).toContain(''); + expect(reply.send.mock.calls[0][0]).toContain(''); + expect(reply.send.mock.calls[0][0]).toContain(''); + expect(reply.send.mock.calls[0][0]).toContain(''); + expect(reply.send.mock.calls[0][0]).toContain(''); + expect(reply.send.mock.calls[0][0]).toContain(''); + + expect(removeInitialState(reply.send.mock.calls[0][0])).not.toContain('undefined'); }); it('sends a rendered page with the one-app script tags', () => { - sendHtml(req, res); - expect(res.send).toHaveBeenCalledTimes(1); - expect(res.send.mock.calls[0][0]).toContain(''); - expect(res.send.mock.calls[0][0]).toContain(''); + sendHtml(request, reply); + expect(reply.send).toHaveBeenCalledTimes(1); + expect(reply.send.mock.calls[0][0]).toContain(''); + expect(reply.send.mock.calls[0][0]).toContain(''); }); it('sends a rendered page with the legacy app bundle according to the user agent', () => { // rv:11 indicates IE 11 on mobile - req.headers['user-agent'] = 'Browser/5.0 (compatible; NUEI 100.0; Doors TX 81.4; rv:11)'; - sendHtml(req, res); - expect(res.send).toHaveBeenCalledTimes(1); - expect(res.send.mock.calls[0][0]).toContain(''); - expect(res.send.mock.calls[0][0]).toContain(''); + request.headers['user-agent'] = 'Browser/5.0 (compatible; NUEI 100.0; Doors TX 81.4; rv:11)'; + sendHtml(request, reply); + expect(reply.send).toHaveBeenCalledTimes(1); + expect(reply.send.mock.calls[0][0]).toContain(''); + expect(reply.send.mock.calls[0][0]).toContain(''); }); it('sends a rendered page with the locale data script tag', () => { - sendHtml(req, res); - expect(res.send).toHaveBeenCalledTimes(1); - expect(res.send.mock.calls[0][0]).toContain('' ); - expect(res.send.mock.calls[0][0]).toContain( + expect(reply.send.mock.calls[0][0]).toContain( '' ); - expect(res.send.mock.calls[0][0]).toContain( + expect(reply.send.mock.calls[0][0]).toContain( '' ); - expect(res.send.mock.calls[0][0]).toContain( + expect(reply.send.mock.calls[0][0]).toContain( '