mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-11-24 06:35:49 +03:00
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:
parent
48cc229b21
commit
7fa997c91d
@ -1,6 +0,0 @@
|
||||
module.exports = {
|
||||
plugins: ['ghost'],
|
||||
extends: [
|
||||
'plugin:ghost/node'
|
||||
]
|
||||
};
|
@ -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.
|
@ -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).
|
@ -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);
|
||||
});
|
||||
};
|
@ -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
|
||||
};
|
@ -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;
|
@ -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"
|
||||
}
|
||||
}
|
@ -1,6 +0,0 @@
|
||||
module.exports = {
|
||||
plugins: ['ghost'],
|
||||
extends: [
|
||||
'plugin:ghost/test'
|
||||
]
|
||||
};
|
@ -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/');
|
||||
});
|
||||
});
|
||||
});
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
@ -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;
|
||||
// });
|
@ -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');
|
@ -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');
|
@ -1,6 +0,0 @@
|
||||
module.exports = {
|
||||
plugins: ['ghost'],
|
||||
extends: [
|
||||
'plugin:ghost/node'
|
||||
]
|
||||
};
|
@ -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.
|
@ -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).
|
@ -1 +0,0 @@
|
||||
module.exports = require('./lib/transform');
|
@ -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);
|
@ -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"
|
||||
}
|
||||
}
|
@ -1,6 +0,0 @@
|
||||
module.exports = {
|
||||
plugins: ['ghost'],
|
||||
extends: [
|
||||
'plugin:ghost/test'
|
||||
]
|
||||
};
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
@ -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;
|
||||
// });
|
@ -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');
|
@ -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;
|
||||
};
|
@ -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');
|
@ -1,6 +0,0 @@
|
||||
module.exports = {
|
||||
plugins: ['ghost'],
|
||||
extends: [
|
||||
'plugin:ghost/node'
|
||||
]
|
||||
};
|
@ -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.
|
@ -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, you’ll 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).
|
@ -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`
|
||||
}
|
||||
};
|
@ -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
|
||||
};
|
@ -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
|
||||
*/
|
@ -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
|
||||
};
|
@ -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"
|
||||
}
|
||||
}
|
@ -1,6 +0,0 @@
|
||||
module.exports = {
|
||||
plugins: ['ghost'],
|
||||
extends: [
|
||||
'plugin:ghost/test'
|
||||
]
|
||||
};
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
25
ghost/limit-service/test/fixtures/errors.js
vendored
25
ghost/limit-service/test/fixtures/errors.js
vendored
@ -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
|
||||
};
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
@ -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');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
@ -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;
|
||||
// });
|
@ -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');
|
@ -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');
|
Loading…
Reference in New Issue
Block a user