diff --git a/packages/fxa-content-server/package.json b/packages/fxa-content-server/package.json index dfbf7941a0..8cf795b102 100644 --- a/packages/fxa-content-server/package.json +++ b/packages/fxa-content-server/package.json @@ -136,6 +136,8 @@ "@babel/cli": "7.7.4", "@testing-library/react": "^8.0.4", "@types/backbone": "^1.4.1", + "@types/sinon-chai": "3.2.4", + "@types/sinon-express-mock": "1.3.8", "audit-filter": "^0.5.0", "babel-eslint": "9.0.0", "babel-plugin-dynamic-import-webpack": "1.0.2", @@ -163,6 +165,8 @@ "request": "2.88.0", "request-promise": "4.2.0", "sinon": "4.5.0", + "sinon-chai": "^3.5.0", + "sinon-express-mock": "^2.2.1", "ts-node": "^8.10.1", "upng-js": "2.1.0", "url-loader": "4.1.0", diff --git a/packages/fxa-content-server/server/bin/fxa-content-server.js b/packages/fxa-content-server/server/bin/fxa-content-server.js index b4a2a0dca3..a85422150a 100755 --- a/packages/fxa-content-server/server/bin/fxa-content-server.js +++ b/packages/fxa-content-server/server/bin/fxa-content-server.js @@ -27,6 +27,7 @@ const https = require('https'); const path = require('path'); const serveStatic = require('serve-static'); +const { settingsMiddleware } = require('../lib/beta-settings'); const config = require('../lib/configuration'); const sentry = require('../lib/sentry'); const { cors, routing } = require('fxa-shared/express')(); @@ -89,11 +90,7 @@ function makeApp() { writeToDisk: true, }) ); - const { createProxyMiddleware } = require('http-proxy-middleware'); - app.use( - '/beta/settings', - createProxyMiddleware({ target: 'http://localhost:3000', ws: true }) - ); + app.use('/beta/settings', settingsMiddleware); } app.engine('html', consolidate.handlebars); diff --git a/packages/fxa-content-server/server/lib/beta-settings.js b/packages/fxa-content-server/server/lib/beta-settings.js new file mode 100644 index 0000000000..f4ff9c980a --- /dev/null +++ b/packages/fxa-content-server/server/lib/beta-settings.js @@ -0,0 +1,68 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const { createProxyMiddleware } = require('http-proxy-middleware'); +const config = require('./configuration'); + +// Inject Beta Settings meta content +function swapBetaMeta(html, metaContent = {}) { + let result = html; + + Object.keys(metaContent).forEach((key) => { + result = result.replace( + key, + encodeURIComponent(JSON.stringify(metaContent[key])) + ); + }); + + return result; +} + +// Conditionally modify the response +function modifyResponse(proxyRes, req, res) { + const bodyChunks = []; + + proxyRes.on('data', (chunk) => { + bodyChunks.push(chunk); + }); + + proxyRes.on('end', () => { + const body = Buffer.concat(bodyChunks); + + // forward existing response data + res.status(proxyRes.statusCode); + Object.keys(proxyRes.headers).forEach((key) => { + res.append(key, proxyRes.headers[key]); + }); + + // if it's an html content type, inject server config + if ( + proxyRes.headers['content-type'] && + proxyRes.headers['content-type'].includes('text/html') + ) { + let html = body.toString(); + html = swapBetaMeta(html, { + __SERVER_CONFIG__: config, + }); + res.send(new Buffer.from(html)); + } else { + res.send(body); + } + + res.end(); + }); +} + +const settingsMiddleware = createProxyMiddleware({ + target: 'http://localhost:3000', + ws: true, + selfHandleResponse: true, // ensure res.end is not called early + onProxyRes: modifyResponse, +}); + +module.exports = { + settingsMiddleware, + swapBetaMeta, + modifyResponse, +}; diff --git a/packages/fxa-content-server/tests/server/beta-settings.js b/packages/fxa-content-server/tests/server/beta-settings.js new file mode 100644 index 0000000000..e5bf8b6183 --- /dev/null +++ b/packages/fxa-content-server/tests/server/beta-settings.js @@ -0,0 +1,98 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const { registerSuite } = intern.getInterface('object'); +const { expect, use } = require('chai'); +const { readFileSync } = require('fs'); +const { resolve } = require('path'); +const sinonChai = require('sinon-chai'); +const { mockRes } = require('sinon-express-mock'); +const config = require('../../server/lib/configuration'); +const { + swapBetaMeta, + modifyResponse, +} = require('../../server/lib/beta-settings'); + +use(sinonChai); + +const dummyHtml = readFileSync( + resolve(__dirname, './fixtures/server-config-index.html'), + { encoding: 'utf8' } +); + +const responseOptions = { + headers: {}, +}; + +function mockedResponse(data = '', headers = {}) { + return mockRes( + Object.assign(responseOptions, { + headers, + statusCode: 200, + on(name, callback) { + const args = []; + + if (name === 'data') { + args.push(new Buffer.from(data)); + } + + callback(...args); + }, + }) + ); +} + +registerSuite('beta settings', { + tests: { + 'replaces beta meta string with config data': function () { + const config = { kenny: 'spenny' }; + const encodedConfig = encodeURIComponent(JSON.stringify(config)); + + const result = swapBetaMeta(dummyHtml, { + __SERVER_CONFIG__: config, + }); + + expect(result).to.contain(encodedConfig); + }, + 'proxies the response': function () { + const headers = { 'x-foo': 'bar' }; + const proxyingResponse = mockedResponse('', headers); + const proxiedResponse = mockRes(responseOptions); + modifyResponse(proxyingResponse, null, proxiedResponse); + proxyingResponse.send(); + + expect(proxiedResponse.send).to.be.called; + expect(proxiedResponse.end).to.be.called; + expect(proxiedResponse.statusCode).to.equal(proxyingResponse.statusCode); + expect(proxiedResponse.headers).to.equal(headers); + }, + 'modifies the response of html files': function () { + const proxyingResponse = mockedResponse(dummyHtml, { + 'content-type': 'text/html', + }); + const proxiedResponse = mockRes(responseOptions); + modifyResponse(proxyingResponse, null, proxiedResponse); + proxyingResponse.send(dummyHtml); + + const outputHtml = swapBetaMeta(dummyHtml, { + __SERVER_CONFIG__: config, + }); + + expect(proxiedResponse.send).to.be.calledWith( + new Buffer.from(outputHtml) + ); + }, + 'does not modify the response of non-html files': function () { + const data = JSON.stringify({ foo: 'bar' }); + const proxyingResponse = mockedResponse(data, { + 'content-type': 'application/json', + }); + const proxiedResponse = mockRes(responseOptions); + modifyResponse(proxyingResponse, null, proxiedResponse); + proxyingResponse.send(data); + + expect(proxiedResponse.send).to.be.calledWith(new Buffer.from(data)); + }, + }, +}); diff --git a/packages/fxa-content-server/tests/server/fixtures/server-config-index.html b/packages/fxa-content-server/tests/server/fixtures/server-config-index.html new file mode 100644 index 0000000000..699fa52103 --- /dev/null +++ b/packages/fxa-content-server/tests/server/fixtures/server-config-index.html @@ -0,0 +1,14 @@ + + + + + + Hello, world + + + + +
+ + + diff --git a/packages/fxa-content-server/tests/tests_server.js b/packages/fxa-content-server/tests/tests_server.js index 83018efb51..343a7267fa 100644 --- a/packages/fxa-content-server/tests/tests_server.js +++ b/packages/fxa-content-server/tests/tests_server.js @@ -8,6 +8,7 @@ module.exports = [ 'tests/server/ver.json.js', 'tests/server/amplitude.js', 'tests/server/amplitude-schema-validation.js', + 'tests/server/beta-settings.js', 'tests/server/csp.js', 'tests/server/flow-event.js', 'tests/server/flow-metrics.js', diff --git a/yarn.lock b/yarn.lock index 3875942578..a50708a618 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5929,7 +5929,7 @@ __metadata: languageName: node linkType: hard -"@types/sinon-chai@npm:^3.2.4": +"@types/sinon-chai@npm:3.2.4, @types/sinon-chai@npm:^3.2.4": version: 3.2.4 resolution: "@types/sinon-chai@npm:3.2.4" dependencies: @@ -5939,6 +5939,16 @@ __metadata: languageName: node linkType: hard +"@types/sinon-express-mock@npm:1.3.8": + version: 1.3.8 + resolution: "@types/sinon-express-mock@npm:1.3.8" + dependencies: + "@types/express": "*" + "@types/sinon": "*" + checksum: 3/06ef01ba0081b2d5e6995457a599872b891e44a3141a9f17b3c7f2ccd914460747379e50984780b2114b2a4a25d88c6ecf3ef7df2ab9c225c742e83f0af8e2e6 + languageName: node + linkType: hard + "@types/sinon@npm:*, @types/sinon@npm:^9.0.0": version: 9.0.0 resolution: "@types/sinon@npm:9.0.0" @@ -17168,6 +17178,8 @@ fsevents@^1.2.7: "@sentry/node": ^5.11.0 "@testing-library/react": ^8.0.4 "@types/backbone": ^1.4.1 + "@types/sinon-chai": 3.2.4 + "@types/sinon-express-mock": 1.3.8 asmcrypto.js: ^0.22.0 audit-filter: ^0.5.0 autoprefixer: 9.0.1 @@ -17275,6 +17287,8 @@ fsevents@^1.2.7: sass-loader: ^8.0.2 serve-static: 1.13.1 sinon: 4.5.0 + sinon-chai: ^3.5.0 + sinon-express-mock: ^2.2.1 source-map: ^0.7.3 source-map-loader: ^0.2.4 speed-trap: 0.0.10 @@ -33332,6 +33346,25 @@ resolve@~1.11.1: languageName: node linkType: hard +"sinon-chai@npm:^3.5.0": + version: 3.5.0 + resolution: "sinon-chai@npm:3.5.0" + peerDependencies: + chai: ^4.0.0 + sinon: ">=4.0.0 <10.0.0" + checksum: 3/7be5aba73ed36111064069c191af35cf5540540ed11be9da80cccc7c2a457e6dd0864e216dc8a78c5ab8d7769d6c697bbcee4cedc887d67239dcb2b9f740d7c7 + languageName: node + linkType: hard + +"sinon-express-mock@npm:^2.2.1": + version: 2.2.1 + resolution: "sinon-express-mock@npm:2.2.1" + peerDependencies: + sinon: "*" + checksum: 3/d10e5f527bd7092ae9848f2ce780a2168ae7bae8026e8d2cb4fee422d58bfc3515ba5898edcfe359385c47dea25f73155f572ae6c3fd379910e890f9d77e06b0 + languageName: node + linkType: hard + "sinon@npm:4.5.0": version: 4.5.0 resolution: "sinon@npm:4.5.0"