Skip to content

Instantly share code, notes, and snippets.

@twolfson
Last active December 16, 2016 01:39
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save twolfson/6b2919b9b83dc54c270a9c89db973288 to your computer and use it in GitHub Desktop.
Save twolfson/6b2919b9b83dc54c270a9c89db973288 to your computer and use it in GitHub Desktop.
scenario v2, a describe wrapper for mocha with ACL testing enforcement

We have been using scenario for a while which handles common server setup actions for tests (only recently posted as a gist):

https://gist.github.com/twolfson/1ddf42a41bffefb8c2f298c082e4a337

This has been great but we are human and often forget security-oriented test scenarios (e.g. non-owner user, item not found, non-logged in user).

To address this, we have introduced a new syntax:

scenario('A request to GET /item/:id', function () {
  scenario.routeTest('from the owner user', function () {
    it('...', function () { /* ... */ });
  });
  
  scenario.nonOwner('from a non-owner user', function () {
    it('...', function () { /* ... */ });
  });
  
  scenario.nonExistent('for a non-existent item', function () {
    it('...', function () { /* ... */ });
  });

  scenario.loggedOut('from a logged out user', function () {
    it('...', function () { /* ... */ });
  });
});

When mocha processes these scenarios, it asserts that we have the required nonOwner, nonExistent, and loggedOut cases.

If we need to opt-out of any, then we can override them via the requiredTests option:

scenario.route('A request to GET /settings', {
  // Remove requirement for `nonExistent` and `nonOwner`
  requiredTests: {nonExistent: false, nonOwner: false}
}, function () { /* ... */ });

Additionally, we can reuse this wrapper configuration for reusing setups like installing fixtures and opting out of starting fake servers (e.g. for model tests).

scenario.model('An item model', function () {
  // ...
});

Keen observers might notice this is close to inheritance in TDD except TDD doesn't support nesting so it can't support the deeper level of flag toggling in one-off tests.

// Load in our dependencies
var assert = require('assert');
var _ = require('underscore');
// Define our constants
var SCENARIO_ROUTE = 'route';
var SCENARIO_ROUTE_TEST = 'routeTest';
var SCENARIO_LOGGED_OUT = 'loggedOut';
var SCENARIO_NON_EXISTENT = 'nonExistent';
var SCENARIO_NON_OWNER = 'nonOwner';
// Define a general `describe` wrapper
function normalizeArguments(describeStr, options, describeFn) {
// If there is no describe function, then assume it was options
// `scenario('A request ...', function () {})` -> `scenario('A request ...', {}, function () {})`
if (describeFn === undefined) {
describeFn = options;
options = {};
}
// Return our normalized options
return [describeStr, options, describeFn];
}
function getDescribeWrapper(defaultOptions, wrapper) {
// Define our describe wrapper
function callDescribe(describeStr, options, describeFn) {
var args = normalizeArguments.apply(this, arguments);
args[1] /* options */ = _.defaults({}, args[1] /* options */, defaultOptions);
describe(describeStr, wrapper.apply(this, args));
}
callDescribe.skip = function (describeStr, options, describeFn) {
var args = normalizeArguments.apply(this, arguments);
args[1] /* options */ = _.defaults({}, args[1] /* options */, defaultOptions);
describe.skip(describeStr, wrapper.apply(this, args));
};
callDescribe.only = function (describeStr, options, describeFn) {
var args = normalizeArguments.apply(this, arguments);
args[1] /* options */ = _.defaults({}, args[1] /* options */, defaultOptions);
describe.only(describeStr, wrapper.apply(this, args));
};
// Return our wrapped describe funciton
return callDescribe;
}
// Define all-in-one base setup
function _scenarioBaseSetup(describeStr, options, describeFn) {
// If we want to start our server, then start it
// DEV: We don't need to start our server in model only tests
if (options.startServer) {
before(function runServer () {
// Perform server start
});
}
// If we want to set up database fixtures, then clean our database and add fixtures
if (options.dbFixtures) {
before(function truncateDatabase (done) {
// Perform truncation
});
before(function installFixtures (done) {
// Perform fixture installation
});
}
// If we have Google fixtures, then run a server
if (options.googleFixtures && options.googleFixtures.length) {
// Start a fake Google server
}
}
function _scenarioRouteTestBaseSetup(describeStr, options, describeFn) {
// Verify we are in a route scenario
assert.strictEqual(this.parent._scenario, SCENARIO_ROUTE,
'Route test expected to be in a `scenario.route` but it wasn\'t');
// Run our normal setup
_scenarioBaseSetup.call(this, describeStr, options, describeFn);
}
// Define route + ACL wrappers
// scenario.route('A request to GET /item/:id', {
// dbFixtures: ['item-default'].concat(dbFixtures.DEFAULT_FIXTURES)
// url: serverUtils.getUrl('/item/' + itemId)
// }, function () {
// this.scenarioUrl = urlDefinedAbove
exports.route = getDescribeWrapper({} /* Doesn't use base setup */,
function _scenarioRouteWrapper (describeStr, options, describeFn) {
return function scenarioRouteFn () {
// Flag our suite with a scenario property
this._scenario = SCENARIO_ROUTE;
// Run describe actions
describeFn.call(this);
// Fallback our options
options = _.defaults({
requiredTests: _.extend({
loggedOut: true,
nonExistent: true,
nonOwner: true
}, options.requiredTests)
}, options);
// Verify we had our ACL methods run
// jscs:disable safeContextKeyword
var suite = this;
// jscs:enable safeContextKeyword
var childSuites = suite.suites;
if (options.requiredTests.loggedOut === true) {
assert(_.findWhere(childSuites, {_scenario: SCENARIO_LOGGED_OUT}),
'`scenario.route(\'' + suite.fullTitle() + '\')` expected to have a loggedOut `scenario` but it didn\'t. ' +
'Please add a `scenario.loggedOut` or disable it via `requiredTests: {loggedOut: false}`');
}
if (options.requiredTests.nonExistent === true) {
assert(_.findWhere(childSuites, {_scenario: SCENARIO_NON_EXISTENT}),
'`scenario.route(\'' + suite.fullTitle() + '\')` expected to have a nonExistent `scenario` but it didn\'t. ' +
'Please add a `scenario.nonExistent` or disable it via `requiredTests: {nonExistent: false}`');
}
if (options.requiredTests.nonOwner === true) {
assert(_.findWhere(childSuites, {_scenario: SCENARIO_NON_OWNER}),
'`scenario.route(\'' + suite.fullTitle() + '\')` expected to have a nonOwner `scenario` but it didn\'t. ' +
'Please add a `scenario.nonOwner` or disable it via `requiredTests: {nonOwner: false}`');
}
};
});
var DEFAULT_ROUTE_TEST_OPTIONS = {
dbFixtures: dbFixtures.DEFAULT_FIXTURES,
// DEV: Later services might want to add/remove a single fixture
// We could support that via `{add: [], remove: [], removeAll: true}`
// Default behavior would be `[overrides] = {add: [overrides], removeAll: true}`
googleFixtures: fakeGoogleFactory.DEFAULT_FIXTURES,
startServer: true
};
exports.routeTest = getDescribeWrapper(DEFAULT_ROUTE_TEST_OPTIONS,
function _scenarioRouteTestWrapper (describeStr, options, describeFn) {
return function scenarioRouteTestFn () {
// Flag our suite with a scenario property
this._scenario = SCENARIO_ROUTE_TEST;
// Run our base setup
_scenarioRouteTestBaseSetup.call(this, describeStr, options, describeFn);
// Run describe actions
describeFn.call(this);
};
});
exports.loggedOut = getDescribeWrapper(_.defaults({
// Set up no fixtures
dbFixtures: null,
// Don't set up Google server as we don't log in
googleFixtures: []
}, DEFAULT_ROUTE_TEST_OPTIONS), function _scenarioLoggedOutWrapper (describeStr, options, describeFn) {
return function scenarioLoggedOutFn () {
this._scenario = SCENARIO_LOGGED_OUT;
_scenarioRouteTestBaseSetup.call(this, describeStr, options, describeFn);
describeFn.call(this);
};
});
exports.nonExistent = getDescribeWrapper(DEFAULT_ROUTE_TEST_OPTIONS,
function _scenarioNonExistentWrapper (describeStr, options, describeFn) {
return function scenarioNonExistentFn () {
this._scenario = SCENARIO_NON_EXISTENT;
_scenarioRouteTestBaseSetup.call(this, describeStr, options, describeFn);
describeFn.call(this);
};
});
exports.nonOwner = getDescribeWrapper(DEFAULT_ROUTE_TEST_OPTIONS,
function _scenarioNonOwnerWrapper (describeStr, options, describeFn) {
return function scenarioNonOwnerFn () {
this._scenario = SCENARIO_NON_OWNER;
_scenarioRouteTestBaseSetup.call(this, describeStr, options, describeFn);
describeFn.call(this);
};
});
// Define model wrappers
exports.model = getDescribeWrapper({
// Truncate all fixtures
dbFixtures: [],
// Don't set up Google server as we don't log in
googleFixtures: [],
// Don't start our server
startServer: false
}, function _scenarioModelWrapper (describeStr, options, describeFn) {
return function scenarioModelFn () {
// Run describe actions
_scenarioBaseSetup.call(this, describeStr, options, describeFn);
describeFn.call(this);
};
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment