diff --git a/ghost/config-url-helpers/.eslintrc.js b/ghost/config-url-helpers/.eslintrc.js deleted file mode 100644 index c9c1bcb522..0000000000 --- a/ghost/config-url-helpers/.eslintrc.js +++ /dev/null @@ -1,6 +0,0 @@ -module.exports = { - plugins: ['ghost'], - extends: [ - 'plugin:ghost/node' - ] -}; diff --git a/ghost/config-url-helpers/LICENSE b/ghost/config-url-helpers/LICENSE deleted file mode 100644 index 19bcb01bef..0000000000 --- a/ghost/config-url-helpers/LICENSE +++ /dev/null @@ -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. diff --git a/ghost/config-url-helpers/README.md b/ghost/config-url-helpers/README.md deleted file mode 100644 index ce3f20424e..0000000000 --- a/ghost/config-url-helpers/README.md +++ /dev/null @@ -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). \ No newline at end of file diff --git a/ghost/config-url-helpers/index.js b/ghost/config-url-helpers/index.js deleted file mode 100644 index 7925f9141e..0000000000 --- a/ghost/config-url-helpers/index.js +++ /dev/null @@ -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); - }); -}; diff --git a/ghost/config-url-helpers/lib/config-url-helpers.js b/ghost/config-url-helpers/lib/config-url-helpers.js deleted file mode 100644 index 1c4311a825..0000000000 --- a/ghost/config-url-helpers/lib/config-url-helpers.js +++ /dev/null @@ -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 -}; diff --git a/ghost/config-url-helpers/lib/utils/deduplicate-subdirectory.js b/ghost/config-url-helpers/lib/utils/deduplicate-subdirectory.js deleted file mode 100644 index 68b20627d9..0000000000 --- a/ghost/config-url-helpers/lib/utils/deduplicate-subdirectory.js +++ /dev/null @@ -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; diff --git a/ghost/config-url-helpers/package.json b/ghost/config-url-helpers/package.json deleted file mode 100644 index df6c37ded9..0000000000 --- a/ghost/config-url-helpers/package.json +++ /dev/null @@ -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" - } -} diff --git a/ghost/config-url-helpers/test/.eslintrc.js b/ghost/config-url-helpers/test/.eslintrc.js deleted file mode 100644 index 829b601eb0..0000000000 --- a/ghost/config-url-helpers/test/.eslintrc.js +++ /dev/null @@ -1,6 +0,0 @@ -module.exports = { - plugins: ['ghost'], - extends: [ - 'plugin:ghost/test' - ] -}; diff --git a/ghost/config-url-helpers/test/config-url-helpers.test.js b/ghost/config-url-helpers/test/config-url-helpers.test.js deleted file mode 100644 index 8c9fdf17c0..0000000000 --- a/ghost/config-url-helpers/test/config-url-helpers.test.js +++ /dev/null @@ -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/'); - }); - }); -}); diff --git a/ghost/config-url-helpers/test/unit/utils/deduplicate-subdirectory.test.js b/ghost/config-url-helpers/test/unit/utils/deduplicate-subdirectory.test.js deleted file mode 100644 index f146167d56..0000000000 --- a/ghost/config-url-helpers/test/unit/utils/deduplicate-subdirectory.test.js +++ /dev/null @@ -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'); - }); - }); -}); diff --git a/ghost/config-url-helpers/test/utils/assertions.js b/ghost/config-url-helpers/test/utils/assertions.js deleted file mode 100644 index 7364ee8aa1..0000000000 --- a/ghost/config-url-helpers/test/utils/assertions.js +++ /dev/null @@ -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; -// }); diff --git a/ghost/config-url-helpers/test/utils/index.js b/ghost/config-url-helpers/test/utils/index.js deleted file mode 100644 index 0d67d86ff8..0000000000 --- a/ghost/config-url-helpers/test/utils/index.js +++ /dev/null @@ -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'); diff --git a/ghost/config-url-helpers/test/utils/overrides.js b/ghost/config-url-helpers/test/utils/overrides.js deleted file mode 100644 index 90203424ee..0000000000 --- a/ghost/config-url-helpers/test/utils/overrides.js +++ /dev/null @@ -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'); diff --git a/ghost/image-transform/.eslintrc.js b/ghost/image-transform/.eslintrc.js deleted file mode 100644 index c9c1bcb522..0000000000 --- a/ghost/image-transform/.eslintrc.js +++ /dev/null @@ -1,6 +0,0 @@ -module.exports = { - plugins: ['ghost'], - extends: [ - 'plugin:ghost/node' - ] -}; diff --git a/ghost/image-transform/LICENSE b/ghost/image-transform/LICENSE deleted file mode 100644 index 19bcb01bef..0000000000 --- a/ghost/image-transform/LICENSE +++ /dev/null @@ -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. diff --git a/ghost/image-transform/README.md b/ghost/image-transform/README.md deleted file mode 100644 index 4392199633..0000000000 --- a/ghost/image-transform/README.md +++ /dev/null @@ -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). diff --git a/ghost/image-transform/index.js b/ghost/image-transform/index.js deleted file mode 100644 index da28016d3e..0000000000 --- a/ghost/image-transform/index.js +++ /dev/null @@ -1 +0,0 @@ -module.exports = require('./lib/transform'); diff --git a/ghost/image-transform/lib/transform.js b/ghost/image-transform/lib/transform.js deleted file mode 100644 index 3dd7ee9227..0000000000 --- a/ghost/image-transform/lib/transform.js +++ /dev/null @@ -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} 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); diff --git a/ghost/image-transform/package.json b/ghost/image-transform/package.json deleted file mode 100644 index 04a12056e5..0000000000 --- a/ghost/image-transform/package.json +++ /dev/null @@ -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" - } -} diff --git a/ghost/image-transform/test/.eslintrc.js b/ghost/image-transform/test/.eslintrc.js deleted file mode 100644 index 829b601eb0..0000000000 --- a/ghost/image-transform/test/.eslintrc.js +++ /dev/null @@ -1,6 +0,0 @@ -module.exports = { - plugins: ['ghost'], - extends: [ - 'plugin:ghost/test' - ] -}; diff --git a/ghost/image-transform/test/transform.test.js b/ghost/image-transform/test/transform.test.js deleted file mode 100644 index a5bd8a1fe4..0000000000 --- a/ghost/image-transform/test/transform.test.js +++ /dev/null @@ -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'); - }); - }); -}); diff --git a/ghost/image-transform/test/utils/assertions.js b/ghost/image-transform/test/utils/assertions.js deleted file mode 100644 index 7364ee8aa1..0000000000 --- a/ghost/image-transform/test/utils/assertions.js +++ /dev/null @@ -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; -// }); diff --git a/ghost/image-transform/test/utils/index.js b/ghost/image-transform/test/utils/index.js deleted file mode 100644 index 597142c39c..0000000000 --- a/ghost/image-transform/test/utils/index.js +++ /dev/null @@ -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'); diff --git a/ghost/image-transform/test/utils/modules.js b/ghost/image-transform/test/utils/modules.js deleted file mode 100644 index ff1295fa37..0000000000 --- a/ghost/image-transform/test/utils/modules.js +++ /dev/null @@ -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; -}; diff --git a/ghost/image-transform/test/utils/overrides.js b/ghost/image-transform/test/utils/overrides.js deleted file mode 100644 index 90203424ee..0000000000 --- a/ghost/image-transform/test/utils/overrides.js +++ /dev/null @@ -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'); diff --git a/ghost/limit-service/.eslintrc.js b/ghost/limit-service/.eslintrc.js deleted file mode 100644 index c9c1bcb522..0000000000 --- a/ghost/limit-service/.eslintrc.js +++ /dev/null @@ -1,6 +0,0 @@ -module.exports = { - plugins: ['ghost'], - extends: [ - 'plugin:ghost/node' - ] -}; diff --git a/ghost/limit-service/LICENSE b/ghost/limit-service/LICENSE deleted file mode 100644 index 19bcb01bef..0000000000 --- a/ghost/limit-service/LICENSE +++ /dev/null @@ -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. diff --git a/ghost/limit-service/README.md b/ghost/limit-service/README.md deleted file mode 100644 index 85caf91ae9..0000000000 --- a/ghost/limit-service/README.md +++ /dev/null @@ -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). diff --git a/ghost/limit-service/index.js b/ghost/limit-service/index.js deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/ghost/limit-service/lib/config.js b/ghost/limit-service/lib/config.js deleted file mode 100644 index 4166a60298..0000000000 --- a/ghost/limit-service/lib/config.js +++ /dev/null @@ -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` - } -}; diff --git a/ghost/limit-service/lib/date-utils.js b/ghost/limit-service/lib/date-utils.js deleted file mode 100644 index cc91f34019..0000000000 --- a/ghost/limit-service/lib/date-utils.js +++ /dev/null @@ -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 -}; diff --git a/ghost/limit-service/lib/limit-service.js b/ghost/limit-service/lib/limit-service.js deleted file mode 100644 index 13145c2666..0000000000 --- a/ghost/limit-service/lib/limit-service.js +++ /dev/null @@ -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} - */ - 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 - */ diff --git a/ghost/limit-service/lib/limit.js b/ghost/limit-service/lib/limit.js deleted file mode 100644 index f2027459ea..0000000000 --- a/ghost/limit-service/lib/limit.js +++ /dev/null @@ -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 -}; diff --git a/ghost/limit-service/package.json b/ghost/limit-service/package.json deleted file mode 100644 index b7df05aa32..0000000000 --- a/ghost/limit-service/package.json +++ /dev/null @@ -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" - } -} diff --git a/ghost/limit-service/test/.eslintrc.js b/ghost/limit-service/test/.eslintrc.js deleted file mode 100644 index 829b601eb0..0000000000 --- a/ghost/limit-service/test/.eslintrc.js +++ /dev/null @@ -1,6 +0,0 @@ -module.exports = { - plugins: ['ghost'], - extends: [ - 'plugin:ghost/test' - ] -}; diff --git a/ghost/limit-service/test/date-utils.test.js b/ghost/limit-service/test/date-utils.test.js deleted file mode 100644 index e0585e6b0e..0000000000 --- a/ghost/limit-service/test/date-utils.test.js +++ /dev/null @@ -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'); - }); - }); -}); diff --git a/ghost/limit-service/test/fixtures/errors.js b/ghost/limit-service/test/fixtures/errors.js deleted file mode 100644 index 7512ccaa5e..0000000000 --- a/ghost/limit-service/test/fixtures/errors.js +++ /dev/null @@ -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 -}; diff --git a/ghost/limit-service/test/limit-service.test.js b/ghost/limit-service/test/limit-service.test.js deleted file mode 100644 index d2492b5315..0000000000 --- a/ghost/limit-service/test/limit-service.test.js +++ /dev/null @@ -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); - }); - }); -}); diff --git a/ghost/limit-service/test/limit.test.js b/ghost/limit-service/test/limit.test.js deleted file mode 100644 index e75b178980..0000000000 --- a/ghost/limit-service/test/limit.test.js +++ /dev/null @@ -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'); - } - }); - }); -}); diff --git a/ghost/limit-service/test/utils/assertions.js b/ghost/limit-service/test/utils/assertions.js deleted file mode 100644 index 7364ee8aa1..0000000000 --- a/ghost/limit-service/test/utils/assertions.js +++ /dev/null @@ -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; -// }); diff --git a/ghost/limit-service/test/utils/index.js b/ghost/limit-service/test/utils/index.js deleted file mode 100644 index 0d67d86ff8..0000000000 --- a/ghost/limit-service/test/utils/index.js +++ /dev/null @@ -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'); diff --git a/ghost/limit-service/test/utils/overrides.js b/ghost/limit-service/test/utils/overrides.js deleted file mode 100644 index 90203424ee..0000000000 --- a/ghost/limit-service/test/utils/overrides.js +++ /dev/null @@ -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');