Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

consistent test results regardless of environment #122

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 51 additions & 12 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const {
appendSourceList,
buildPolicyString,
calculateConfig,
isIndexHtmlForTesting,
readConfig
} = require('./lib/utils');

Expand Down Expand Up @@ -68,18 +69,29 @@ module.exports = {
// hook may be called more than once, but we only need to calculate once
if (!this._config) {
let { app, project } = this;
let ownConfig = readConfig(project, environment);
let ui = project.ui;
let ownConfig = readConfig(project, environment);
let config = calculateConfig(environment, ownConfig, runConfig, ui);

// add static test nonce if build includes tests
this._config = config;
this._policyString = buildPolicyString(config.policy);

// generate config for test environment if app includes tests
// Note: app is not defined for CLI commands
if (app && app.tests) {
appendSourceList(config.policy, 'script-src', `'nonce-${STATIC_TEST_NONCE}'`);
}
let ownConfigForTest = readConfig(project, 'test');
let runConfigForTest = project.config('test');
let configForTest = calculateConfig('test', ownConfigForTest, runConfigForTest, ui);

this._config = config;
this._policyString = buildPolicyString(config.policy);
// add static nonce required for tests
appendSourceList(configForTest.policy, 'script-src', `'nonce-${STATIC_TEST_NONCE}'`);

// testem requires frame-src to run
configForTest.policy['frame-src'] = ["'self'"];

this._configForTest = configForTest;
this._policyStringForTest = buildPolicyString(configForTest.policy);
}
}

// CSP header should only be set in FastBoot if
Expand Down Expand Up @@ -120,6 +132,7 @@ module.exports = {
// support live reload, executing tests in development enviroment via
// `http://localhost:4200/tests` and reporting CSP violations on CLI.
let policyObject = this._config.policy;
let policyObjectForTest = this._configForTest.policy;

// live reload requires some addition CSP directives
if (options.liveReload) {
Expand All @@ -128,22 +141,35 @@ module.exports = {
port: options.liveReloadPort,
ssl: options.ssl
});

allowLiveReload(policyObjectForTest, {
hostname: options.liveReloadHost,
port: options.liveReloadPort,
ssl: options.ssl
});
}

// add report URI to policy object and allow it as connection source
if (this._config.reportOnly && !('report-uri' in policyObject)) {
let ecHost = options.host || 'localhost';
let ecProtocol = options.ssl ? 'https://' : 'http://';
let ecOrigin = ecProtocol + ecHost + ':' + options.port;

appendSourceList(policyObject, 'connect-src', ecOrigin);
appendSourceList(policyObjectForTest, 'connect-src', ecOrigin);

policyObject['report-uri'] = ecOrigin + REPORT_PATH;
policyObjectForTest['report-uri'] = policyObject['report-uri'];
}

this._policyString = buildPolicyString(policyObject);
this._policyStringForTest = buildPolicyString(policyObjectForTest);

app.use((req, res, next) => {
let header = this._config.reportOnly ? CSP_HEADER_REPORT_ONLY : CSP_HEADER;
let policyString = this._policyString;
let isRequestForTests = req.originalUrl.startsWith('/tests');
let config = isRequestForTests ? this._configForTest : this._config;
let policyString = isRequestForTests ? this._policyStringForTest : this._policyString;
let header = config.reportOnly ? CSP_HEADER_REPORT_ONLY : CSP_HEADER;

// clear existing headers before setting ours
res.removeHeader(CSP_HEADER);
Expand Down Expand Up @@ -175,21 +201,34 @@ module.exports = {
}

// inject CSP meta tag
if (type === 'head' && this._config.delivery.indexOf('meta') !== -1) {
if (
// if addon is configured to deliver CSP by meta tag
( type === 'head' && this._config.delivery.indexOf('meta') !== -1 ) ||
// ensure it's injected in tests/index.html to ensure consistent test results
type === 'test-head'
) {
// skip head slot for tests/index.html to prevent including the CSP meta tag twice
if (type === 'head' && isIndexHtmlForTesting(existingContent)) {
return;
}

let config = type === 'head' ? this._config : this._configForTest;
let policyString = type === 'head' ? this._policyString : this._policyStringForTest;

this.ui.writeWarnLine(
'Content Security Policy does not support report only mode if delivered via meta element. ' +
"Either set `ENV['ember-cli-content-security-policy'].reportOnly` to `false` or remove `'meta'` " +
"from `ENV['ember-cli-content-security-policy'].delivery`.",
this._config.reportOnly
config.reportOnly
);

unsupportedDirectives(this._config.policy).forEach(function(name) {
unsupportedDirectives(config.policy).forEach(function(name) {
let msg = 'CSP delivered via meta does not support `' + name + '`, ' +
'per the W3C recommendation.';
console.log(chalk.yellow(msg)); // eslint-disable-line no-console
});

return `<meta http-equiv="${CSP_HEADER}" content="${this._policyString}">`;
return `<meta http-equiv="${CSP_HEADER}" content="${policyString}">`;
}

// inject event listener needed for test support
Expand Down
53 changes: 40 additions & 13 deletions lib/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

'use strict';

const assert = require('assert');
const fs = require('fs');
const path = require('path');

Expand Down Expand Up @@ -82,11 +83,6 @@ function calculateConfig(environment, ownConfig, runConfig, ui) {
reportOnly: true,
};

// testem requires frame-src to run
if (environment === 'test') {
config.policy['frame-src'] = CSP_SELF;
}

ui.writeWarnLine(
'Configuring ember-cli-content-security-policy using `contentSecurityPolicy`, ' +
'`contentSecurityPolicyHeader` and `contentSecurityPolicyMeta` keys in `config/environment.js` ' +
Expand All @@ -111,14 +107,6 @@ function calculateConfig(environment, ownConfig, runConfig, ui) {
// apply configuration
Object.assign(config, ownConfig);

// test environment may not use express server and therefore requires CSP
// delivery through meta element. Otherwise tests may fail if run via
// `http://localhost:4200/tests` or `ember test --server` but not for
// `ember test`.
if (environment === 'test' && config.failTests && !config.delivery.includes('meta')) {
config.delivery.push('meta');
}

return config;
}

Expand Down Expand Up @@ -166,9 +154,48 @@ function appendSourceList(policyObject, directiveName, sourceList) {
policyObject[directiveName].push(sourceList);
}


/**
* Determines based on `existingContent` passed to `contentFor` hook, if the hook
* is run for `index.html` or `tests/index.html`.
*
* When running this addon only meta tag for runtime configuration is injected in
* existingContent for sure. The runtime configuration has different value for
* `environment` property between index.html and tests/index.html.
*
* @param {string[]} isIndexHtmlForTesting
* @return {boolean}
*/
function isIndexHtmlForTesting(existingContent) {
let encodedRunTimeConfig;
let configRegExp = /<meta name=".*\/config\/environment" content="(.*)" \/>/;
for (let content of existingContent) {
let matches = content.match(configRegExp);

if (matches && matches.length >= 1) {
encodedRunTimeConfig = matches[1];
}
}
assert(
encodedRunTimeConfig,
'Run time configuration is required in order to determine if contentFor hook is run for ' +
'but seems to be missing in existing content.'
);

let runTimeConfig
try {
runTimeConfig = JSON.parse(decodeURIComponent(encodedRunTimeConfig));
} catch(error) {
throw new Error(`Could not decode runtime configuration cause of ${error}`);
}

return runTimeConfig.environment === 'test';
}

module.exports = {
appendSourceList,
buildPolicyString,
calculateConfig,
isIndexHtmlForTesting,
readConfig
};
2 changes: 1 addition & 1 deletion node-tests/e2e/deliver-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@ const denodeify = require('denodeify');
const request = denodeify(require('request'));
const AddonTestApp = require('ember-cli-addon-tests').AddonTestApp;
const {
CSP_META_TAG_REG_EXP,
removeConfig,
setConfig
} = require('../utils');

const CSP_META_TAG_REG_EXP = /<meta http-equiv="Content-Security-Policy" content="(.*)">/i;

describe('e2e: delivers CSP as configured', function() {
this.timeout(300000);
Expand Down
110 changes: 110 additions & 0 deletions node-tests/e2e/test-support-test.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
const expect = require('chai').expect;
const AddonTestApp = require('ember-cli-addon-tests').AddonTestApp;
const fs = require('fs-extra');
const denodeify = require('denodeify');
const request = denodeify(require('request'));
const {
CSP_META_TAG_REG_EXP,
getConfigPath,
removeConfig,
setConfig
} = require('../utils');
Expand Down Expand Up @@ -60,6 +64,112 @@ describe('e2e: provides test support', function() {
}
});

it('ensures CSP is applied in tests regradless if executed with development server or not', async function() {
await setConfig(app, {
delivery: ['header'],
});

await app.runEmberCommand('build');

let testsIndexHtml = await fs.readFile(app.filePath('dist/tests/index.html'), 'utf8');
let indexHtml = await fs.readFile(app.filePath('dist/index.html'), 'utf8');
expect(testsIndexHtml).to.match(CSP_META_TAG_REG_EXP);
expect(indexHtml).to.not.match(CSP_META_TAG_REG_EXP);
});

describe('it uses CSP configuration for test environment if running tests', function() {
before(async function() {
// setConfig utility does not support configuration depending on environment
// need to write the file manually
let configuration = `
module.exports = function(environment) {
return {
delivery: ['header', 'meta'],
policy: {
'default-src': environment === 'test' ? ["'none'"] : ["'self'"]
},
reportOnly: false
};
};
`;
await fs.writeFile(getConfigPath(app), configuration);

await app.startServer();
});

after(async function() {
await app.stopServer();
});

it('uses CSP configuration for test environment for meta tag in tests/index.html', async function() {
let testsIndexHtml = await fs.readFile(app.filePath('dist/tests/index.html'), 'utf8');
let indexHtml = await fs.readFile(app.filePath('dist/index.html'), 'utf8');

let [,cspInTestsIndexHtml] = testsIndexHtml.match(CSP_META_TAG_REG_EXP);
let [,cspInIndexHtml] = indexHtml.match(CSP_META_TAG_REG_EXP);

expect(cspInTestsIndexHtml).to.include("default-src 'none';");
expect(cspInIndexHtml).to.include("default-src 'self';");
});

it('uses CSP configuration for test environment for CSP header serving tests/', async function() {
let responseForTests = await request({
url: 'http://localhost:49741/tests',
headers: {
'Accept': 'text/html'
}
});
let responseForApp = await request({
url: 'http://localhost:49741',
headers: {
'Accept': 'text/html'
}
});

let cspForTests = responseForTests.headers['content-security-policy'];
let cspForApp = responseForApp.headers['content-security-policy'];

expect(cspForTests).to.include("default-src 'none';");
expect(cspForApp).to.include("default-src 'self';");
});
});

describe('includes frame-src required by testem', function() {
before(async function() {
await setConfig(app, {
delivery: ['header', 'meta'],
reportOnly: false,
});

await app.startServer();
});

after(async function() {
await app.stopServer();

await removeConfig(app);
});

it('includes frame-src required by testem in CSP delivered by meta tag', async function() {
let testsIndexHtml = await fs.readFile(app.filePath('dist/tests/index.html'), 'utf8');
let [,cspInTestsIndexHtml] = testsIndexHtml.match(CSP_META_TAG_REG_EXP);

expect(cspInTestsIndexHtml).to.include("frame-src 'self';");
});

it('includes frame-src required by testem in CSP delivered by HTTP header', async function() {
let responseForTests = await request({
url: 'http://localhost:49741/tests',
headers: {
'Accept': 'text/html'
}
});
let cspForTests = responseForTests.headers['content-security-policy'];

expect(cspForTests).to.include("frame-src 'self';");
});
});

it('does not cause tests failures if addon is disabled', async function() {
await setConfig(app, {
enabled: false,
Expand Down
4 changes: 4 additions & 0 deletions node-tests/utils.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
const fs = require('fs-extra');

const CSP_META_TAG_REG_EXP = /<meta http-equiv="Content-Security-Policy" content="(.*)">/i;

function getConfigPath(app) {
return app.filePath('config/content-security-policy.js');
}
Expand All @@ -22,6 +24,8 @@ async function removeConfig(app) {
}

module.exports = {
CSP_META_TAG_REG_EXP,
getConfigPath,
removeConfig,
setConfig,
};