Deleted packages migrated to SDK

refs https://github.com/TryGhost/Toolbox/issues/354

- these packages were migrated to the SDK repository so
  they aren't needed here any more
This commit is contained in:
Daniel Lockyer 2022-07-26 14:38:09 +02:00
parent 48cc229b21
commit 7fa997c91d
42 changed files with 0 additions and 3315 deletions

View File

@ -1,6 +0,0 @@
module.exports = {
plugins: ['ghost'],
extends: [
'plugin:ghost/node'
]
};

View File

@ -1,21 +0,0 @@
MIT License
Copyright (c) 2013-2022 Ghost Foundation
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -1,39 +0,0 @@
# Config Url Helpers
## Install
`npm install @tryghost/config-url-helpers --save`
or
`yarn add @tryghost/config-url-helpers`
## Usage
## Develop
This is a mono repository, managed with [lerna](https://lernajs.io/).
Follow the instructions for the top-level repo.
1. `git clone` this repo & `cd` into it as usual
2. Run `yarn` to install top-level dependencies.
## Run
- `yarn dev`
## Test
- `yarn lint` run just eslint
- `yarn test` run lint and tests
# Copyright & License
Copyright (c) 2013-2022 Ghost Foundation - Released under the [MIT license](LICENSE).

View File

@ -1,15 +0,0 @@
const configUrlHelpers = require('./lib/config-url-helpers');
/**
* @typedef {Object} BoundHelpers
* @property {configUrlHelpers.getSubdirFn} getSubdir
* @property {configUrlHelpers.getSiteUrlFn} getSiteUrl
* @property {configUrlHelpers.getAdminUrlFn} getAdminUrl
*
* @param {*} nconf
*/
module.exports.bindAll = (nconf) => {
Object.keys(configUrlHelpers).forEach((helper) => {
nconf[helper] = configUrlHelpers[helper].bind(nconf);
});
};

View File

@ -1,73 +0,0 @@
const deduplicateSubdirectory = require('./utils/deduplicate-subdirectory');
/**
* Returns a subdirectory URL, if defined so in the config.
* @callback getSubdirFn
* @return {string} a subdirectory if configured.
*/
function getSubdir() {
// Parse local path location
let {pathname} = new URL(this.get('url'));
let subdir;
// Remove trailing slash
if (pathname !== '/') {
pathname = pathname.replace(/\/$/, '');
}
subdir = pathname === '/' ? '' : pathname;
return subdir;
}
/**
* Returns the base URL of the site as set in the config.
*
* Secure:
* If the request is secure, we want to force returning the site url as https.
* Imagine Ghost runs with http, but nginx allows SSL connections.
*
* @callback getSiteUrlFn
* @return {string} returns the url as defined in config, but always with a trailing `/`
*/
function getSiteUrl() {
let siteUrl = this.get('url');
if (!siteUrl.match(/\/$/)) {
siteUrl += '/';
}
return siteUrl;
}
/**
*
* @callback getAdminUrlFn
* @returns {string} returns the url as defined in config, but always with a trailing `/`
*/
function getAdminUrl() {
let adminUrl = this.get('admin:url');
const subdir = this.getSubdir();
if (!adminUrl) {
return;
}
if (!adminUrl.match(/\/$/)) {
adminUrl += '/';
}
adminUrl = `${adminUrl}${subdir}`;
if (!adminUrl.match(/\/$/)) {
adminUrl += '/';
}
adminUrl = deduplicateSubdirectory(adminUrl, this.getSiteUrl());
return adminUrl;
}
module.exports = {
getSubdir,
getSiteUrl,
getAdminUrl
};

View File

@ -1,34 +0,0 @@
const {URL} = require('url');
/**
* Remove duplicated directories from the start of a path or url's path
*
* @param {string} url URL or pathname with possible duplicate subdirectory
* @param {string} rootUrl Root URL with an optional subdirectory
* @returns {string} URL or pathname with any duplicated subdirectory removed
*/
const deduplicateSubdirectory = function deduplicateSubdirectory(url, rootUrl) {
// force root url to always have a trailing-slash for consistent behaviour
if (!rootUrl.endsWith('/')) {
rootUrl = `${rootUrl}/`;
}
// Cleanup any extraneous slashes in url for consistent behaviour
url = url.replace(/(^|[^:])\/\/+/g, '$1/');
const parsedRoot = new URL(rootUrl);
// do nothing if rootUrl does not have a subdirectory
if (parsedRoot.pathname === '/') {
return url;
}
const subdir = parsedRoot.pathname.replace(/(^\/|\/$)+/g, '');
// we can have subdirs that match TLDs so we need to restrict matches to
// duplicates that start with a / or the beginning of the url
const subdirRegex = new RegExp(`(^|/)${subdir}/${subdir}(/|$)`);
return url.replace(subdirRegex, `$1${subdir}/`);
};
module.exports = deduplicateSubdirectory;

View File

@ -1,28 +0,0 @@
{
"name": "@tryghost/config-url-helpers",
"version": "1.0.2",
"repository": "https://github.com/TryGhost/Utils/tree/main/packages/config-url-helpers",
"author": "Ghost Foundation",
"license": "MIT",
"main": "index.js",
"scripts": {
"dev": "echo \"Implement me!\"",
"test": "NODE_ENV=testing c8 --all --reporter text --reporter cobertura --check-coverage mocha './test/**/*.test.js'",
"coverage": "c8 report -r html",
"lint": "eslint . --ext .js --cache",
"posttest": "yarn lint"
},
"files": [
"index.js",
"lib"
],
"publishConfig": {
"access": "public"
},
"devDependencies": {
"c8": "7.12.0",
"mocha": "10.0.0",
"should": "13.2.3",
"sinon": "14.0.0"
}
}

View File

@ -1,6 +0,0 @@
module.exports = {
plugins: ['ghost'],
extends: [
'plugin:ghost/test'
]
};

View File

@ -1,115 +0,0 @@
// Switch these lines once there are useful utils
// const testUtils = require('./utils');
require('./utils');
const sinon = require('sinon');
const configUrlHelpers = require('../');
let nconf;
const fakeConfig = {
url: '',
adminUrl: null
};
describe('Config URL Helpers', function () {
before(function () {
const configFaker = (arg) => {
if (arg === 'url') {
return fakeConfig.url;
} else if (arg === 'admin:url') {
return fakeConfig.adminUrl;
}
};
nconf = {
get: sinon.stub().callsFake(configFaker)
};
configUrlHelpers.bindAll(nconf);
});
describe('getSubdir', function () {
it('url has no subdir', function () {
fakeConfig.url = 'http://my-ghost-blog.com/';
nconf.getSubdir().should.eql('');
});
it('url has subdir', function () {
fakeConfig.url = 'http://my-ghost-blog.com/blog';
nconf.getSubdir().should.eql('/blog');
fakeConfig.url = 'http://my-ghost-blog.com/blog/';
nconf.getSubdir().should.eql('/blog');
fakeConfig.url = 'http://my-ghost-blog.com/my/blog';
nconf.getSubdir().should.eql('/my/blog');
fakeConfig.url = 'http://my-ghost-blog.com/my/blog/';
nconf.getSubdir().should.eql('/my/blog');
});
it('should not return a slash for subdir', function () {
fakeConfig.url = 'http://my-ghost-blog.com';
nconf.getSubdir().should.eql('');
fakeConfig.url = 'http://my-ghost-blog.com/';
nconf.getSubdir().should.eql('');
});
});
describe('getSiteUrl', function () {
it('returns config url', function () {
fakeConfig.url = 'http://example.com/';
nconf.getSiteUrl().should.eql('http://example.com/');
});
it('adds trailing slash', function () {
fakeConfig.url = 'http://example.com';
nconf.getSiteUrl().should.eql('http://example.com/');
});
});
describe('getAdminUrl', function () {
it('returns undefinied if no admin URL is set', function () {
should.not.exist(nconf.getAdminUrl());
});
it('returns config url', function () {
fakeConfig.adminUrl = 'http://admin.example.com/';
nconf.getAdminUrl().should.eql('http://admin.example.com/');
});
it('adds trailing slash', function () {
fakeConfig.adminUrl = 'http://admin.example.com';
nconf.getAdminUrl().should.eql('http://admin.example.com/');
});
it('returns with subdirectory correctly if not provided', function () {
fakeConfig.url = 'http://example.com/blog/';
fakeConfig.adminUrl = 'http://admin.example.com';
nconf.getAdminUrl().should.eql('http://admin.example.com/blog/');
});
it('returns with subdirectory correctly if provided with slash', function () {
fakeConfig.url = 'http://example.com/blog/';
fakeConfig.adminUrl = 'http://admin.example.com/blog/';
nconf.getAdminUrl().should.eql('http://admin.example.com/blog/');
});
it('returns with subdirectory correctly if provided without slash', function () {
fakeConfig.url = 'http://example.com/blog/';
fakeConfig.adminUrl = 'http://admin.example.com/blog';
nconf.getAdminUrl().should.eql('http://admin.example.com/blog/');
});
});
});

View File

@ -1,185 +0,0 @@
// Switch these lines once there are useful utils
// const testUtils = require('./utils');
require('../../utils');
const deduplicateSubdirectory = require('../../../lib/utils/deduplicate-subdirectory');
describe('utils: deduplicateSubdirectory()', function () {
describe('with url', function () {
it('ignores rootUrl with no subdirectory', function () {
let url = 'http://example.com/my/my/path.png';
deduplicateSubdirectory(url, 'https://example.com')
.should.eql('http://example.com/my/my/path.png', 'without root trailing-slash');
deduplicateSubdirectory(url, 'https://example.com/')
.should.eql('http://example.com/my/my/path.png', 'with root trailing-slash');
});
it('deduplicates single directory', function () {
let url = 'http://example.com/subdir/subdir/my/path.png';
deduplicateSubdirectory(url, 'http://example.com/subdir')
.should.eql('http://example.com/subdir/my/path.png', 'without root trailing-slash');
deduplicateSubdirectory(url, 'http://example.com/subdir/')
.should.eql('http://example.com/subdir/my/path.png', 'with root trailing-slash');
});
it('deduplicates multiple directories', function () {
let url = 'http://example.com/my/subdir/my/subdir/my/path.png';
deduplicateSubdirectory(url, 'http://example.com/my/subdir')
.should.eql('http://example.com/my/subdir/my/path.png', 'without root trailing-slash');
deduplicateSubdirectory(url, 'http://example.com/my/subdir/')
.should.eql('http://example.com/my/subdir/my/path.png', 'with root trailing-slash');
});
it('handles file that matches subdirectory', function () {
let url = 'http://example.com/my/path/my/path.png';
deduplicateSubdirectory(url, 'http://example.com/my/path')
.should.eql('http://example.com/my/path/my/path.png', 'without root trailing-slash');
deduplicateSubdirectory(url, 'http://example.com/my/path/')
.should.eql('http://example.com/my/path/my/path.png', 'with root trailing-slash');
});
it('handles subdirectory that matches tld', function () {
let url = 'http://example.blog/blog/file.png';
deduplicateSubdirectory(url, 'http://example.blog/blog/subdir')
.should.eql('http://example.blog/blog/file.png', 'without root trailing-slash');
deduplicateSubdirectory(url, 'http://example.blog/blog/subdir/')
.should.eql('http://example.blog/blog/file.png', 'with root trailing-slash');
});
it('keeps query and hash params', function () {
let url = 'http://example.blog/blog/blog/file.png?test=true#testing';
deduplicateSubdirectory(url, 'http://example.blog/blog/subdir')
.should.eql('http://example.blog/blog/blog/file.png?test=true#testing', 'without root trailing-slash');
deduplicateSubdirectory(url, 'http://example.blog/blog/subdir/')
.should.eql('http://example.blog/blog/blog/file.png?test=true#testing', 'with root trailing-slash');
});
});
describe('with path', function () {
it('ignores rootUrl with no subdirectory', function () {
let path = '/my/my/path.png';
deduplicateSubdirectory(path, 'https://example.com')
.should.eql('/my/my/path.png', 'without root trailing-slash');
deduplicateSubdirectory(path, 'https://example.com/')
.should.eql('/my/my/path.png', 'with root trailing-slash');
});
it('deduplicates single directory', function () {
let path = '/subdir/subdir/my/path.png';
deduplicateSubdirectory(path, 'https://example.com/subdir')
.should.eql('/subdir/my/path.png', 'without root trailing-slash');
deduplicateSubdirectory(path, 'https://example.com/subdir/')
.should.eql('/subdir/my/path.png', 'with root trailing-slash');
});
it('deduplicates multiple directories', function () {
let path = '/my/subdir/my/subdir/my/path.png';
deduplicateSubdirectory(path, 'http://example.com/my/subdir')
.should.eql('/my/subdir/my/path.png', 'without root trailing-slash');
deduplicateSubdirectory(path, 'http://example.com/my/subdir/')
.should.eql('/my/subdir/my/path.png', 'with root trailing-slash');
});
it('handles file that matches subdirectory', function () {
let path = '/my/path/my/path.png';
deduplicateSubdirectory(path, 'http://example.com/my/path')
.should.eql('/my/path/my/path.png', 'without root trailing-slash');
deduplicateSubdirectory(path, 'http://example.com/my/path/')
.should.eql('/my/path/my/path.png', 'with root trailing-slash');
});
it('handles subdirectory that matches tld', function () {
let path = '/blog/file.png';
deduplicateSubdirectory(path, 'http://example.blog/blog/subdir')
.should.eql('/blog/file.png', 'without root trailing-slash');
deduplicateSubdirectory(path, 'http://example.blog/blog/subdir/')
.should.eql('/blog/file.png', 'with root trailing-slash');
});
it('keeps query and hash params', function () {
let path = '/blog/blog/file.png?test=true#testing';
deduplicateSubdirectory(path, 'http://example.blog/blog/subdir')
.should.eql('/blog/blog/file.png?test=true#testing', 'without root trailing-slash');
deduplicateSubdirectory(path, 'http://example.blog/blog/subdir/')
.should.eql('/blog/blog/file.png?test=true#testing', 'with root trailing-slash');
});
it('deduplicates path with no trailing slash that matches subdir', function () {
deduplicateSubdirectory('/blog/blog', 'http://example.com/blog')
.should.equal('/blog/', 'without root trailing-slash');
deduplicateSubdirectory('/blog/blog', 'http://example.com/blog/')
.should.equal('/blog/', 'with root trailing-slash');
});
});
describe('with multiple slashes', function () {
it('handles double slash between root and subdir', function () {
deduplicateSubdirectory('http://admin.example.com//blog/blog/', 'http://example.com/blog')
.should.equal('http://admin.example.com/blog/', 'without root trailing-slash');
deduplicateSubdirectory('http://admin.example.com//blog/blog/', 'http://example.com/blog/')
.should.equal('http://admin.example.com/blog/', 'with root trailing-slash');
deduplicateSubdirectory('http://admin.example.com//blog/', 'http://example.com/blog')
.should.equal('http://admin.example.com/blog/', 'without root trailing-slash');
deduplicateSubdirectory('http://admin.example.com//blog/', 'http://example.com/blog/')
.should.equal('http://admin.example.com/blog/', 'with root trailing-slash');
});
it('handles double slash between duplicates', function () {
deduplicateSubdirectory('http://admin.example.com/blog//blog/', 'http://example.com/blog')
.should.equal('http://admin.example.com/blog/', 'without root trailing-slash');
deduplicateSubdirectory('http://admin.example.com/blog//blog/', 'http://example.com/blog/')
.should.equal('http://admin.example.com/blog/', 'with root trailing-slash');
});
it('handles double slash after subdirs', function () {
deduplicateSubdirectory('http://admin.example.com/blog/blog//', 'http://example.com/blog')
.should.equal('http://admin.example.com/blog/', 'without root trailing-slash');
deduplicateSubdirectory('http://admin.example.com/blog/blog//', 'http://example.com/blog/')
.should.equal('http://admin.example.com/blog/', 'with root trailing-slash');
deduplicateSubdirectory('http://admin.example.com/blog//', 'http://example.com/blog')
.should.equal('http://admin.example.com/blog/', 'without root trailing-slash');
deduplicateSubdirectory('http://admin.example.com/blog//', 'http://example.com/blog/')
.should.equal('http://admin.example.com/blog/', 'with root trailing-slash');
});
it('handles double slashes everywhere', function () {
deduplicateSubdirectory('http://admin.example.com//blog//blog//', 'http://example.com/blog')
.should.equal('http://admin.example.com/blog/', 'without root trailing-slash');
deduplicateSubdirectory('http://admin.example.com//blog//blog//', 'http://example.com/blog/')
.should.equal('http://admin.example.com/blog/', 'with root trailing-slash');
});
});
});

View File

@ -1,11 +0,0 @@
/**
* Custom Should Assertions
*
* Add any custom assertions to this file.
*/
// Example Assertion
// should.Assertion.add('ExampleAssertion', function () {
// this.params = {operator: 'to be a valid Example Assertion'};
// this.obj.should.be.an.Object;
// });

View File

@ -1,11 +0,0 @@
/**
* Test Utilities
*
* Shared utils for writing tests
*/
// Require overrides - these add globals for tests
require('./overrides');
// Require assertions - adds custom should assertions
require('./assertions');

View File

@ -1,10 +0,0 @@
// This file is required before any test is run
// Taken from the should wiki, this is how to make should global
// Should is a global in our eslint test config
global.should = require('should').noConflict();
should.extend();
// Sinon is a simple case
// Sinon is a global in our eslint test config
global.sinon = require('sinon');

View File

@ -1,6 +0,0 @@
module.exports = {
plugins: ['ghost'],
extends: [
'plugin:ghost/node'
]
};

View File

@ -1,21 +0,0 @@
MIT License
Copyright (c) 2013-2022 Ghost Foundation
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -1,39 +0,0 @@
# Image Transform
## Install
`npm install @tryghost/image-transform --save`
or
`yarn add @tryghost/image-transform`
## Usage
## Develop
This is a mono repository, managed with [lerna](https://lernajs.io/).
Follow the instructions for the top-level repo.
1. `git clone` this repo & `cd` into it as usual
2. Run `yarn` to install top-level dependencies.
## Run
- `yarn dev`
## Test
- `yarn lint` run just eslint
- `yarn test` run lint and tests
# Copyright & License
Copyright (c) 2013-2022 Ghost Foundation - Released under the [MIT license](LICENSE).

View File

@ -1 +0,0 @@
module.exports = require('./lib/transform');

View File

@ -1,154 +0,0 @@
const Promise = require('bluebird');
const errors = require('@tryghost/errors');
const fs = require('fs-extra');
const path = require('path');
/**
* Check if this tool can handle any file transformations as Sharp is an optional dependency
*/
const canTransformFiles = () => {
try {
require('sharp');
return true;
} catch (err) {
return false;
}
};
/**
* Check if this tool can handle a particular extension
* @param {String} ext the extension to check, including the leading dot
*/
const canTransformFileExtension = ext => !['.ico'].includes(ext);
/**
* Check if this tool can handle a particular extension, only to resize (= not convert format)
* - In this case we don't want to resize SVG's (doesn't save file size)
* - We don't want to resize GIF's (because we would lose the animation)
* So this is a 'should' instead of a 'could'. Because Sharp can handle them, but animations are lost.
* This is 'resize' instead of 'transform', because for the transform we might want to convert a SVG to a PNG, which is perfectly possible.
* @param {String} ext the extension to check, including the leading dot
*/
const shouldResizeFileExtension = ext => !['.ico', '.svg', '.svgz'].includes(ext);
/**
* Can we output animation (prevents outputting animated JPGs that are just all the pages listed under each other)
* Sharp doesn't support AVIF image sequences yet (animation)
* @param {keyof import('sharp').FormatEnum} format the extension to check, EXCLUDING the leading dot
*/
const doesFormatSupportAnimation = format => ['webp', 'gif'].includes(format);
/**
* Check if this tool can convert to a particular format (used in the format option of ResizeFromBuffer)
* @param {String} format the format to check, EXCLUDING the leading dot
* @returns {ext is keyof import('sharp').FormatEnum}
*/
const canTransformToFormat = format => [
'gif',
'jpeg',
'jpg',
'png',
'webp',
'avif'
].includes(format);
/**
* @NOTE: Sharp cannot operate on the same image path, that's why we have to use in & out paths.
*
* We currently can't enable compression or having more config options, because of
* https://github.com/lovell/sharp/issues/1360.
*
* Resize an image referenced by the `in` path and write it to the `out` path
* @param {{in, out, width}} options
*/
const unsafeResizeFromPath = (options = {}) => {
return fs.readFile(options.in)
.then((data) => {
return unsafeResizeFromBuffer(data, {
width: options.width
});
})
.then((data) => {
return fs.writeFile(options.out, data);
});
};
/**
* Resize an image
*
* @param {Buffer} originalBuffer image to resize
* @param {{width?: number, height?: number, format?: keyof import('sharp').FormatEnum, animated?: boolean, withoutEnlargement?: boolean}} [options]
* options.animated defaults to true for file formats where animation is supported (will always maintain animation if possible)
* @returns {Promise<Buffer>} the resizedBuffer
*/
const unsafeResizeFromBuffer = async (originalBuffer, options = {}) => {
const sharp = require('sharp');
// Disable the internal libvips cache - https://sharp.pixelplumbing.com/api-utility#cache
sharp.cache(false);
// It is safe to set animated to true for all formats, because if the input image doesn't contain animation
// nothing will change.
let animated = options.animated ?? true;
if (options.format) {
// Only set animated to true if the output format supports animation
// Else we end up with multiple images stacked on top of each other (from the source image)
animated = doesFormatSupportAnimation(options.format);
}
let s = sharp(originalBuffer, {animated})
.resize(options.width, options.height, {
// CASE: dont make the image bigger than it was
withoutEnlargement: options.withoutEnlargement ?? true
})
// CASE: Automatically remove metadata and rotate based on the orientation.
.rotate();
if (options.format) {
s = s.toFormat(options.format);
}
const resizedBuffer = await s.toBuffer();
return options.format || resizedBuffer.length < originalBuffer.length ? resizedBuffer : originalBuffer;
};
/**
* Internal utility to wrap all transform functions in error handling
* Allows us to keep Sharp as an optional dependency
*
* @param {T} fn
* @return {T}
* @template {Function} T
*/
const makeSafe = fn => (...args) => {
try {
require('sharp');
} catch (err) {
return Promise.reject(new errors.InternalServerError({
message: 'Sharp wasn\'t installed',
code: 'SHARP_INSTALLATION',
err: err
}));
}
return fn(...args).catch((err) => {
throw new errors.InternalServerError({
message: 'Unable to manipulate image.',
err: err,
code: 'IMAGE_PROCESSING'
});
});
};
const generateOriginalImageName = (originalPath) => {
const parsedFileName = path.parse(originalPath);
return path.join(parsedFileName.dir, `${parsedFileName.name}_o${parsedFileName.ext}`);
};
module.exports.canTransformFiles = canTransformFiles;
module.exports.canTransformFileExtension = canTransformFileExtension;
module.exports.shouldResizeFileExtension = shouldResizeFileExtension;
module.exports.canTransformToFormat = canTransformToFormat;
module.exports.generateOriginalImageName = generateOriginalImageName;
module.exports.resizeFromPath = makeSafe(unsafeResizeFromPath);
module.exports.resizeFromBuffer = makeSafe(unsafeResizeFromBuffer);

View File

@ -1,35 +0,0 @@
{
"name": "@tryghost/image-transform",
"version": "1.2.1",
"repository": "https://github.com/TryGhost/Utils/tree/main/packages/image-transform",
"author": "Ghost Foundation",
"license": "MIT",
"main": "index.js",
"scripts": {
"dev": "echo \"Implement me!\"",
"test": "NODE_ENV=testing c8 --all --reporter text --reporter cobertura mocha './test/**/*.test.js'",
"lint": "eslint . --ext .js --cache",
"posttest": "yarn lint"
},
"files": [
"index.js",
"lib"
],
"publishConfig": {
"access": "public"
},
"devDependencies": {
"c8": "7.12.0",
"mocha": "10.0.0",
"should": "13.2.3",
"sinon": "14.0.0"
},
"dependencies": {
"@tryghost/errors": "^1.2.1",
"bluebird": "^3.7.2",
"fs-extra": "^10.0.0"
},
"optionalDependencies": {
"sharp": "^0.30.0"
}
}

View File

@ -1,6 +0,0 @@
module.exports = {
plugins: ['ghost'],
extends: [
'plugin:ghost/test'
]
};

View File

@ -1,184 +0,0 @@
// Switch these lines once there are useful utils
const testUtils = require('./utils');
const fs = require('fs-extra');
const errors = require('@tryghost/errors');
const transform = require('../');
describe('Transform', function () {
afterEach(function () {
sinon.restore();
testUtils.modules.unmockNonExistentModule();
});
describe('canTransformFiles', function () {
it('returns true when sharp is available', function () {
transform.canTransformFiles().should.be.true;
});
it('returns false when sharp is not available', function () {
testUtils.modules.mockNonExistentModule('sharp', new Error(), true);
transform.canTransformFiles().should.be.false;
});
});
describe('canTransformFileExtension', function () {
it('returns true for ".gif"', function () {
should.equal(
transform.canTransformFileExtension('.gif'),
true
);
});
it('returns true for ".svg"', function () {
should.equal(
transform.canTransformFileExtension('.svg'),
true
);
});
it('returns true for ".svgz"', function () {
should.equal(
transform.canTransformFileExtension('.svgz'),
true
);
});
it('returns false for ".ico"', function () {
should.equal(
transform.canTransformFileExtension('.ico'),
false
);
});
});
describe('shouldResizeFileExtension', function () {
it('returns true for ".gif"', function () {
should.equal(
transform.shouldResizeFileExtension('.gif'),
true
);
});
it('returns false for ".svg"', function () {
should.equal(
transform.shouldResizeFileExtension('.svg'),
false
);
});
it('returns false for ".svgz"', function () {
should.equal(
transform.shouldResizeFileExtension('.svgz'),
false
);
});
it('returns false for ".ico"', function () {
should.equal(
transform.shouldResizeFileExtension('.ico'),
false
);
});
});
describe('cases', function () {
let sharp, sharpInstance;
beforeEach(function () {
sinon.stub(fs, 'readFile').resolves('original');
sinon.stub(fs, 'writeFile').resolves();
sharpInstance = {
resize: sinon.stub().returnsThis(),
rotate: sinon.stub().returnsThis(),
toBuffer: sinon.stub()
};
sharp = sinon.stub().callsFake(() => {
return sharpInstance;
});
sharp.cache = sinon.stub().returns({});
testUtils.modules.mockNonExistentModule('sharp', sharp);
});
it('resize image', function () {
sharpInstance.toBuffer.resolves('manipulated');
return transform.resizeFromPath({width: 1000})
.then(() => {
sharpInstance.resize.calledOnce.should.be.true();
sharpInstance.rotate.calledOnce.should.be.true();
fs.writeFile.calledOnce.should.be.true();
fs.writeFile.calledWith('manipulated');
});
});
it('skip resizing if image is too small', function () {
sharpInstance.toBuffer.resolves('manipulated');
return transform.resizeFromPath({width: 1000})
.then(() => {
sharpInstance.resize.calledOnce.should.be.true();
should.deepEqual(sharpInstance.resize.args[0][2], {
withoutEnlargement: true
});
fs.writeFile.calledOnce.should.be.true();
fs.writeFile.calledWith('manipulated');
});
});
it('uses original image as an output when the size (bytes) is bigger after manipulation', function () {
sharpInstance.toBuffer.resolves('manipulated to a very very very very very very very large size');
return transform.resizeFromPath({width: 1000})
.then(() => {
sharpInstance.resize.calledOnce.should.be.true();
sharpInstance.rotate.calledOnce.should.be.true();
sharpInstance.toBuffer.calledOnce.should.be.true();
fs.writeFile.calledOnce.should.be.true();
fs.writeFile.calledWith('original');
});
});
it('sharp throws error during processing', function () {
sharpInstance.toBuffer.resolves('manipulated');
fs.writeFile.rejects(new Error('whoops'));
return transform.resizeFromPath({width: 2000})
.then(() => {
'1'.should.eql(1, 'Expected to fail');
})
.catch((err) => {
(err instanceof errors.InternalServerError).should.be.true;
err.code.should.eql('IMAGE_PROCESSING');
});
});
});
describe('installation', function () {
beforeEach(function () {
testUtils.modules.mockNonExistentModule('sharp', new Error(), true);
});
it('sharp was not installed', function () {
return transform.resizeFromPath()
.then(() => {
'1'.should.eql(1, 'Expected to fail');
})
.catch((err) => {
(err instanceof errors.InternalServerError).should.be.true();
err.code.should.eql('SHARP_INSTALLATION');
});
});
});
describe('generateOriginalImageName', function () {
it('correctly adds suffix', function () {
transform.generateOriginalImageName('test.jpg').should.eql('test_o.jpg');
transform.generateOriginalImageName('content/images/test.jpg').should.eql('content/images/test_o.jpg');
transform.generateOriginalImageName('content/images/test_o.jpg').should.eql('content/images/test_o_o.jpg');
transform.generateOriginalImageName('content/images/test-1.jpg').should.eql('content/images/test-1_o.jpg');
});
});
});

View File

@ -1,11 +0,0 @@
/**
* Custom Should Assertions
*
* Add any custom assertions to this file.
*/
// Example Assertion
// should.Assertion.add('ExampleAssertion', function () {
// this.params = {operator: 'to be a valid Example Assertion'};
// this.obj.should.be.an.Object;
// });

View File

@ -1,13 +0,0 @@
/**
* Test Utilities
*
* Shared utils for writing tests
*/
// Require overrides - these add globals for tests
require('./overrides');
// Require assertions - adds custom should assertions
require('./assertions');
module.exports.modules = require('./modules');

View File

@ -1,23 +0,0 @@
const Module = require('module');
const originalRequireFn = Module.prototype.require;
/**
* helper fn to mock non-existent modules
* mocks.modules.mockNonExistentModule(/pattern/, mockedModule)
*/
exports.mockNonExistentModule = (modulePath, module, error = false) => {
Module.prototype.require = function (path) {
if (path.match(modulePath)) {
if (error) {
throw module;
}
return module;
}
return originalRequireFn.apply(this, arguments);
};
};
exports.unmockNonExistentModule = () => {
Module.prototype.require = originalRequireFn;
};

View File

@ -1,10 +0,0 @@
// This file is required before any test is run
// Taken from the should wiki, this is how to make should global
// Should is a global in our eslint test config
global.should = require('should').noConflict();
should.extend();
// Sinon is a simple case
// Sinon is a global in our eslint test config
global.sinon = require('sinon');

View File

@ -1,6 +0,0 @@
module.exports = {
plugins: ['ghost'],
extends: [
'plugin:ghost/node'
]
};

View File

@ -1,21 +0,0 @@
MIT License
Copyright (c) 2013-2022 Ghost Foundation
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -1,222 +0,0 @@
# Limit Service
This module is intended to hold **all of the logic** for testing if site:
- would be over a given limit if they took an action (i.e. added one more thing, switched to a different limit)
- if they are over a limit already
- consistent error messages explaining why the limit has been reached
## Install
`npm install @tryghost/limit-service --save`
or
`yarn add @tryghost/limit-service`
## Usage
Below is a sample code to wire up limit service and perform few common limit checks:
```js
const knex = require('knex');
const errors = require('@tryghost/errors');
const LimitService = require('@tryghost/limit-service');
// create a LimitService instance
const limitService = new LimitService();
// setup limit configuration
// currently supported limit keys are: staff, members, customThemes, customIntegrations, uploads
// all limit configs support custom "error" configuration that is a template string
const limits = {
// staff and member are "max" type of limits accepting "max" configuration
staff: {
max: 1,
error: 'Your plan supports up to {{max}} staff users. Please upgrade to add more.'
},
members: {
max: 1000,
error: 'Your plan supports up to {{max}} members. Please upgrade to reenable publishing.'
},
// customThemes is an allowlist type of limit accepting the "allowlist" configuration
customThemes: {
allowlist: ['casper', 'dawn', 'lyra'],
error: 'All our official built-in themes are available the Starter plan, if you upgrade to one of our higher tiers you will also be able to edit and upload custom themes for your site.'
},
// customIntegrations is a "flag" type of limits accepting disabled boolean configuration
customIntegrations: {
disabled: true,
error: 'You can use all our official, built-in integrations on the Starter plan. If you upgrade to one of our higher tiers, youll also be able to create and edit custom integrations and API keys for advanced workflows.'
},
// emails is a hybrid type of limit that can be a "flag" or a "max periodic" type
// below is a "flag" type configuration
emails: {
disabled: true,
error: 'Email sending has been temporarily disabled whilst your account is under review.'
},
// following is a "max periodic" type of configuration
// note if you use this configuration, the limit service has to also get a
// "subscription" parameter to work as expected
// emails: {
// maxPeriodic: 42,
// error: 'Your plan supports up to {{max}} emails. Please upgrade to reenable sending emails.'
// }
uploads: {
// max key is in bytes
max: 5000000,
// formatting of the {{ max }} vairable is in MB, e.g: 5MB
error: 'Your plan supports uploads of max size up to {{max}}. Please upgrade to reenable uploading.'
}
};
// This information is needed for the limit service to work with "max periodic" limits
// The interval value has to be 'month' as thats the only interval that was needed for
// current usecase
// The startDate has to be in ISO 8601 format (https://en.wikipedia.org/wiki/ISO_8601)
const subscription = {
interval: 'month',
startDate: '2022-09-18T19:00:52Z'
};
// initialize the URL linking to help documentation etc.
const helpLink = 'https://ghost.org/help/';
// initialize knex db connection for the limit service to use when running query checks
const db = {
knex: knex({
client: 'mysql',
connection: {
user: 'root',
password: 'toor',
host: 'localhost',
database: 'ghost',
}
});
};
// finish initializing the limits service
limitService.loadLimits({limits, subscription, db, helpLink, errors});
// perform limit checks
// check if there is a 'staff' limit configured
if (limitService.isLimited('staff')) {
// throws an error if current 'staff' limit **would** go over the limit set up in configuration (max:1)
await limitService.errorIfWouldGoOverLimit('staff');
// same as above but overrides the default max check from max of 1 to 100
// useful in cases you need to check if specific instance would still be over the limit if the limit changed
await limitService.errorIfWouldGoOverLimit('staff', {max: 100});
}
// "max" types of limits have currentCountQuery method reguring a number that is currently in use for the limit
// for example it could be 1, 3, 5 or whatever amount of 'staff' is currently in the system
const staffCount = await limitService.currentCountQuery('staff');
// do something with that number
console.log(`Your current staff count is at: ${staffCount}!`);
// check if there is a 'members' limit configured
if (limitService.isLimited('members')) {
// throws an error if current 'staff' limit **is** over the limit set up in configuration (max: 1000)
await limitService.errorIfIsOverLimit('members');
// same as above but overrides the default max check from max of 1000 to 10000
// useful in cases you need to check if specific instance would still be over the limit if the limit changed
await limitService.errorIfIsOverLimit('members', {max: 10000});
}
if (limitService.isLimited('uploads')) {
// for the uploads limit we HAVE TO pass in the "currentCount" parameter and use bytes as a base unit
await limitService.errorIfIsOverLimit('uploads', {currentCount: frame.file.size});
}
// check if any of the limits are acceding
if (limitService.checkIfAnyOverLimit()) {
console.log('One of the limits has acceded!');
}
```
### Transactions
Some limit types (`max` or `maxPeriodic`) need to fetch the current count from the database. Sometimes you need those checks to also run in a transaction. To fix that, you can pass the `transacting` option to all the available checks.
```js
db.transaction((transacting) => {
const options = {transacting};
await limitService.errorIfWouldGoOverLimit('newsletters', options);
await limitService.errorIfIsOverLimit('newsletters', options);
const a = await limitService.checkIsOverLimit('newsletters', options);
const b = await limitService.checkWouldGoOverLimit('newsletters', options);
const c = await limitService.checkIfAnyOverLimit(options);
});
```
### Types of limits
At the moment there are four different types of limits that limit service allows to define. These types are:
1. `flag` - is an "on/off" switch for certain feature. Example usecase: "disable all emails". It's identified by a `disabled: true` property in the "limits" configuration.
2. `max` - checks if the maximum amount of the resource has been used up.Example usecase: "disable creating a staff user when maximum of 5 has been reached". To configure this limit add `max: NUMBER` to the configuration. The limits that support max checks are: `members`, `staff`, and `customIntegrations`
3. `maxPeriodic` - it's a variation of `max` type with a difference that the check is done over certain period of time. Example usecase: "disable sending emails when the sent emails count has acceded a limit for last billing period". To enable this limit define `maxPeriodic: NUMBER` in the limit configuration and provide a subscription configuration when initializing the limit service instance. The subscription object comes as a separate parameter and has to contain two properties: `startDate` and `interval`, where `startDate` is a date in ISO 8601 format and period is `'month'` (other values like `'year'` are not supported yet)
4. `allowList` - checks if provided value is defined in configured "allowlist". Example usecase: "disable theme activation if it is not an official theme". To configure this limit define ` allowlist: ['VALUE_1', 'VALUE_2', 'VALUE_N']` property in the "limits" parameter.
### Supported limits
There's a limited amount of limits that are supported by limit service. The are defined by "key" property name in the "config" module. List of currently supported limit names: `members`, `staff`, `customIntegrations`, `emails`, `customThemes`, `uploads`.
All limits can act as `flag` or `allowList` types. Only certain (`members`, `staff`, and`customIntegrations`) can have a `max` limit. Only `emails` currently supports the `maxPeriodic` type of limit.
### Frontend usage
In case the limit check is run without direct access to the database you can override `currentCountQuery` functions for each "max" or "maxPeriodic" type of limit. An example usecase would be a frontend client running in a browser. A browser client can check the limit data through HTTP request and then provide that data to the limit service. Example code to do exactly that:
```js
const limitService = new LimitService();
let limits = {
staff: {
max: 2,
currentCountQuery: async () => (await fetch('/api/staff')).json().length
}
};
limitService.loadLimits({limits, errors});
if (await limitService.checkIsOverLimit('staff')) {
// do something as "staff" limit has been reached
};
```
### Custom error messages
Errors returned by the limit service can be customized. When configuring the limit service through `loadLimits` method `limits` objects can specify an `error` property that is a template string. Additionally, "MaxLimit" limit type supports following variables- {{count}} and {{max}}.
An example configuration for "MaxLimit" limit using an error template can look like following:
```json
"staff": {
"max": 5,
"error": "Your plan supports up to {{max}} staff users and you currently have {{count}}. Please upgrade to add more."
}
```
## Develop
This is a mono repository, managed with [lerna](https://lernajs.io/).
Follow the instructions for the top-level repo.
1. `git clone` this repo & `cd` into it as usual
2. Run `yarn` to install top-level dependencies.
## Run
- `yarn dev`
## Test
- `yarn lint` run just eslint
- `yarn test` run lint and tests
# Copyright & License
Copyright (c) 2013-2022 Ghost Foundation - Released under the [MIT license](LICENSE).

View File

@ -1,68 +0,0 @@
// NOTE: to support a new config in the limit service add an empty key-object pair in the export below.
// Each type of limit has it's own structure:
// 1. FlagLimit and AllowlistLimit types are empty objects paired with a key, e.g.: `customThemes: {}`
// 2. MaxLimit should contain a `currentCountQuery` function which would count the resources under limit
module.exports = {
members: {
currentCountQuery: async (knex) => {
let result = await knex('members').count('id', {as: 'count'}).first();
return result.count;
}
},
newsletters: {
currentCountQuery: async (knex) => {
let result = await knex('newsletters')
.count('id', {as: 'count'})
.where('status', '=', 'active')
.first();
return result.count;
}
},
emails: {
currentCountQuery: async (knex, startDate) => {
let result = await knex('emails')
.sum('email_count', {as: 'count'})
.where('created_at', '>=', startDate)
.first();
return result.count;
}
},
staff: {
currentCountQuery: async (knex) => {
let result = await knex('users')
.select('users.id')
.leftJoin('roles_users', 'users.id', 'roles_users.user_id')
.leftJoin('roles', 'roles_users.role_id', 'roles.id')
.whereNot('roles.name', 'Contributor').andWhereNot('users.status', 'inactive').union([
knex('invites')
.select('invites.id')
.leftJoin('roles', 'invites.role_id', 'roles.id')
.whereNot('roles.name', 'Contributor')
]);
return result.length;
}
},
customIntegrations: {
currentCountQuery: async (knex) => {
let result = await knex('integrations')
.count('id', {as: 'count'})
.whereNotIn('type', ['internal', 'builtin'])
.first();
return result.count;
}
},
customThemes: {},
uploads: {
// NOTE: this function should not ever be used as for uploads we compare the size
// of the uploaded file with the configured limit. Noop is here to keep the
// MaxLimit constructor happy
currentCountQuery: () => {},
// NOTE: the uploads limit is based on file sizes provided in Bytes
// a custom formatter is here for more user-friendly formatting when forming an error
formatter: count => `${count / 1000000}MB`
}
};

View File

@ -1,37 +0,0 @@
const {DateTime} = require('luxon');
const {IncorrectUsageError} = require('@tryghost/errors');
const messages = {
invalidInterval: 'Invalid interval specified. Only "month" value is accepted.'
};
const SUPPORTED_INTERVALS = ['month'];
/**
* Calculates the start of the last period (billing, cycle, etc.) based on the start date
* and the interval at which the cycle renews.
*
* @param {String} startDate - date in ISO 8601 format (https://en.wikipedia.org/wiki/ISO_8601)
* @param {('month')} interval - currently only supports 'month' value, in the future might support 'year', etc.
*
* @returns {String} - date in ISO 8601 format (https://en.wikipedia.org/wiki/ISO_8601) of the last period start
*/
const lastPeriodStart = (startDate, interval) => {
if (interval === 'month') {
const startDateISO = DateTime.fromISO(startDate, {zone: 'UTC'});
const now = DateTime.now().setZone('UTC');
const fullPeriodsPast = Math.floor(now.diff(startDateISO, 'months').months);
const lastPeriodStartDate = startDateISO.plus({months: fullPeriodsPast});
return lastPeriodStartDate.toISO();
}
throw new IncorrectUsageError({
message: messages.invalidInterval
});
};
module.exports = {
lastPeriodStart,
SUPPORTED_INTERVALS
};

View File

@ -1,175 +0,0 @@
const {MaxLimit, MaxPeriodicLimit, FlagLimit, AllowlistLimit} = require('./limit');
const config = require('./config');
const {IncorrectUsageError} = require('@tryghost/errors');
const _ = require('lodash');
const messages = {
missingErrorsConfig: `Config Missing: 'errors' is required.`,
noSubscriptionParameter: 'Attempted to setup a periodic max limit without a subscription'
};
class LimitService {
constructor() {
this.limits = {};
}
/**
* Initializes the limits based on configuration
*
* @param {Object} options
* @param {Object} [options.limits] - hash containing limit configurations keyed by limit name and containing
* @param {Object} [options.subscription] - hash containing subscription configuration with interval and startDate properties
* @param {String} options.helpLink - URL pointing to help resources for when limit is reached
* @param {Object} options.db - knex db connection instance or other data source for the limit checks
* @param {Object} options.errors - instance of errors compatible with GhostError errors (@tryghost/errors)
*/
loadLimits({limits = {}, subscription, helpLink, db, errors}) {
if (!errors) {
throw new IncorrectUsageError({
message: messages.missingErrorsConfig
});
}
this.errors = errors;
// CASE: reset internal limits state in case load is called multiple times
this.limits = {};
Object.keys(limits).forEach((name) => {
name = _.camelCase(name);
// NOTE: config module acts as an allowlist of supported config names, where each key is a name of supported config
if (config[name]) {
/** @type LimitConfig */
let limitConfig = Object.assign({}, config[name], limits[name]);
if (_.has(limitConfig, 'allowlist')) {
this.limits[name] = new AllowlistLimit({name, config: limitConfig, helpLink, errors});
} else if (_.has(limitConfig, 'max')) {
this.limits[name] = new MaxLimit({name: name, config: limitConfig, helpLink, db, errors});
} else if (_.has(limitConfig, 'maxPeriodic')) {
if (subscription === undefined) {
throw new IncorrectUsageError({
message: messages.noSubscriptionParameter
});
}
const maxPeriodicLimitConfig = Object.assign({}, limitConfig, subscription);
this.limits[name] = new MaxPeriodicLimit({name: name, config: maxPeriodicLimitConfig, helpLink, db, errors});
} else {
this.limits[name] = new FlagLimit({name: name, config: limitConfig, helpLink, errors});
}
}
});
}
isLimited(limitName) {
return !!this.limits[_.camelCase(limitName)];
}
/**
*
* @param {String} limitName - name of the configured limit
* @param {Object} [options] - limit parameters
* @param {Object} [options.transacting] Transaction to run the count query on (if required for the chosen limit)
* @returns
*/
async checkIsOverLimit(limitName, options = {}) {
if (!this.isLimited(limitName)) {
return;
}
try {
await this.limits[limitName].errorIfIsOverLimit(options);
return false;
} catch (error) {
if (error instanceof this.errors.HostLimitError) {
return true;
}
throw error;
}
}
/**
*
* @param {String} limitName - name of the configured limit
* @param {Object} [options] - limit parameters
* @param {Object} [options.transacting] Transaction to run the count query on (if required for the chosen limit)
* @returns
*/
async checkWouldGoOverLimit(limitName, options = {}) {
if (!this.isLimited(limitName)) {
return;
}
try {
await this.limits[limitName].errorIfWouldGoOverLimit(options);
return false;
} catch (error) {
if (error instanceof this.errors.HostLimitError) {
return true;
}
throw error;
}
}
/**
*
* @param {String} limitName - name of the configured limit
* @param {Object} [options] - limit parameters
* @param {Object} [options.transacting] Transaction to run the count query on (if required for the chosen limit)
* @returns
*/
async errorIfIsOverLimit(limitName, options = {}) {
if (!this.isLimited(limitName)) {
return;
}
await this.limits[limitName].errorIfIsOverLimit(options);
}
/**
*
* @param {String} limitName - name of the configured limit
* @param {Object} [options] - limit parameters
* @param {Object} [options.transacting] Transaction to run the count query on (if required for the chosen limit)
* @returns
*/
async errorIfWouldGoOverLimit(limitName, options = {}) {
if (!this.isLimited(limitName)) {
return;
}
await this.limits[limitName].errorIfWouldGoOverLimit(options);
}
/**
* Checks if any of the configured limits acceded
*
* @param {Object} [options] - limit parameters
* @param {Object} [options.transacting] Transaction to run the count queries on (if required for the chosen limit)
* @returns {Promise<boolean>}
*/
async checkIfAnyOverLimit(options = {}) {
for (const limit in this.limits) {
if (await this.checkIsOverLimit(limit, options)) {
return true;
}
}
return false;
}
}
module.exports = LimitService;
/**
* @typedef {Object} LimitConfig
* @prop {Number} [max] - max limit
* @prop {Number} [maxPeriodic] - max limit for a period
* @prop {Boolean} [disabled] - flag disabling/enabling limit
* @prop {String} error - custom error to be displayed when the limit is reached
* @prop {Function} [currentCountQuery] - function returning count for the "max" type of limit
*/

View File

@ -1,365 +0,0 @@
// run in context allows us to change the templateSettings without causing havoc
const _ = require('lodash').runInContext();
const {lastPeriodStart, SUPPORTED_INTERVALS} = require('./date-utils');
_.templateSettings.interpolate = /{{([\s\S]+?)}}/g;
class Limit {
/**
*
* @param {Object} options
* @param {String} options.name - name of the limit
* @param {String} options.error - error message to use when limit is reached
* @param {String} options.helpLink - URL to the resource explaining how the limit works
* @param {Object} [options.db] - instance of knex db connection that currentCountQuery can use to run state check through
* @param {Object} options.errors - instance of errors compatible with GhostError errors (@tryghost/errors)
*/
constructor({name, error, helpLink, db, errors}) {
this.name = name;
this.error = error;
this.helpLink = helpLink;
this.db = db;
this.errors = errors;
}
generateError() {
let errorObj = {
errorDetails: {
name: this.name
}
};
if (this.helpLink) {
errorObj.help = this.helpLink;
}
return errorObj;
}
}
class MaxLimit extends Limit {
/**
*
* @param {Object} options
* @param {String} options.name - name of the limit
* @param {Object} options.config - limit configuration
* @param {Number} options.config.max - maximum limit the limit would check against
* @param {Function} options.config.currentCountQuery - query checking the state that would be compared against the limit
* @param {Function} [options.config.formatter] - function to format the limit counts before they are passed to the error message
* @param {String} [options.config.error] - error message to use when limit is reached
* @param {String} [options.helpLink] - URL to the resource explaining how the limit works
* @param {Object} [options.db] - instance of knex db connection that currentCountQuery can use to run state check through
* @param {Object} options.errors - instance of errors compatible with GhostError errors (@tryghost/errors)
*/
constructor({name, config, helpLink, db, errors}) {
super({name, error: config.error || '', helpLink, db, errors});
if (config.max === undefined) {
throw new errors.IncorrectUsageError({message: 'Attempted to setup a max limit without a limit'});
}
if (!config.currentCountQuery) {
throw new errors.IncorrectUsageError({message: 'Attempted to setup a max limit without a current count query'});
}
this.currentCountQueryFn = config.currentCountQuery;
this.max = config.max;
this.formatter = config.formatter;
this.fallbackMessage = `This action would exceed the ${_.lowerCase(this.name)} limit on your current plan.`;
}
/**
*
* @param {Number} count - current count that acceded the limit
* @returns {Object} instance of HostLimitError
*/
generateError(count) {
let errorObj = super.generateError();
errorObj.message = this.fallbackMessage;
if (this.error) {
const formatter = this.formatter || Intl.NumberFormat().format;
try {
errorObj.message = _.template(this.error)(
{
max: formatter(this.max),
count: formatter(count),
name: this.name
});
} catch (e) {
errorObj.message = this.fallbackMessage;
}
}
errorObj.errorDetails.limit = this.max;
errorObj.errorDetails.total = count;
return new this.errors.HostLimitError(errorObj);
}
/**
* @param {Object} [options]
* @param {Object} [options.transacting] Transaction to run the count query on
* @returns
*/
async currentCountQuery(options = {}) {
return await this.currentCountQueryFn(options.transacting ?? this.db?.knex);
}
/**
* Throws a HostLimitError if the configured or passed max limit is acceded by currentCountQuery
*
* @param {Object} options
* @param {Number} [options.max] - overrides configured default max value to perform checks against
* @param {Number} [options.addedCount] - number of items to add to the currentCount during the check
* @param {Object} [options.transacting] Transaction to run the count query on
*/
async errorIfWouldGoOverLimit(options = {}) {
const {max, addedCount = 1} = options;
let currentCount = await this.currentCountQuery(options);
if ((currentCount + addedCount) > (max || this.max)) {
throw this.generateError(currentCount);
}
}
/**
* Throws a HostLimitError if the configured or passed max limit is acceded by currentCountQuery
*
* @param {Object} options
* @param {Number} [options.max] - overrides configured default max value to perform checks against
* @param {Number} [options.currentCount] - overrides currentCountQuery to perform checks against
* @param {Object} [options.transacting] Transaction to run the count query on
*/
async errorIfIsOverLimit(options = {}) {
const currentCount = options.currentCount || await this.currentCountQuery(options);
if (currentCount > (options.max || this.max)) {
throw this.generateError(currentCount);
}
}
}
class MaxPeriodicLimit extends Limit {
/**
*
* @param {Object} options
* @param {String} options.name - name of the limit
* @param {Object} options.config - limit configuration
* @param {Number} options.config.maxPeriodic - maximum limit the limit would check against
* @param {String} options.config.error - error message to use when limit is reached
* @param {Function} options.config.currentCountQuery - query checking the state that would be compared against the limit
* @param {('month')} options.config.interval - an interval to take into account when checking the limit. Currently only supports 'month' value
* @param {String} options.config.startDate - start date in ISO 8601 format (https://en.wikipedia.org/wiki/ISO_8601), used to calculate period intervals
* @param {String} options.helpLink - URL to the resource explaining how the limit works
* @param {Object} [options.db] - instance of knex db connection that currentCountQuery can use to run state check through
* @param {Object} options.errors - instance of errors compatible with GhostError errors (@tryghost/errors)
*/
constructor({name, config, helpLink, db, errors}) {
super({name, error: config.error || '', helpLink, db, errors});
if (config.maxPeriodic === undefined) {
throw new errors.IncorrectUsageError({message: 'Attempted to setup a periodic max limit without a limit'});
}
if (!config.currentCountQuery) {
throw new errors.IncorrectUsageError({message: 'Attempted to setup a periodic max limit without a current count query'});
}
if (!config.interval) {
throw new errors.IncorrectUsageError({message: 'Attempted to setup a periodic max limit without an interval'});
}
if (!SUPPORTED_INTERVALS.includes(config.interval)) {
throw new errors.IncorrectUsageError({message: `Attempted to setup a periodic max limit without unsupported interval. Please specify one of: ${SUPPORTED_INTERVALS}`});
}
if (!config.startDate) {
throw new errors.IncorrectUsageError({message: 'Attempted to setup a periodic max limit without a start date'});
}
this.currentCountQueryFn = config.currentCountQuery;
this.maxPeriodic = config.maxPeriodic;
this.interval = config.interval;
this.startDate = config.startDate;
this.fallbackMessage = `This action would exceed the ${_.lowerCase(this.name)} limit on your current plan.`;
}
generateError(count) {
let errorObj = super.generateError();
errorObj.message = this.fallbackMessage;
if (this.error) {
try {
errorObj.message = _.template(this.error)(
{
max: Intl.NumberFormat().format(this.maxPeriodic),
count: Intl.NumberFormat().format(count),
name: this.name
});
} catch (e) {
errorObj.message = this.fallbackMessage;
}
}
errorObj.errorDetails.limit = this.maxPeriodic;
errorObj.errorDetails.total = count;
return new this.errors.HostLimitError(errorObj);
}
/**
* @param {Object} [options]
* @param {Object} [options.transacting] Transaction to run the count query on
* @returns
*/
async currentCountQuery(options = {}) {
const lastPeriodStartDate = lastPeriodStart(this.startDate, this.interval);
return await this.currentCountQueryFn(options.transacting ? options.transacting : (this.db ? this.db.knex : undefined), lastPeriodStartDate);
}
/**
* Throws a HostLimitError if the configured or passed max limit is acceded by currentCountQuery
*
* @param {Object} options
* @param {Number} [options.max] - overrides configured default maxPeriodic value to perform checks against
* @param {Number} [options.addedCount] - number of items to add to the currentCount during the check
* @param {Object} [options.transacting] Transaction to run the count query on
*/
async errorIfWouldGoOverLimit(options = {}) {
const {max, addedCount = 1} = options;
let currentCount = await this.currentCountQuery(options);
if ((currentCount + addedCount) > (max || this.maxPeriodic)) {
throw this.generateError(currentCount);
}
}
/**
* Throws a HostLimitError if the configured or passed max limit is acceded by currentCountQuery
*
* @param {Object} options
* @param {Number} [options.max] - overrides configured default maxPeriodic value to perform checks against
* @param {Object} [options.transacting] Transaction to run the count query on
*/
async errorIfIsOverLimit(options = {}) {
const {max} = options;
let currentCount = await this.currentCountQuery(options);
if (currentCount > (max || this.maxPeriodic)) {
throw this.generateError(currentCount);
}
}
}
class FlagLimit extends Limit {
/**
*
* @param {Object} options
* @param {String} options.name - name of the limit
* @param {Object} options.config - limit configuration
* @param {Number} options.config.disabled - disabled/enabled flag for the limit
* @param {String} options.config.error - error message to use when limit is reached
* @param {String} options.helpLink - URL to the resource explaining how the limit works
* @param {Object} [options.db] - instance of knex db connection that currentCountQuery can use to run state check through
* @param {Object} options.errors - instance of errors compatible with GhostError errors (@tryghost/errors)
*/
constructor({name, config, helpLink, db, errors}) {
super({name, error: config.error || '', helpLink, db, errors});
this.disabled = config.disabled;
this.fallbackMessage = `Your plan does not support ${_.lowerCase(this.name)}. Please upgrade to enable ${_.lowerCase(this.name)}.`;
}
generateError() {
let errorObj = super.generateError();
if (this.error) {
errorObj.message = this.error;
} else {
errorObj.message = this.fallbackMessage;
}
return new this.errors.HostLimitError(errorObj);
}
/**
* Flag limits are on/off so using a feature is always over the limit
*/
async errorIfWouldGoOverLimit() {
if (this.disabled) {
throw this.generateError();
}
}
/**
* Flag limits are on/off. They don't necessarily mean the limit wasn't possible to reach
* NOTE: this method should not be relied on as it's impossible to check the limit was surpassed!
*/
async errorIfIsOverLimit() {
return;
}
}
class AllowlistLimit extends Limit {
/**
*
* @param {Object} options
* @param {String} options.name - name of the limit
* @param {Object} options.config - limit configuration
* @param {[String]} options.config.allowlist - allowlist values that would be compared against
* @param {String} options.config.error - error message to use when limit is reached
* @param {String} options.helpLink - URL to the resource explaining how the limit works
* @param {Object} options.errors - instance of errors compatible with GhostError errors (@tryghost/errors)
*/
constructor({name, config, helpLink, errors}) {
super({name, error: config.error || '', helpLink, errors});
if (!config.allowlist || !config.allowlist.length) {
throw new this.errors.IncorrectUsageError({message: 'Attempted to setup an allowlist limit without an allowlist'});
}
this.allowlist = config.allowlist;
this.fallbackMessage = `This action would exceed the ${_.lowerCase(this.name)} limit on your current plan.`;
}
generateError() {
let errorObj = super.generateError();
if (this.error) {
errorObj.message = this.error;
} else {
errorObj.message = this.fallbackMessage;
}
return new this.errors.HostLimitError(errorObj);
}
async errorIfWouldGoOverLimit(metadata) {
if (!metadata || !metadata.value) {
throw new this.errors.IncorrectUsageError({message: 'Attempted to check an allowlist limit without a value'});
}
if (!this.allowlist.includes(metadata.value)) {
throw this.generateError();
}
}
async errorIfIsOverLimit(metadata) {
if (!metadata || !metadata.value) {
throw new this.errors.IncorrectUsageError({message: 'Attempted to check an allowlist limit without a value'});
}
if (!this.allowlist.includes(metadata.value)) {
throw this.generateError();
}
}
}
module.exports = {
MaxLimit,
MaxPeriodicLimit,
FlagLimit,
AllowlistLimit
};

View File

@ -1,33 +0,0 @@
{
"name": "@tryghost/limit-service",
"version": "1.2.2",
"repository": "https://github.com/TryGhost/Utils/tree/main/packages/limit-service",
"author": "Ghost Foundation",
"license": "MIT",
"main": "./lib/limit-service.js",
"exports": "./lib/limit-service.js",
"scripts": {
"dev": "echo \"Implement me!\"",
"test": "NODE_ENV=testing c8 --all --reporter text --reporter cobertura mocha './test/**/*.test.js'",
"lint": "eslint . --ext .js --cache",
"posttest": "yarn lint"
},
"files": [
"index.js",
"lib"
],
"publishConfig": {
"access": "public"
},
"devDependencies": {
"c8": "7.12.0",
"mocha": "10.0.0",
"should": "13.2.3",
"sinon": "14.0.0"
},
"dependencies": {
"@tryghost/errors": "^1.2.1",
"lodash": "^4.17.21",
"luxon": "^1.26.0"
}
}

View File

@ -1,6 +0,0 @@
module.exports = {
plugins: ['ghost'],
extends: [
'plugin:ghost/test'
]
};

View File

@ -1,76 +0,0 @@
// Switch these lines once there are useful utils
// const testUtils = require('./utils');
require('./utils');
const {DateTime} = require('luxon');
const sinon = require('sinon');
const {lastPeriodStart} = require('../lib/date-utils');
describe('Date Utils', function () {
describe('fn: lastPeriodStart', function () {
let clock;
afterEach(function () {
if (clock) {
clock.restore();
}
});
it('returns same date if current date is less than a period away from current date', async function () {
const weekAgoDate = DateTime.now().toUTC().plus({weeks: -1});
const weekAgoISO = weekAgoDate.toISO();
const lastPeriodStartDate = lastPeriodStart(weekAgoISO, 'month');
lastPeriodStartDate.should.equal(weekAgoISO);
});
it('returns beginning of last month\'s period', async function () {
const weekAgoDate = DateTime.now().toUTC().plus({weeks: -1});
const weekAgoISO = weekAgoDate.toISO();
const weekAndAMonthAgo = weekAgoDate.plus({months: -1});
const weekAndAMonthAgoISO = weekAndAMonthAgo.toISO();
const lastPeriodStartDate = lastPeriodStart(weekAndAMonthAgoISO, 'month');
lastPeriodStartDate.should.equal(weekAgoISO);
});
it('returns 3rd day or current month when monthly period started on 3rd day in the past', async function () {
// fake current clock to be past 3rd day of a month
clock = sinon.useFakeTimers(new Date('2021-08-18T19:00:52Z').getTime());
const lastPeriodStartDate = lastPeriodStart('2020-03-03T23:00:01Z', 'month');
lastPeriodStartDate.should.equal('2021-08-03T23:00:01.000Z');
});
it('returns 5rd day or last month when monthly period started on 5th day in the past and it is 3rd day of the month', async function () {
// fake current clock to be on 3rd day of a month
clock = sinon.useFakeTimers(new Date('2021-09-03T12:12:12Z').getTime());
const lastPeriodStartDate = lastPeriodStart('2020-03-05T11:11:11Z', 'month');
lastPeriodStartDate.should.equal('2021-08-05T11:11:11.000Z');
});
it('return 29th of Feb if the subscription started on the 31st day and it is a leap year', async function () {
// fake current clock to be march of a leap year
clock = sinon.useFakeTimers(new Date('2020-03-05T13:15:07Z').getTime());
const lastPeriodStartDate = lastPeriodStart('2020-01-31T23:00:01Z', 'month');
lastPeriodStartDate.should.equal('2020-02-29T23:00:01.000Z');
});
it('return 28th of Feb if the subscription started on the 30th day and it is **not** a leap year', async function () {
// fake current clock to be March of non-leap year
clock = sinon.useFakeTimers(new Date('2021-03-05T13:15:07Z').getTime());
const lastPeriodStartDate = lastPeriodStart('2019-04-30T01:59:42Z', 'month');
lastPeriodStartDate.should.equal('2021-02-28T01:59:42.000Z');
});
});
});

View File

@ -1,25 +0,0 @@
class Error {
constructor({errorType, errorDetails, message}) {
this.errorType = errorType;
this.errorDetails = errorDetails;
this.message = message;
}
}
class IncorrectUsageError extends Error {
constructor(options) {
super(Object.assign({errorType: 'IncorrectUsageError'}, options));
}
}
class HostLimitError extends Error {
constructor(options) {
super(Object.assign({errorType: 'HostLimitError'}, options));
}
}
// NOTE: this module is here to serve as a dummy fixture for GhostError errors (@tryghost/errors)
module.exports = {
IncorrectUsageError,
HostLimitError
};

View File

@ -1,514 +0,0 @@
// Switch these lines once there are useful utils
// const testUtils = require('./utils');
require('./utils');
const should = require('should');
const LimitService = require('../lib/limit-service');
const {MaxLimit, MaxPeriodicLimit, FlagLimit} = require('../lib/limit');
const sinon = require('sinon');
const errors = require('./fixtures/errors');
describe('Limit Service', function () {
describe('Lodash Template', function () {
it('Does not get clobbered by this lib', function () {
require('../lib/limit');
let _ = require('lodash');
_.templateSettings.interpolate.should.eql(/<%=([\s\S]+?)%>/g);
});
});
describe('Error Messages', function () {
it('Formats numbers correctly', function () {
let limit = new MaxLimit({
name: 'test',
config: {
max: 35000000,
currentCountQuery: () => {},
error: 'Your plan supports up to {{max}} staff users. Please upgrade to add more.'
},
errors
});
let error = limit.generateError(35000001);
error.message.should.eql('Your plan supports up to 35,000,000 staff users. Please upgrade to add more.');
error.errorDetails.limit.should.eql(35000000);
error.errorDetails.total.should.eql(35000001);
});
it('Supports {{max}}, {{count}}, and {{name}} variables', function () {
let limit = new MaxLimit({
name: 'Test Resources',
config: {
max: 5,
currentCountQuery: () => {},
error: '{{name}} limit reached. Your plan supports up to {{max}} staff users. You are currently at {{count}} staff users.Please upgrade to add more.'
},
errors
});
let error = limit.generateError(7);
error.message.should.eql('Test Resources limit reached. Your plan supports up to 5 staff users. You are currently at 7 staff users.Please upgrade to add more.');
error.errorDetails.name.should.eql('Test Resources');
error.errorDetails.limit.should.eql(5);
error.errorDetails.total.should.eql(7);
});
});
describe('Loader', function () {
it('throws if errors configuration is not specified', function () {
const limitService = new LimitService();
let limits = {staff: {max: 2}};
try {
limitService.loadLimits({limits});
should.fail(limitService, 'Should have errored');
} catch (err) {
should.exist(err);
err.message.should.eql(`Config Missing: 'errors' is required.`);
}
});
it('can load a max limit', function () {
const limitService = new LimitService();
let limits = {staff: {max: 2}};
limitService.loadLimits({limits, errors});
limitService.limits.should.be.an.Object().with.properties(['staff']);
limitService.limits.staff.should.be.an.instanceOf(MaxLimit);
limitService.isLimited('staff').should.be.true();
limitService.isLimited('members').should.be.false();
});
it('can load a periodic max limit', function () {
const limitService = new LimitService();
let limits = {
emails: {
maxPeriodic: 3
}
};
let subscription = {
interval: 'month',
startDate: '2021-09-18T19:00:52Z'
};
limitService.loadLimits({limits, subscription, errors});
limitService.limits.should.be.an.Object().with.properties(['emails']);
limitService.limits.emails.should.be.an.instanceOf(MaxPeriodicLimit);
limitService.isLimited('emails').should.be.true();
limitService.isLimited('staff').should.be.false();
});
it('throws when loadding a periodic max limit without a subscription', function () {
const limitService = new LimitService();
let limits = {
emails: {
maxPeriodic: 3
}
};
try {
limitService.loadLimits({limits, errors});
throw new Error('Should have failed earlier...');
} catch (error) {
error.errorType.should.equal('IncorrectUsageError');
error.message.should.match(/periodic max limit without a subscription/);
}
});
it('can load multiple limits', function () {
const limitService = new LimitService();
let limits = {
staff: {max: 2},
members: {max: 100},
emails: {disabled: true}
};
limitService.loadLimits({limits, errors});
limitService.limits.should.be.an.Object().with.properties(['staff', 'members']);
limitService.limits.staff.should.be.an.instanceOf(MaxLimit);
limitService.limits.members.should.be.an.instanceOf(MaxLimit);
limitService.isLimited('staff').should.be.true();
limitService.isLimited('members').should.be.true();
limitService.isLimited('emails').should.be.true();
});
it('can load camel cased limits', function () {
const limitService = new LimitService();
let limits = {customThemes: {disabled: true}};
limitService.loadLimits({limits, errors});
limitService.limits.should.be.an.Object().with.properties(['customThemes']);
limitService.limits.customThemes.should.be.an.instanceOf(FlagLimit);
limitService.isLimited('staff').should.be.false();
limitService.isLimited('members').should.be.false();
limitService.isLimited('custom_themes').should.be.true();
limitService.isLimited('customThemes').should.be.true();
});
it('can load incorrectly cased limits', function () {
const limitService = new LimitService();
let limits = {custom_themes: {disabled: true}};
limitService.loadLimits({limits, errors});
limitService.limits.should.be.an.Object().with.properties(['customThemes']);
limitService.limits.customThemes.should.be.an.instanceOf(FlagLimit);
limitService.isLimited('staff').should.be.false();
limitService.isLimited('members').should.be.false();
limitService.isLimited('custom_themes').should.be.true();
limitService.isLimited('customThemes').should.be.true();
});
it('answers correctly when no limits are provided', function () {
const limitService = new LimitService();
let limits = {};
limitService.loadLimits({limits, errors});
limitService.isLimited('staff').should.be.false();
limitService.isLimited('members').should.be.false();
limitService.isLimited('custom_themes').should.be.false();
limitService.isLimited('customThemes').should.be.false();
limitService.isLimited('emails').should.be.false();
});
it('populates new limits if called multiple times', function () {
const limitService = new LimitService();
const staffLimit = {staff: {max: 2}};
limitService.loadLimits({limits: staffLimit, errors});
limitService.limits.should.be.an.Object().with.properties(['staff']);
limitService.limits.staff.should.be.an.instanceOf(MaxLimit);
limitService.isLimited('staff').should.be.true();
limitService.isLimited('members').should.be.false();
const membersLimit = {members: {max: 3}};
limitService.loadLimits({limits: membersLimit, errors});
limitService.limits.should.be.an.Object().with.properties(['members']);
limitService.limits.members.should.be.an.instanceOf(MaxLimit);
limitService.isLimited('staff').should.be.false();
limitService.isLimited('members').should.be.true();
});
});
describe('Custom limit count query configuration', function () {
it('can use a custom implementation of max limit query', async function () {
const limitService = new LimitService();
let limits = {
staff: {
max: 2,
currentCountQuery: () => 5
},
members: {
max: 100,
currentCountQuery: () => 100
}
};
limitService.loadLimits({limits, errors});
(await limitService.checkIsOverLimit('staff')).should.be.true();
(await limitService.checkWouldGoOverLimit('staff')).should.be.true();
(await limitService.checkIsOverLimit('members')).should.be.false();
(await limitService.checkWouldGoOverLimit('members')).should.be.true();
});
});
describe('Check if any of configured limits are acceded', function () {
it('Confirms an acceded limit', async function () {
const limitService = new LimitService();
let limits = {
staff: {
max: 2,
currentCountQuery: () => 5
},
members: {
max: 100,
currentCountQuery: () => 100
},
emails: {
maxPeriodic: 3,
currentCountQuery: () => 5
},
customIntegrations: {
disabled: true
}
};
const subscription = {
interval: 'month',
startDate: '2021-09-18T19:00:52Z'
};
limitService.loadLimits({limits, errors, subscription});
(await limitService.checkIfAnyOverLimit()).should.be.true();
});
it('Does not confirm if no limits are acceded', async function () {
const limitService = new LimitService();
let limits = {
staff: {
max: 2,
currentCountQuery: () => 1
},
members: {
max: 100,
currentCountQuery: () => 2
},
emails: {
maxPeriodic: 3,
currentCountQuery: () => 2
},
// TODO: allowlist type of limits doesn't have "checkIsOverLimit" implemented yet!
// customThemes: {
// allowlist: ['casper', 'dawn', 'lyra']
// },
// NOTE: the flag limit has flawed assumption of not being acceded previously
// this test might fail when the flaw is addressed
customIntegrations: {
disabled: true
}
};
const subscription = {
interval: 'month',
startDate: '2021-09-18T19:00:52Z'
};
limitService.loadLimits({limits, errors, subscription});
(await limitService.checkIfAnyOverLimit()).should.be.false();
});
it('Returns nothing if limit is not configured', async function () {
const limitService = new LimitService();
const isOverLimitResult = await limitService.checkIsOverLimit('unlimited');
should.equal(isOverLimitResult, undefined);
const wouldGoOverLimitResult = await limitService.checkWouldGoOverLimit('unlimited');
should.equal(wouldGoOverLimitResult, undefined);
const errorIfIsOverLimitResult = await limitService.errorIfIsOverLimit('unlimited');
should.equal(errorIfIsOverLimitResult, undefined);
const errorIfWouldGoOverLimitResult = await limitService.errorIfWouldGoOverLimit('unlimited');
should.equal(errorIfWouldGoOverLimitResult, undefined);
});
it('Throws an error when an allowlist limit is checked', async function () {
const limitService = new LimitService();
let limits = {
// TODO: allowlist type of limits doesn't have "checkIsOverLimit" implemented yet!
customThemes: {
allowlist: ['casper', 'dawn', 'lyra']
}
};
limitService.loadLimits({limits, errors});
try {
await limitService.checkIfAnyOverLimit();
should.fail(limitService, 'Should have errored');
} catch (err) {
err.message.should.eql(`Attempted to check an allowlist limit without a value`);
}
});
});
describe('Metadata', function () {
afterEach(function () {
sinon.restore();
});
it('passes options for checkIsOverLimit', async function () {
const limitService = new LimitService();
let limits = {
staff: {
max: 2,
currentCountQuery: () => 1
}
};
const maxSpy = sinon.spy(MaxLimit.prototype, 'errorIfIsOverLimit');
const subscription = {
interval: 'month',
startDate: '2021-09-18T19:00:52Z'
};
limitService.loadLimits({limits, errors, subscription});
const options = {
testData: 'true'
};
await limitService.checkIsOverLimit('staff', options);
sinon.assert.callCount(maxSpy, 1);
sinon.assert.alwaysCalledWithExactly(maxSpy, options);
});
it('passes options for checkWouldGoOverLimit', async function () {
const limitService = new LimitService();
let limits = {
staff: {
max: 2,
currentCountQuery: () => 1
}
};
const maxSpy = sinon.spy(MaxLimit.prototype, 'errorIfWouldGoOverLimit');
const subscription = {
interval: 'month',
startDate: '2021-09-18T19:00:52Z'
};
limitService.loadLimits({limits, errors, subscription});
const options = {
testData: 'true'
};
await limitService.checkWouldGoOverLimit('staff', options);
sinon.assert.callCount(maxSpy, 1);
sinon.assert.alwaysCalledWithExactly(maxSpy, options);
});
it('passes options for errorIfIsOverLimit', async function () {
const limitService = new LimitService();
let limits = {
staff: {
max: 2,
currentCountQuery: () => 1
}
};
const maxSpy = sinon.spy(MaxLimit.prototype, 'errorIfIsOverLimit');
const subscription = {
interval: 'month',
startDate: '2021-09-18T19:00:52Z'
};
limitService.loadLimits({limits, errors, subscription});
const options = {
testData: 'true'
};
await limitService.errorIfIsOverLimit('staff', options);
sinon.assert.callCount(maxSpy, 1);
sinon.assert.alwaysCalledWithExactly(maxSpy, options);
});
it('passes options for errorIfWouldGoOverLimit', async function () {
const limitService = new LimitService();
let limits = {
staff: {
max: 2,
currentCountQuery: () => 1
}
};
const maxSpy = sinon.spy(MaxLimit.prototype, 'errorIfWouldGoOverLimit');
const subscription = {
interval: 'month',
startDate: '2021-09-18T19:00:52Z'
};
limitService.loadLimits({limits, errors, subscription});
const options = {
testData: 'true'
};
await limitService.errorIfWouldGoOverLimit('staff', options);
sinon.assert.callCount(maxSpy, 1);
sinon.assert.alwaysCalledWithExactly(maxSpy, options);
});
it('passes options for checkIfAnyOverLimit', async function () {
const limitService = new LimitService();
let limits = {
staff: {
max: 2,
currentCountQuery: () => 2
},
members: {
max: 100,
currentCountQuery: () => 100
},
emails: {
maxPeriodic: 3,
currentCountQuery: () => 3
},
customIntegrations: {
disabled: true
}
};
const flagSpy = sinon.spy(FlagLimit.prototype, 'errorIfIsOverLimit');
const maxSpy = sinon.spy(MaxLimit.prototype, 'errorIfIsOverLimit');
const maxPeriodSpy = sinon.spy(MaxPeriodicLimit.prototype, 'errorIfIsOverLimit');
const subscription = {
interval: 'month',
startDate: '2021-09-18T19:00:52Z'
};
limitService.loadLimits({limits, errors, subscription});
const options = {
testData: 'true'
};
(await limitService.checkIfAnyOverLimit(options)).should.be.false();
sinon.assert.callCount(flagSpy, 1);
sinon.assert.alwaysCalledWithExactly(flagSpy, options);
sinon.assert.callCount(maxSpy, 2);
sinon.assert.alwaysCalledWithExactly(maxSpy, options);
sinon.assert.callCount(maxPeriodSpy, 1);
sinon.assert.alwaysCalledWithExactly(maxPeriodSpy, options);
});
});
});

View File

@ -1,678 +0,0 @@
// Switch these lines once there are useful utils
// const testUtils = require('./utils');
require('./utils');
const should = require('should');
const sinon = require('sinon');
const errors = require('./fixtures/errors');
const {MaxLimit, AllowlistLimit, FlagLimit, MaxPeriodicLimit} = require('../lib/limit');
describe('Limit Service', function () {
describe('Flag Limit', function () {
it('do nothing if is over limit', async function () {
// NOTE: the behavior of flag limit in "is over limit" usecase is flawed and should not be relied on
// possible solution could be throwing an error to prevent clients from using it?
const config = {
disabled: true
};
const limit = new FlagLimit({name: 'flaggy', config, errors});
const result = await limit.errorIfIsOverLimit();
should(result).be.undefined();
});
it('throws if would go over limit', async function () {
const config = {
disabled: true
};
const limit = new FlagLimit({name: 'flaggy', config, errors});
try {
await limit.errorIfWouldGoOverLimit();
should.fail(limit, 'Should have errored');
} catch (err) {
should.exist(err);
should.exist(err.errorType);
should.equal(err.errorType, 'HostLimitError');
should.exist(err.errorDetails);
should.equal(err.errorDetails.name, 'flaggy');
should.exist(err.message);
should.equal(err.message, 'Your plan does not support flaggy. Please upgrade to enable flaggy.');
}
});
});
describe('Max Limit', function () {
describe('Constructor', function () {
it('passes if within the limit and custom currentCount overriding currentCountQuery', async function () {
const config = {
max: 5,
error: 'You have gone over the limit',
currentCountQuery: function () {
throw new Error('Should not be called');
}
};
try {
const limit = new MaxLimit({name: '', config, errors});
await limit.errorIfIsOverLimit({currentCount: 4});
} catch (error) {
should.fail('Should have not errored', error);
}
});
it('throws if initialized without a max limit', function () {
const config = {};
try {
const limit = new MaxLimit({name: 'no limits!', config, errors});
should.fail(limit, 'Should have errored');
} catch (err) {
should.exist(err);
should.exist(err.errorType);
should.equal(err.errorType, 'IncorrectUsageError');
err.message.should.match(/max limit without a limit/);
}
});
it('throws if initialized without a current count query', function () {
const config = {
max: 100
};
try {
const limit = new MaxLimit({name: 'no accountability!', config, errors});
should.fail(limit, 'Should have errored');
} catch (err) {
should.exist(err);
should.exist(err.errorType);
should.equal(err.errorType, 'IncorrectUsageError');
err.message.should.match(/max limit without a current count query/);
}
});
it('throws when would go over the limit and custom currentCount overriding currentCountQuery', async function () {
const _5MB = 5000000;
const config = {
max: _5MB,
formatter: count => `${count / 1000000}MB`,
error: 'You have exceeded the maximum file size {{ max }}',
currentCountQuery: function () {
throw new Error('Should not be called');
}
};
try {
const limit = new MaxLimit({
name: 'fileSize',
config,
errors
});
const _10MB = 10000000;
await limit.errorIfIsOverLimit({currentCount: _10MB});
} catch (error) {
error.errorType.should.equal('HostLimitError');
error.errorDetails.name.should.equal('fileSize');
error.errorDetails.limit.should.equal(5000000);
error.errorDetails.total.should.equal(10000000);
error.message.should.equal('You have exceeded the maximum file size 5MB');
}
});
});
describe('Is over limit', function () {
it('throws if is over the limit', async function () {
const config = {
max: 3,
currentCountQuery: () => 42
};
const limit = new MaxLimit({name: 'maxy', config, errors});
try {
await limit.errorIfIsOverLimit();
should.fail(limit, 'Should have errored');
} catch (err) {
should.exist(err);
should.exist(err.errorType);
should.equal(err.errorType, 'HostLimitError');
should.exist(err.errorDetails);
should.equal(err.errorDetails.name, 'maxy');
should.exist(err.message);
should.equal(err.message, 'This action would exceed the maxy limit on your current plan.');
}
});
it('passes if does not go over the limit', async function () {
const config = {
max: 1,
currentCountQuery: () => 1
};
const limit = new MaxLimit({name: 'maxy', config, errors});
await limit.errorIfIsOverLimit();
});
it('ignores default configured max limit when it is passed explicitly', async function () {
const config = {
max: 10,
currentCountQuery: () => 10
};
const limit = new MaxLimit({name: 'maxy', config, errors});
// should pass as the limit is exactly on the limit 10 >= 10
await limit.errorIfIsOverLimit({max: 10});
try {
// should fail because limit is overridden to 10 < 9
await limit.errorIfIsOverLimit({max: 9});
should.fail(limit, 'Should have errored');
} catch (err) {
should.exist(err);
should.exist(err.errorType);
should.equal(err.errorType, 'HostLimitError');
should.exist(err.errorDetails);
should.equal(err.errorDetails.name, 'maxy');
should.exist(err.message);
should.equal(err.message, 'This action would exceed the maxy limit on your current plan.');
}
});
});
describe('Would go over limit', function () {
it('throws if would go over the limit', async function () {
const config = {
max: 1,
currentCountQuery: () => 1
};
const limit = new MaxLimit({name: 'maxy', config, errors});
try {
await limit.errorIfWouldGoOverLimit();
should.fail(limit, 'Should have errored');
} catch (err) {
should.exist(err);
should.exist(err.errorType);
should.equal(err.errorType, 'HostLimitError');
should.exist(err.errorDetails);
should.equal(err.errorDetails.name, 'maxy');
should.exist(err.message);
should.equal(err.message, 'This action would exceed the maxy limit on your current plan.');
}
});
it('throws if would go over the limit with with custom added count', async function () {
const config = {
max: 23,
currentCountQuery: () => 13
};
const limit = new MaxLimit({name: 'maxy', config, errors});
try {
await limit.errorIfWouldGoOverLimit({addedCount: 11});
should.fail(limit, 'Should have errored');
} catch (err) {
should.exist(err);
should.exist(err.errorType);
should.equal(err.errorType, 'HostLimitError');
should.exist(err.errorDetails);
should.equal(err.errorDetails.name, 'maxy');
should.exist(err.message);
should.equal(err.message, 'This action would exceed the maxy limit on your current plan.');
}
});
it('passes if does not go over the limit', async function () {
const config = {
max: 2,
currentCountQuery: () => 1
};
const limit = new MaxLimit({name: 'maxy', config, errors});
await limit.errorIfWouldGoOverLimit();
});
it('ignores default configured max limit when it is passed explicitly', async function () {
const config = {
max: 10,
currentCountQuery: () => 10
};
const limit = new MaxLimit({name: 'maxy', config, errors});
// should pass as the limit is overridden to 10 + 1 = 11
await limit.errorIfWouldGoOverLimit({max: 11});
try {
// should fail because limit is overridden to 10 + 1 < 1
await limit.errorIfWouldGoOverLimit({max: 1});
should.fail(limit, 'Should have errored');
} catch (err) {
should.exist(err);
should.exist(err.errorType);
should.equal(err.errorType, 'HostLimitError');
should.exist(err.errorDetails);
should.equal(err.errorDetails.name, 'maxy');
should.exist(err.message);
should.equal(err.message, 'This action would exceed the maxy limit on your current plan.');
}
});
});
describe('Transactions', function () {
it('passes undefined if no db or transacting option passed', async function () {
const config = {
max: 5,
error: 'You have gone over the limit',
currentCountQuery: sinon.stub()
};
config.currentCountQuery.resolves(0);
try {
const limit = new MaxLimit({name: '', config, errors});
await limit.errorIfIsOverLimit();
await limit.errorIfWouldGoOverLimit();
} catch (error) {
should.fail('Should have not errored', error);
}
sinon.assert.calledTwice(config.currentCountQuery);
sinon.assert.alwaysCalledWithExactly(config.currentCountQuery, undefined);
});
it('passes default db if no transacting option passed', async function () {
const config = {
max: 5,
error: 'You have gone over the limit',
currentCountQuery: sinon.stub()
};
const db = {
knex: 'This is our connection'
};
config.currentCountQuery.resolves(0);
try {
const limit = new MaxLimit({name: '', config, db, errors});
await limit.errorIfIsOverLimit();
await limit.errorIfWouldGoOverLimit();
} catch (error) {
should.fail('Should have not errored', error);
}
sinon.assert.calledTwice(config.currentCountQuery);
sinon.assert.alwaysCalledWithExactly(config.currentCountQuery, db.knex);
});
it('passes transacting option', async function () {
const config = {
max: 5,
error: 'You have gone over the limit',
currentCountQuery: sinon.stub()
};
const db = {
knex: 'This is our connection'
};
const transaction = 'Our transaction';
config.currentCountQuery.resolves(0);
try {
const limit = new MaxLimit({name: '', config, db, errors});
await limit.errorIfIsOverLimit({transacting: transaction});
await limit.errorIfWouldGoOverLimit({transacting: transaction});
} catch (error) {
should.fail('Should have not errored', error);
}
sinon.assert.calledTwice(config.currentCountQuery);
sinon.assert.alwaysCalledWithExactly(config.currentCountQuery, transaction);
});
});
});
describe('Periodic Max Limit', function () {
describe('Constructor', function () {
it('throws if initialized without a maxPeriodic limit', function () {
const config = {};
try {
const limit = new MaxPeriodicLimit({name: 'no limits!', config, errors});
should.fail(limit, 'Should have errored');
} catch (err) {
should.exist(err);
should.exist(err.errorType);
should.equal(err.errorType, 'IncorrectUsageError');
err.message.should.match(/periodic max limit without a limit/gi);
}
});
it('throws if initialized without a current count query', function () {
const config = {
maxPeriodic: 100
};
try {
const limit = new MaxPeriodicLimit({name: 'no accountability!', config, errors});
should.fail(limit, 'Should have errored');
} catch (err) {
should.exist(err);
should.exist(err.errorType);
should.equal(err.errorType, 'IncorrectUsageError');
err.message.should.match(/periodic max limit without a current count query/gi);
}
});
it('throws if initialized without interval', function () {
const config = {
maxPeriodic: 100,
currentCountQuery: () => {}
};
try {
const limit = new MaxPeriodicLimit({name: 'no accountability!', config, errors});
should.fail(limit, 'Should have errored');
} catch (err) {
should.exist(err);
should.exist(err.errorType);
should.equal(err.errorType, 'IncorrectUsageError');
err.message.should.match(/periodic max limit without an interval/gi);
}
});
it('throws if initialized with unsupported interval', function () {
const config = {
maxPeriodic: 100,
currentCountQuery: () => {},
interval: 'week'
};
try {
const limit = new MaxPeriodicLimit({name: 'no accountability!', config, errors});
should.fail(limit, 'Should have errored');
} catch (err) {
should.exist(err);
should.exist(err.errorType);
should.equal(err.errorType, 'IncorrectUsageError');
err.message.should.match(/periodic max limit without unsupported interval. Please specify one of: month/gi);
}
});
it('throws if initialized without start date', function () {
const config = {
maxPeriodic: 100,
currentCountQuery: () => {},
interval: 'month'
};
try {
const limit = new MaxPeriodicLimit({name: 'no accountability!', config, errors});
should.fail(limit, 'Should have errored');
} catch (err) {
should.exist(err);
should.exist(err.errorType);
should.equal(err.errorType, 'IncorrectUsageError');
err.message.should.match(/periodic max limit without a start date/gi);
}
});
});
describe('Is over limit', function () {
it('throws if is over the limit', async function () {
const currentCountyQueryMock = sinon.mock().returns(11);
const config = {
maxPeriodic: 3,
error: 'You have exceeded the number of emails you can send within your billing period.',
interval: 'month',
startDate: '2021-01-01T00:00:00Z',
currentCountQuery: currentCountyQueryMock
};
try {
const limit = new MaxPeriodicLimit({name: 'mailguard', config, errors});
await limit.errorIfIsOverLimit();
} catch (error) {
error.errorType.should.equal('HostLimitError');
error.errorDetails.name.should.equal('mailguard');
error.errorDetails.limit.should.equal(3);
error.errorDetails.total.should.equal(11);
currentCountyQueryMock.callCount.should.equal(1);
should(currentCountyQueryMock.args).not.be.undefined();
should(currentCountyQueryMock.args[0][0]).be.undefined(); //knex db connection
const nowDate = new Date();
const startOfTheMonthDate = new Date(Date.UTC(
nowDate.getUTCFullYear(),
nowDate.getUTCMonth()
)).toISOString();
currentCountyQueryMock.args[0][1].should.equal(startOfTheMonthDate);
}
});
});
describe('Would go over limit', function () {
it('passes if within the limit', async function () {
const currentCountyQueryMock = sinon.mock().returns(4);
const config = {
maxPeriodic: 5,
error: 'You have exceeded the number of emails you can send within your billing period.',
interval: 'month',
startDate: '2021-01-01T00:00:00Z',
currentCountQuery: currentCountyQueryMock
};
try {
const limit = new MaxPeriodicLimit({name: 'mailguard', config, errors});
await limit.errorIfWouldGoOverLimit();
} catch (error) {
should.fail('MaxPeriodicLimit errorIfWouldGoOverLimit check should not have errored');
}
});
it('throws if would go over limit', async function () {
const currentCountyQueryMock = sinon.mock().returns(5);
const config = {
maxPeriodic: 5,
error: 'You have exceeded the number of emails you can send within your billing period.',
interval: 'month',
startDate: '2021-01-01T00:00:00Z',
currentCountQuery: currentCountyQueryMock
};
try {
const limit = new MaxPeriodicLimit({name: 'mailguard', config, errors});
await limit.errorIfWouldGoOverLimit();
} catch (error) {
error.errorType.should.equal('HostLimitError');
error.errorDetails.name.should.equal('mailguard');
error.errorDetails.limit.should.equal(5);
error.errorDetails.total.should.equal(5);
currentCountyQueryMock.callCount.should.equal(1);
should(currentCountyQueryMock.args).not.be.undefined();
should(currentCountyQueryMock.args[0][0]).be.undefined(); //knex db connection
const nowDate = new Date();
const startOfTheMonthDate = new Date(Date.UTC(
nowDate.getUTCFullYear(),
nowDate.getUTCMonth()
)).toISOString();
currentCountyQueryMock.args[0][1].should.equal(startOfTheMonthDate);
}
});
it('throws if would go over limit with custom added count', async function () {
const currentCountyQueryMock = sinon.mock().returns(5);
const config = {
maxPeriodic: 13,
error: 'You have exceeded the number of emails you can send within your billing period.',
interval: 'month',
startDate: '2021-01-01T00:00:00Z',
currentCountQuery: currentCountyQueryMock
};
try {
const limit = new MaxPeriodicLimit({name: 'mailguard', config, errors});
await limit.errorIfWouldGoOverLimit({addedCount: 9});
} catch (error) {
error.errorType.should.equal('HostLimitError');
error.errorDetails.name.should.equal('mailguard');
error.errorDetails.limit.should.equal(13);
error.errorDetails.total.should.equal(5);
currentCountyQueryMock.callCount.should.equal(1);
should(currentCountyQueryMock.args).not.be.undefined();
should(currentCountyQueryMock.args[0][0]).be.undefined(); //knex db connection
const nowDate = new Date();
const startOfTheMonthDate = new Date(Date.UTC(
nowDate.getUTCFullYear(),
nowDate.getUTCMonth()
)).toISOString();
currentCountyQueryMock.args[0][1].should.equal(startOfTheMonthDate);
}
});
});
describe('Transactions', function () {
it('passes undefined if no db or transacting option passed', async function () {
const config = {
maxPeriodic: 5,
error: 'You have exceeded the number of emails you can send within your billing period.',
interval: 'month',
startDate: '2021-01-01T00:00:00Z',
currentCountQuery: sinon.stub()
};
config.currentCountQuery.resolves(0);
try {
const limit = new MaxPeriodicLimit({name: 'mailguard', config, errors});
await limit.errorIfIsOverLimit();
await limit.errorIfWouldGoOverLimit();
} catch (error) {
should.fail('Should have not errored', error);
}
sinon.assert.calledTwice(config.currentCountQuery);
sinon.assert.alwaysCalledWith(config.currentCountQuery, undefined);
});
it('passes default db if no transacting option passed', async function () {
const config = {
maxPeriodic: 5,
error: 'You have exceeded the number of emails you can send within your billing period.',
interval: 'month',
startDate: '2021-01-01T00:00:00Z',
currentCountQuery: sinon.stub()
};
const db = {
knex: 'This is our connection'
};
config.currentCountQuery.resolves(0);
try {
const limit = new MaxPeriodicLimit({name: 'mailguard', config, db, errors});
await limit.errorIfIsOverLimit();
await limit.errorIfWouldGoOverLimit();
} catch (error) {
should.fail('Should have not errored', error);
}
sinon.assert.calledTwice(config.currentCountQuery);
sinon.assert.alwaysCalledWith(config.currentCountQuery, db.knex);
});
it('passes transacting option', async function () {
const config = {
maxPeriodic: 5,
error: 'You have exceeded the number of emails you can send within your billing period.',
interval: 'month',
startDate: '2021-01-01T00:00:00Z',
currentCountQuery: sinon.stub()
};
const db = {
knex: 'This is our connection'
};
const transaction = 'Our transaction';
config.currentCountQuery.resolves(0);
try {
const limit = new MaxPeriodicLimit({name: 'mailguard', config, db, errors});
await limit.errorIfIsOverLimit({transacting: transaction});
await limit.errorIfWouldGoOverLimit({transacting: transaction});
} catch (error) {
should.fail('Should have not errored', error);
}
sinon.assert.calledTwice(config.currentCountQuery);
sinon.assert.alwaysCalledWith(config.currentCountQuery, transaction);
});
});
});
describe('Allowlist limit', function () {
it('rejects when the allowlist config isn\'t specified', async function () {
try {
new AllowlistLimit({name: 'test', config: {}, errors});
throw new Error('Should have failed earlier...');
} catch (error) {
error.errorType.should.equal('IncorrectUsageError');
error.message.should.match(/allowlist limit without an allowlist/);
}
});
it('accept correct values', async function () {
const limit = new AllowlistLimit({name: 'test', config: {
allowlist: ['test', 'ok']
}, errors});
await limit.errorIfIsOverLimit({value: 'test'});
});
it('rejects unknown values', async function () {
const limit = new AllowlistLimit({name: 'test', config: {
allowlist: ['test', 'ok']
}, errors});
try {
await limit.errorIfIsOverLimit({value: 'unknown value'});
throw new Error('Should have failed earlier...');
} catch (error) {
error.errorType.should.equal('HostLimitError');
}
});
});
});

View File

@ -1,11 +0,0 @@
/**
* Custom Should Assertions
*
* Add any custom assertions to this file.
*/
// Example Assertion
// should.Assertion.add('ExampleAssertion', function () {
// this.params = {operator: 'to be a valid Example Assertion'};
// this.obj.should.be.an.Object;
// });

View File

@ -1,11 +0,0 @@
/**
* Test Utilities
*
* Shared utils for writing tests
*/
// Require overrides - these add globals for tests
require('./overrides');
// Require assertions - adds custom should assertions
require('./assertions');

View File

@ -1,10 +0,0 @@
// This file is required before any test is run
// Taken from the should wiki, this is how to make should global
// Should is a global in our eslint test config
global.should = require('should').noConflict();
should.extend();
// Sinon is a simple case
// Sinon is a global in our eslint test config
global.sinon = require('sinon');