├── lib ├── joi │ ├── VERSION │ ├── REPOSITORY │ └── README.md ├── mkdirp │ ├── VERSION │ ├── REPOSITORY │ ├── LICENSE │ └── index.js ├── commander │ ├── VERSION │ ├── REPOSITORY │ └── LICENSE ├── indent.js │ ├── VERSION │ ├── REPOSITORY │ └── LICENSE └── simple-mock │ ├── VERSION │ ├── REPOSITORY │ └── LICENSE ├── test ├── resources │ ├── fragment-nested-property.js │ ├── init-doc-definitions.js │ ├── type-id-validator-doc-definitions.js │ ├── fragment-string's-doc-definition.js │ ├── fragment-boolean's-doc-definition.js │ ├── fragment-object-doc-definition.js │ ├── simple-type-filter-doc-definitions.js │ ├── immutable-when-set-doc-definitions.js │ ├── fragment-doc-definitions.js │ ├── any-type-doc-definitions.js │ ├── enum-doc-definitions.js │ ├── document-id-regex-pattern-doc-definitions.js │ ├── uuid-doc-definitions.js │ ├── timezone-doc-definitions.js │ ├── time-doc-definitions.js │ ├── date-doc-definitions.js │ ├── immutable-when-set-strict-doc-definitions.js │ ├── immutable-items-doc-definitions.js │ ├── custom-validation-doc-definitions.js │ ├── datetime-doc-definitions.js │ ├── dynamic-constraints-doc-definitions.js │ ├── custom-actions-doc-definitions.js │ ├── immutable-items-strict-doc-definitions.js │ ├── attachment-reference-doc-definitions.js │ ├── array-doc-definitions.js │ ├── immutable-docs-doc-definitions.js │ ├── general-doc-definitions.js │ ├── property-validators-doc-definitions.js │ ├── hashtable-doc-definitions.js │ ├── immutable-nested-properties-doc-definitions.js │ ├── attachment-constraints-doc-definitions.js │ ├── authorization-doc-definitions.js │ ├── skip-validation-when-value-unchanged-doc-definitions.js │ ├── must-equal-doc-definitions.js │ ├── string-doc-definitions.js │ ├── skip-validation-when-value-unchanged-strict-doc-definitions.js │ ├── required-doc-definitions.js │ ├── range-constraint-doc-definitions.js │ ├── must-not-be-null-doc-definitions.js │ └── must-not-be-missing-doc-definitions.js ├── .jshintrc ├── fragment.spec.js ├── helpers │ └── sample-spec-helper-maker.js ├── type-id-validator.spec.js ├── init.spec.js ├── simple-type-filter.spec.js ├── custom-validation.spec.js ├── enum.spec.js ├── dynamic-constraints.spec.js ├── sample-notifications-reference.spec.js ├── sample-payment-requisitions-reference.spec.js ├── sample-payment-requisition.spec.js ├── document-id-regex-pattern.spec.js ├── sample-payment-processor-definition.spec.js ├── any-type.spec.js ├── sample-notification-transport-processing-summary.spec.js └── sample-payment-processor-settlement.spec.js ├── .gitignore ├── src ├── .jshintrc ├── index.spec.js ├── validation │ ├── dynamic-constraint-schema-maker.js │ ├── validation-environment-maker.js │ ├── validation-environment-maker.spec.js │ ├── dynamic-constraint-schema-maker.spec.js │ └── document-definitions-validator.js ├── index.js ├── loading │ ├── document-definitions-loader.js │ ├── validation-function-loader.js │ ├── file-fragment-loader.js │ ├── file-fragment-loader.spec.js │ ├── document-definitions-loader.spec.js │ └── validation-function-loader.spec.js ├── testing │ ├── test-environment-maker.js │ └── test-environment-maker.spec.js ├── environments │ ├── stubbed-environment-maker.js │ └── stubbed-environment-maker.spec.js └── saving │ ├── validation-function-writer.js │ └── validation-function-writer.spec.js ├── .jshintignore ├── templates ├── environments │ ├── .jshintrc │ ├── validation-environment-template.js │ └── test-environment-template.js └── validation-function │ ├── .jshintrc │ ├── validation-module.js │ └── document-constraints-validation-module.js ├── etc ├── jshintrc-validation-function-template.json └── prepare-tests.sh ├── .jshintrc ├── .github ├── dependabot.yml ├── PULL_REQUEST_TEMPLATE.md ├── workflows │ └── ci.yml └── ISSUE_TEMPLATE.md ├── .nycrc ├── samples ├── fragment-notifications-reference.js ├── fragment-payment-requisitions-reference.js ├── fragment-payment-requisition.js ├── fragment-payment-processor-definition.js ├── fragment-notification-transport-processing-summary.js ├── fragment-notification-transport.js ├── fragment-business.js ├── fragment-payment-attempt.js ├── fragment-notifications-config.js ├── fragment-payment-processor-settlement.js └── fragment-notification.js ├── package.json ├── LICENSE ├── validate-document-definitions ├── make-validation-function └── CHANGELOG.md /lib/joi/VERSION: -------------------------------------------------------------------------------- 1 | 15.1.0 2 | -------------------------------------------------------------------------------- /lib/mkdirp/VERSION: -------------------------------------------------------------------------------- 1 | 0.5.1 2 | -------------------------------------------------------------------------------- /lib/commander/VERSION: -------------------------------------------------------------------------------- 1 | 2.20.0 2 | -------------------------------------------------------------------------------- /lib/indent.js/VERSION: -------------------------------------------------------------------------------- 1 | 0.3.4 2 | -------------------------------------------------------------------------------- /lib/simple-mock/VERSION: -------------------------------------------------------------------------------- 1 | 0.8.0 2 | -------------------------------------------------------------------------------- /lib/joi/REPOSITORY: -------------------------------------------------------------------------------- 1 | https://github.com/hapijs/joi 2 | -------------------------------------------------------------------------------- /lib/commander/REPOSITORY: -------------------------------------------------------------------------------- 1 | https://github.com/tj/commander.js 2 | -------------------------------------------------------------------------------- /lib/indent.js/REPOSITORY: -------------------------------------------------------------------------------- 1 | https://github.com/zebzhao/indent.js 2 | -------------------------------------------------------------------------------- /lib/mkdirp/REPOSITORY: -------------------------------------------------------------------------------- 1 | https://github.com/substack/node-mkdirp 2 | -------------------------------------------------------------------------------- /test/resources/fragment-nested-property.js: -------------------------------------------------------------------------------- 1 | { type: 'integer' } 2 | -------------------------------------------------------------------------------- /lib/simple-mock/REPOSITORY: -------------------------------------------------------------------------------- 1 | https://github.com/jupiter/simple-mock 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.nyc_output 3 | /.vscode 4 | /node_modules 5 | /build 6 | -------------------------------------------------------------------------------- /src/.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../.jshintrc", 3 | "mocha" : true 4 | } 5 | -------------------------------------------------------------------------------- /test/.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../.jshintrc", 3 | "mocha" : true 4 | } 5 | -------------------------------------------------------------------------------- /.jshintignore: -------------------------------------------------------------------------------- 1 | /build/coverage 2 | /lib 3 | /node_modules 4 | /samples 5 | /test/resources 6 | -------------------------------------------------------------------------------- /templates/environments/.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../.jshintrc", 3 | "globals": { 4 | "$DOC_DEFINITIONS_PLACEHOLDER$": false, 5 | "$VALIDATION_FUNC_PLACEHOLDER$": false 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /test/resources/init-doc-definitions.js: -------------------------------------------------------------------------------- 1 | { 2 | initDoc: { 3 | typeFilter: simpleTypeFilter, 4 | authorizedRoles: { write: 'write' }, 5 | propertyValidators: { 6 | testProp: { 7 | type: 'float', 8 | required: true 9 | } 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /test/resources/type-id-validator-doc-definitions.js: -------------------------------------------------------------------------------- 1 | { 2 | typeIdDoc: { 3 | authorizedRoles: { 4 | write: [ 'write' ] 5 | }, 6 | typeFilter: function(doc) { 7 | return doc._id === 'typeIdDoc'; 8 | }, 9 | propertyValidators: { 10 | typeIdProp: typeIdValidator 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /etc/jshintrc-validation-function-template.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../.jshintrc", 3 | "-W025": true, 4 | "esversion": 5, 5 | "node": false, 6 | "unused": false, 7 | "varstmt": false, 8 | "globals": { 9 | "isArray": false, 10 | "log": false, 11 | "sum": false, 12 | "toJSON": false 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "bitwise": true, 3 | "curly": true, 4 | "eqeqeq": true, 5 | "esversion": 6, 6 | "freeze": true, 7 | "futurehostile": true, 8 | "latedef": "nofunc", 9 | "noarg": true, 10 | "node": true, 11 | "nonew": true, 12 | "shadow": false, 13 | "strict": false, 14 | "undef": true, 15 | "unused": true, 16 | "varstmt": true 17 | } 18 | -------------------------------------------------------------------------------- /test/resources/fragment-string's-doc-definition.js: -------------------------------------------------------------------------------- 1 | { 2 | // Uses the type filter that is defined in the document definitions file in which this fragment is to be embedded 3 | typeFilter: myCustomDocTypeFilter('stringFragmentDoc'), 4 | authorizedRoles: { write: 'write' }, 5 | propertyValidators: { 6 | stringProp: { 7 | type: 'string' 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | 4 | # Couchster project dependencies 5 | - package-ecosystem: npm 6 | directory: / 7 | schedule: 8 | interval: daily 9 | versioning-strategy: increase 10 | 11 | # GitHub Actions used in workflows 12 | - package-ecosystem: github-actions 13 | directory: / 14 | schedule: 15 | interval: daily 16 | -------------------------------------------------------------------------------- /templates/validation-function/.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../.jshintrc", 3 | "esversion": 5, 4 | "node": false, 5 | "varstmt": false, 6 | "globals": { 7 | "$DOCUMENT_DEFINITIONS_PLACEHOLDER$": false, 8 | "importValidationFunctionFragment": false, 9 | "isArray": false, 10 | "log": false, 11 | "sum": false, 12 | "toJSON": false 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /test/resources/fragment-boolean's-doc-definition.js: -------------------------------------------------------------------------------- 1 | { 2 | // Uses the type filter that is defined in the document definitions file in which this fragment is to be embedded 3 | typeFilter: myCustomDocTypeFilter('booleanFragmentDoc'), 4 | authorizedRoles: { write: 'write' }, 5 | propertyValidators: { 6 | booleanProp: { 7 | type: 'boolean' 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /test/resources/fragment-object-doc-definition.js: -------------------------------------------------------------------------------- 1 | { 2 | typeFilter: simpleTypeFilter, 3 | authorizedRoles: { write: 'write' }, 4 | propertyValidators: { 5 | objectProp: { 6 | type: 'object', 7 | required: true, 8 | propertyValidators: { 9 | nestedProperty: importDocumentDefinitionFragment('fragment-nested-property.js') 10 | } 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /.nycrc: -------------------------------------------------------------------------------- 1 | { 2 | "check-coverage": true, 3 | "per-file": true, 4 | "statements": 100, 5 | "branches": 100, 6 | "functions": 100, 7 | "lines": 0, 8 | "include": [ 9 | "src/**/*.js" 10 | ], 11 | "exclude": [ 12 | "src/**/*.spec.js" 13 | ], 14 | "reporter": [ 15 | "text-summary", 16 | "lcov" 17 | ], 18 | "cache": true, 19 | "all": true, 20 | "report-dir": "build/coverage" 21 | } 22 | -------------------------------------------------------------------------------- /test/resources/simple-type-filter-doc-definitions.js: -------------------------------------------------------------------------------- 1 | { 2 | myExplicitTypeValidatorDoc: { 3 | authorizedRoles: { write: 'write' }, 4 | typeFilter: simpleTypeFilter, 5 | propertyValidators: { 6 | type: { 7 | type: 'string' 8 | } 9 | } 10 | }, 11 | myImplicitTypeValidatorDoc: { 12 | authorizedRoles: { write: 'write' }, 13 | typeFilter: simpleTypeFilter, 14 | propertyValidators: { } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | # Description 2 | 3 | Enter a few sentences to describe the issue that is being addressed by this change. Review the project's [contribution guidelines](https://github.com/OldSneerJaw/couchster/blob/master/CONTRIBUTING.md) before proceeding. 4 | 5 | # Testing 6 | 7 | Describe how the changes have been tested and how a reviewer can test for themselves. Provide example configuration if necessary. 8 | 9 | # Related Issue 10 | 11 | * GitHub issue link: 12 | -------------------------------------------------------------------------------- /lib/joi/README.md: -------------------------------------------------------------------------------- 1 | This bundle of the [Joi](https://github.com/hapijs/joi) library and its dependencies was generated with the following Webpack build configuration (`webpack.config.js`), which can be executed from the root of the Joi repo: 2 | 3 | ``` 4 | const path = require('path'); 5 | 6 | module.exports = exports = { 7 | target: 'node', 8 | entry: './lib/index.js', 9 | mode: 'production', 10 | optimization: { 11 | minimize: false 12 | }, 13 | output: { 14 | path: path.resolve(__dirname, 'dist'), 15 | filename: 'joi.bundle.js', 16 | libraryTarget: 'commonjs2' 17 | } 18 | }; 19 | ``` 20 | -------------------------------------------------------------------------------- /src/index.spec.js: -------------------------------------------------------------------------------- 1 | const { expect } = require('chai'); 2 | const index = require('./index'); 3 | 4 | describe('Main package module', () => { 5 | it('exposes the public API', () => { 6 | expect(index).to.eql({ 7 | documentDefinitionsValidator: require('./validation/document-definitions-validator'), 8 | validationFunctionLoader: require('./loading/validation-function-loader'), 9 | validationFunctionWriter: require('./saving/validation-function-writer'), 10 | testFixtureMaker: require('./testing/test-fixture-maker'), 11 | validationErrorFormatter: require('./testing/validation-error-formatter') 12 | }); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /test/resources/immutable-when-set-doc-definitions.js: -------------------------------------------------------------------------------- 1 | { 2 | myDoc: { 3 | authorizedRoles: { write: 'write' }, 4 | typeFilter: function(doc) { 5 | return doc._id === 'myDoc'; 6 | }, 7 | propertyValidators: { 8 | staticValidationProp: { 9 | type: 'string', 10 | immutableWhenSet: true 11 | }, 12 | dynamicPropertiesAreImmutable: { 13 | type: 'boolean' 14 | }, 15 | dynamicValidationProp: { 16 | type: 'integer', 17 | immutableWhenSet: function(doc, oldDoc, value, oldValue) { 18 | return doc.dynamicPropertiesAreImmutable; 19 | } 20 | } 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /test/resources/fragment-doc-definitions.js: -------------------------------------------------------------------------------- 1 | function() { 2 | // Used to demonstrate that document definition fragments can access functions from the enclosing document definitions file 3 | function myCustomDocTypeFilter(expectedDocId) { 4 | return function(doc) { 5 | return doc._id === expectedDocId; 6 | }; 7 | } 8 | 9 | return { 10 | singleQuotedFragmentDoc: importDocumentDefinitionFragment( 'fragment-string\'s-doc-definition.js' ), 11 | doubleQuotedFragmentDoc: importDocumentDefinitionFragment( "fragment-boolean\'s-doc-definition.js" ), 12 | nestedImportDoc: importDocumentDefinitionFragment('fragment-object-doc-definition.js') 13 | }; 14 | } 15 | -------------------------------------------------------------------------------- /test/resources/any-type-doc-definitions.js: -------------------------------------------------------------------------------- 1 | function() { 2 | return { 3 | anyTypeDoc: { 4 | typeFilter: simpleTypeFilter, 5 | authorizedRoles: { write: 'write' }, 6 | propertyValidators: { 7 | arrayProp: { 8 | type: 'array', 9 | arrayElementsValidator: { 10 | type: 'any', 11 | required: true 12 | } 13 | }, 14 | hashtableProp: { 15 | type: 'hashtable', 16 | hashtableValuesValidator: { 17 | type: 'any', 18 | immutableWhenSet: true 19 | } 20 | }, 21 | anyProp: { 22 | type: 'any' 23 | } 24 | } 25 | } 26 | }; 27 | } 28 | -------------------------------------------------------------------------------- /test/resources/enum-doc-definitions.js: -------------------------------------------------------------------------------- 1 | { 2 | enumDoc: { 3 | typeFilter: function(doc, oldDoc) { 4 | return doc._id === 'enumDoc'; 5 | }, 6 | authorizedRoles: { write: 'write' }, 7 | propertyValidators: { 8 | staticEnumProp: { 9 | type: 'enum', 10 | predefinedValues: [ 'value1', 2 ] 11 | }, 12 | invalidEnumProp: { 13 | type: 'enum' 14 | }, 15 | dynamicPredefinedValues: { 16 | type: 'array' 17 | }, 18 | dynamicEnumProp: { 19 | type: 'enum', 20 | predefinedValues: function(doc, oldDoc, value, oldValue) { 21 | return doc.dynamicPredefinedValues; 22 | } 23 | } 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /test/resources/document-id-regex-pattern-doc-definitions.js: -------------------------------------------------------------------------------- 1 | function() { 2 | return { 3 | staticDocumentIdRegexPatternDoc: { 4 | typeFilter: simpleTypeFilter, 5 | authorizedRoles: { write: 'write' }, 6 | documentIdRegexPattern: /^my-doc\.\d+$/, 7 | propertyValidators: { } 8 | }, 9 | dynamicDocumentIdRegexPatternDoc: { 10 | typeFilter: simpleTypeFilter, 11 | authorizedRoles: { write: 'write' }, 12 | documentIdRegexPattern: function(doc) { 13 | return new RegExp('^entity\\.' + doc.entityId + '$'); 14 | }, 15 | propertyValidators: { 16 | entityId: { 17 | type: 'string', 18 | required: true 19 | } 20 | } 21 | } 22 | }; 23 | } 24 | -------------------------------------------------------------------------------- /src/validation/dynamic-constraint-schema-maker.js: -------------------------------------------------------------------------------- 1 | const joi = require('../../lib/joi/joi.bundle'); 2 | 3 | /** 4 | * Generates a schema that accepts as valid either a function with the specified maximum number of arguments (arity) or 5 | * the specified other schema. 6 | * 7 | * @param {Object} otherSchema The Joi schema to be applied if the value is not a function 8 | * @param {number} maxArity The maximum number of function arguments to allow when a function is encountered 9 | */ 10 | module.exports = exports = function makeConstraintSchemaDynamic(otherSchema, maxArity) { 11 | return joi.any() 12 | .when( 13 | joi.func(), 14 | { 15 | then: joi.func().maxArity(maxArity), 16 | otherwise: otherSchema 17 | }); 18 | }; 19 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - '**' 10 | 11 | workflow_dispatch: # Allows the workflow to be triggered manually 12 | 13 | jobs: 14 | unit-test: 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | fail-fast: false 19 | matrix: 20 | node-version: ['14', 'lts/*', 'latest'] # 14 is the oldest supported Node.js version 21 | 22 | steps: 23 | - uses: actions/checkout@v6 24 | 25 | - name: Install Node.js 26 | uses: actions/setup-node@v6 27 | with: 28 | node-version: ${{ matrix.node-version }} 29 | 30 | - name: Install dependencies 31 | run: npm install 32 | 33 | - name: Run unit tests 34 | run: npm test 35 | -------------------------------------------------------------------------------- /test/resources/uuid-doc-definitions.js: -------------------------------------------------------------------------------- 1 | { 2 | uuidDocType: { 3 | typeFilter: simpleTypeFilter, 4 | authorizedRoles: { write: 'write' }, 5 | propertyValidators: { 6 | formatValidationProp: { 7 | type: 'uuid' 8 | }, 9 | rangeValidationProp: { 10 | type: 'uuid', 11 | minimumValue: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 12 | maximumValue: 'DDDDDDDD-DDDD-DDDD-DDDD-DDDDDDDDDDDD' 13 | }, 14 | immutableValidationProp: { 15 | type: 'uuid', 16 | immutableWhenSet: true 17 | } 18 | } 19 | }, 20 | uuidMustEqualDocType: { 21 | typeFilter: simpleTypeFilter, 22 | authorizedRoles: { write: 'write' }, 23 | propertyValidators: { 24 | equalityValidationProp: { 25 | type: 'uuid', 26 | mustEqual: '5e7f697b-fe56-4b98-a68b-aae104bff1d4' 27 | } 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /templates/environments/validation-environment-template.js: -------------------------------------------------------------------------------- 1 | function makeValidationEnvironment(simpleMock) { 2 | const newDoc = { }; 3 | const oldDoc = { }; 4 | const typeIdValidator = { type: 'string' }; 5 | const simpleTypeFilter = simpleMock.stub(); 6 | const isDocumentMissingOrDeleted = simpleMock.stub(); 7 | const isValueNullOrUndefined = simpleMock.stub(); 8 | 9 | const isArray = Array.isArray; 10 | const log = simpleMock.stub(); 11 | const toJSON = JSON.stringify; 12 | 13 | function sum(list) { 14 | return list.reduce((accumulator, item) => accumulator + item, 0); 15 | } 16 | 17 | return { 18 | newDoc, 19 | oldDoc, 20 | typeIdValidator, 21 | simpleTypeFilter, 22 | isDocumentMissingOrDeleted, 23 | isValueNullOrUndefined, 24 | isArray, 25 | log, 26 | sum, 27 | toJSON, 28 | documentDefinitions: $DOC_DEFINITIONS_PLACEHOLDER$ 29 | }; 30 | } 31 | -------------------------------------------------------------------------------- /samples/fragment-notifications-reference.js: -------------------------------------------------------------------------------- 1 | { 2 | authorizedRoles: function(doc, oldDoc) { 3 | return toDefaultDbRoles(doc, oldDoc, 'NOTIFICATIONS'); 4 | }, 5 | typeFilter: function(doc, oldDoc) { 6 | return createBusinessEntityRegex('notifications$').test(doc._id); 7 | }, 8 | propertyValidators: { 9 | allNotificationIds: { 10 | // A list of the IDs of every notification that has ever been generated for the business 11 | type: 'array', 12 | required: false, 13 | arrayElementsValidator: { 14 | type: 'string', 15 | required: true, 16 | mustNotBeEmpty: true 17 | } 18 | }, 19 | unreadNotificationIds: { 20 | // The IDs of notifications that have not yet been read 21 | type: 'array', 22 | required: false, 23 | arrayElementsValidator: { 24 | type: 'string', 25 | required: true, 26 | mustNotBeEmpty: true 27 | } 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /test/resources/timezone-doc-definitions.js: -------------------------------------------------------------------------------- 1 | { 2 | timezoneDoc: { 3 | typeFilter: simpleTypeFilter, 4 | authorizedRoles: { write: 'write' }, 5 | propertyValidators: { 6 | formatValidationProp: { 7 | type: 'timezone' 8 | }, 9 | minAndMaxInclusiveValuesProp: { 10 | type: 'timezone', 11 | minimumValue: 'Z', 12 | maximumValue: '+00:00' 13 | }, 14 | minAndMaxExclusiveValuesProp: { 15 | type: 'timezone', 16 | minimumValueExclusive: '-11:31', 17 | maximumValueExclusive: '+12:31' 18 | }, 19 | immutableValidationProp: { 20 | type: 'timezone', 21 | immutable: true 22 | } 23 | } 24 | }, 25 | timezoneMustEqualDocType: { 26 | typeFilter: simpleTypeFilter, 27 | authorizedRoles: { write: 'write' }, 28 | propertyValidators: { 29 | equalityValidationProp: { 30 | type: 'timezone', 31 | mustEqual: 'Z' 32 | } 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /test/resources/time-doc-definitions.js: -------------------------------------------------------------------------------- 1 | { 2 | timeDoc: { 3 | typeFilter: simpleTypeFilter, 4 | authorizedRoles: { write: 'write' }, 5 | propertyValidators: { 6 | formatValidationProp: { 7 | type: 'time' 8 | }, 9 | minAndMaxInclusiveValuesProp: { 10 | type: 'time', 11 | minimumValue: '01:08:00.000', 12 | maximumValue: '01:09:01' 13 | }, 14 | minAndMaxExclusiveValuesProp: { 15 | type: 'time', 16 | minimumValueExclusive: '13:42:00.999', 17 | maximumValueExclusive: '13:42:01.002' 18 | }, 19 | immutableValidationProp: { 20 | type: 'time', 21 | immutableWhenSet: true 22 | } 23 | } 24 | }, 25 | timeMustEqualDocType: { 26 | typeFilter: simpleTypeFilter, 27 | authorizedRoles: { write: 'write' }, 28 | propertyValidators: { 29 | equalityValidationProp: { 30 | type: 'time', 31 | mustEqual: '22:56:00.000' 32 | } 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "couchster", 3 | "version": "1.2.1", 4 | "description": "A tool to build comprehensive validation functions for Apache CouchDB", 5 | "keywords": [ 6 | "apache", 7 | "apache-couchdb", 8 | "couchdb", 9 | "couchster", 10 | "synctos", 11 | "validation" 12 | ], 13 | "main": "src/index.js", 14 | "dependencies": {}, 15 | "devDependencies": { 16 | "chai": "^4.5.0", 17 | "jshint": "^2.13.6", 18 | "lodash": "^4.17.21", 19 | "mocha": "^11.7.5", 20 | "mock-require": "^3.0.3", 21 | "nyc": "^17.1.0" 22 | }, 23 | "scripts": { 24 | "clean": "rm -rf build .nyc_output", 25 | "test": "etc/prepare-tests.sh && nyc mocha \"**/*.spec.js\"" 26 | }, 27 | "license": "MIT", 28 | "repository": { 29 | "type": "git", 30 | "url": "https://github.com/OldSneerJaw/couchster" 31 | }, 32 | "bin": { 33 | "couchster": "./make-validation-function", 34 | "couchster-validate": "./validate-document-definitions" 35 | }, 36 | "engines": { 37 | "node": ">=8.11.0" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | // This module comprises the public API for couchster. 2 | 3 | /** 4 | * The document-definitions-validator module. Reports violations of the document definitions schema. 5 | */ 6 | exports.documentDefinitionsValidator = require('./validation/document-definitions-validator'); 7 | 8 | /** 9 | * The validation-function-loader module. Reads validation functions from files. 10 | */ 11 | exports.validationFunctionLoader = require('./loading/validation-function-loader'); 12 | 13 | /** 14 | * The validation-function-writer module. Writes validation functions to files. 15 | */ 16 | exports.validationFunctionWriter = require('./saving/validation-function-writer'); 17 | 18 | /** 19 | * The test-fixture-maker module. Provides a number of conveniences to test the behaviour of document definitions. 20 | */ 21 | exports.testFixtureMaker = require('./testing/test-fixture-maker'); 22 | 23 | /** 24 | * The validation-error-formatter module. Formats document validation error messages for use in document definition tests. 25 | */ 26 | exports.validationErrorFormatter = require('./testing/validation-error-formatter'); 27 | -------------------------------------------------------------------------------- /templates/environments/test-environment-template.js: -------------------------------------------------------------------------------- 1 | function makeTestEnvironment(simpleMock) { 2 | const isArray = Array.isArray; 3 | const log = simpleMock.stub(); 4 | const toJSON = JSON.stringify; 5 | 6 | function sum(list) { 7 | return list.reduce((accumulator, item) => accumulator + item, 0); 8 | } 9 | 10 | return { 11 | isArray, 12 | log, 13 | sum, 14 | toJSON, 15 | validationFunction: function(newDoc, oldDoc, userContext, securityInfo) { 16 | try { 17 | ($VALIDATION_FUNC_PLACEHOLDER$)(newDoc, oldDoc, userContext, securityInfo); 18 | } catch (error) { 19 | /* Ensure that validation function errors are thrown as Error objects in test cases to avoid misleading test 20 | failure messages (https://github.com/OldSneerJaw/couchster/issues/15) */ 21 | if (error.forbidden && !(error instanceof Error)) { 22 | const wrapperError = new Error(error.forbidden); 23 | wrapperError.forbidden = error.forbidden; 24 | 25 | throw wrapperError; 26 | } else { 27 | throw error; 28 | } 29 | } 30 | } 31 | }; 32 | } 33 | -------------------------------------------------------------------------------- /src/validation/validation-environment-maker.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const simpleMock = require('../../lib/simple-mock/index'); 3 | const stubbedEnvironmentMaker = require('../environments/stubbed-environment-maker'); 4 | 5 | /** 6 | * Parses the given document definitions string as JavaScript and creates a stubbed environment where the global CouchDB functions and 7 | * variables (e.g. doc, oldDoc, simpleTypeFilter, toJSON) are simple stubs. 8 | * 9 | * @param {string} docDefinitionsString The document definitions as a string 10 | * 11 | * @returns A JavaScript object that exposes the document definitions via the "documentDefinitions" property along with the stubbed global 12 | * dependencies via properties that match their names (e.g. "newDoc", "oldDoc", "typeIdValidator", "simpleTypeFilter") 13 | */ 14 | exports.create = function(docDefinitionsString) { 15 | const envFunction = stubbedEnvironmentMaker.create( 16 | path.resolve(__dirname, '../../templates/environments/validation-environment-template.js'), 17 | '$DOC_DEFINITIONS_PLACEHOLDER$', 18 | docDefinitionsString); 19 | 20 | return envFunction(simpleMock); 21 | }; 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Kashoo Cloud Accounting Inc., Joel Andrews 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /samples/fragment-payment-requisitions-reference.js: -------------------------------------------------------------------------------- 1 | { 2 | authorizedRoles: function(doc, oldDoc) { 3 | return toDefaultDbRoles(doc, oldDoc, 'INVOICE_PAYMENT_REQUISITIONS'); 4 | }, 5 | typeFilter: function(doc, oldDoc) { 6 | return createBusinessEntityRegex('invoice\\.[A-Za-z0-9_-]+.paymentRequisitions$').test(doc._id); 7 | }, 8 | propertyValidators: { 9 | paymentProcessorId: { 10 | // The ID of the payment processor to use 11 | type: 'string', 12 | required: true, 13 | mustNotBeEmpty: true 14 | }, 15 | paymentRequisitionIds: { 16 | // A list of payment requisitions that were issued for the invoice 17 | type: 'array', 18 | required: true, 19 | mustNotBeEmpty: true, 20 | arrayElementsValidator: { 21 | type: 'string', 22 | required: true, 23 | mustNotBeEmpty: true 24 | } 25 | }, 26 | paymentAttemptIds: { 27 | // A list of payment attempts that were made for the invoice 28 | type: 'array', 29 | arrayElementsValidator: { 30 | type: 'string', 31 | required: true, 32 | mustNotBeEmpty: true 33 | } 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /lib/simple-mock/LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Pieter Raubenheimer 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /samples/fragment-payment-requisition.js: -------------------------------------------------------------------------------- 1 | { 2 | authorizedRoles: function(doc, oldDoc) { 3 | return toDefaultDbRoles(doc, oldDoc, 'INVOICE_PAYMENT_REQUISITIONS'); 4 | }, 5 | typeFilter: function(doc, oldDoc) { 6 | return /^paymentRequisition\.[A-Za-z0-9_-]+$/.test(doc._id); 7 | }, 8 | cannotReplace: true, 9 | propertyValidators: { 10 | businessId: { 11 | // The ID of the business with which the payment requisition is associated 12 | type: 'integer', 13 | minimumValue: 1, 14 | customValidation: validateBusinessIdProperty 15 | }, 16 | invoiceRecordId: { 17 | // The ID of the invoice with which the payment requisition is associated 18 | type: 'integer', 19 | required: true, 20 | minimumValue: 1 21 | }, 22 | issuedAt: { 23 | // When the payment requisition was sent/issued 24 | type: 'datetime' 25 | }, 26 | issuedByUserId: { 27 | // The ID of the Kashoo user that issued the payment requisition 28 | type: 'integer', 29 | minimumValue: 1 30 | }, 31 | invoiceRecipients: { 32 | // Who received the payment requisition 33 | type: 'string' 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /lib/commander/LICENSE: -------------------------------------------------------------------------------- 1 | (The MIT License) 2 | 3 | Copyright (c) 2011 TJ Holowaychuk 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | 'Software'), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 21 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 22 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /lib/indent.js/LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | ===================== 3 | 4 | Copyright © 2018 Zeb Zhao 5 | 6 | Permission is hereby granted, free of charge, to any person 7 | obtaining a copy of this software and associated documentation 8 | files (the “Software”), to deal in the Software without 9 | restriction, including without limitation the rights to use, 10 | copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | copies of the Software, and to permit persons to whom the 12 | Software is furnished to do so, subject to the following 13 | conditions: 14 | 15 | The above copyright notice and this permission notice shall be 16 | included in all copies or substantial portions of the Software. 17 | 18 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, 19 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 20 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 21 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 22 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 23 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 24 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 25 | OTHER DEALINGS IN THE SOFTWARE. 26 | -------------------------------------------------------------------------------- /lib/mkdirp/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2010 James Halliday (mail@substack.net) 2 | 3 | This project is free software released under the MIT/X11 license: 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /test/resources/date-doc-definitions.js: -------------------------------------------------------------------------------- 1 | { 2 | dateDoc: { 3 | authorizedRoles: { write: 'write' }, 4 | typeFilter: function(doc) { 5 | return doc._id === 'dateDoc'; 6 | }, 7 | propertyValidators: { 8 | inclusiveRangeValidationProp: { 9 | type: 'date', 10 | minimumValue: new Date(Date.UTC(2015, 11, 31, 23, 59, 59, 999)), 11 | maximumValue: new Date(Date.UTC(2016, 0, 1, 23, 59, 59, 999)) 12 | }, 13 | exclusiveRangeValidationProp: { 14 | type: 'date', 15 | minimumValueExclusive: '2018', 16 | maximumValueExclusive: '2018-02-02' 17 | }, 18 | formatValidationProp: { 19 | type: 'date' 20 | }, 21 | immutableValidationProp: { 22 | type: 'date', 23 | immutableWhenSet: true 24 | } 25 | } 26 | }, 27 | dateMustEqualDocType: { 28 | typeFilter: function(doc) { 29 | return doc._id === 'dateMustEqualDoc'; 30 | }, 31 | authorizedRoles: { write: 'write' }, 32 | propertyValidators: { 33 | equalityValidationProp: { 34 | type: 'date', 35 | mustEqual: function() { 36 | return new Date(Date.UTC(2018, 0, 1, 0, 0, 0, 0)); 37 | } 38 | } 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /test/fragment.spec.js: -------------------------------------------------------------------------------- 1 | const testFixtureMaker = require('../src/testing/test-fixture-maker'); 2 | 3 | describe('Document definition fragments:', () => { 4 | const testFixture = 5 | testFixtureMaker.initFromValidationFunction('build/validation-functions/test-fragment-validation-function.js'); 6 | 7 | afterEach(() => { 8 | testFixture.resetTestEnvironment(); 9 | }); 10 | 11 | it('can create documents for a document type whose definition was imported with a single-quoted filename', () => { 12 | const doc = { 13 | _id: 'stringFragmentDoc', 14 | stringProp: '2017-01-06' 15 | }; 16 | 17 | testFixture.verifyDocumentCreated(doc); 18 | }); 19 | 20 | it('can create documents for a document type whose definition was imported with a double-quoted filename', () => { 21 | const doc = { 22 | _id: 'booleanFragmentDoc', 23 | booleanProp: true 24 | }; 25 | 26 | testFixture.verifyDocumentCreated(doc); 27 | }); 28 | 29 | it('can create documents with nested imports', () => { 30 | const doc = { 31 | _id: 'objectFragmentDoc', 32 | type: 'nestedImportDoc', 33 | objectProp: { nestedProperty: -58 } 34 | }; 35 | 36 | testFixture.verifyDocumentCreated(doc); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /test/resources/immutable-when-set-strict-doc-definitions.js: -------------------------------------------------------------------------------- 1 | { 2 | myDoc: { 3 | authorizedRoles: { write: 'write' }, 4 | typeFilter: function(doc) { 5 | return doc._id === 'myDoc'; 6 | }, 7 | propertyValidators: { 8 | staticValidationProp: { 9 | type: 'string', 10 | immutableWhenSetStrict: true 11 | }, 12 | staticImmutableDateProp: { 13 | type: 'date', 14 | immutableStrict: true 15 | }, 16 | staticImmutableDatetimeProp: { 17 | type: 'datetime', 18 | immutableStrict: true 19 | }, 20 | staticImmutableTimeProp: { 21 | type: 'time', 22 | immutableStrict: true 23 | }, 24 | staticImmutableTimezoneProp: { 25 | type: 'timezone', 26 | immutableStrict: true 27 | }, 28 | staticImmutableUuidProp: { 29 | type: 'uuid', 30 | immutableStrict: true 31 | }, 32 | dynamicPropertiesAreImmutable: { 33 | type: 'boolean' 34 | }, 35 | dynamicValidationProp: { 36 | type: 'integer', 37 | immutableWhenSetStrict: function(doc, oldDoc, value, oldValue) { 38 | return doc.dynamicPropertiesAreImmutable; 39 | } 40 | } 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /test/resources/immutable-items-doc-definitions.js: -------------------------------------------------------------------------------- 1 | function() { 2 | function isImmutable(doc, oldDoc, value, oldValue) { 3 | return doc.dynamicPropertiesAreImmutable; 4 | } 5 | 6 | return { 7 | immutableItemsDoc: { 8 | authorizedRoles: { write: 'write' }, 9 | typeFilter: function(doc) { 10 | return doc._id === 'immutableItemsDoc'; 11 | }, 12 | propertyValidators: { 13 | staticImmutableArrayProp: { 14 | type: 'array', 15 | immutable: true 16 | }, 17 | staticImmutableObjectProp: { 18 | type: 'object', 19 | immutable: true 20 | }, 21 | staticImmutableHashtableProp: { 22 | type: 'hashtable', 23 | immutable: true 24 | }, 25 | dynamicPropertiesAreImmutable: { 26 | type: 'boolean' 27 | }, 28 | dynamicImmutableArrayProp: { 29 | type: 'array', 30 | immutable: isImmutable 31 | }, 32 | dynamicImmutableObjectProp: { 33 | type: 'object', 34 | immutable: isImmutable 35 | }, 36 | dynamicImmutableHashtableProp: { 37 | type: 'hashtable', 38 | immutable: isImmutable 39 | } 40 | } 41 | } 42 | }; 43 | } 44 | -------------------------------------------------------------------------------- /src/loading/document-definitions-loader.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Loads the document definitions from the specified file. Any document definition fragments referenced therein will be resolved 3 | * automatically. 4 | * 5 | * @param {string} docDefinitionsFile The path to the document definitions file 6 | * 7 | * @returns {string} The full contents of the document definitions as a string 8 | */ 9 | exports.load = loadFromFile; 10 | 11 | const fs = require('fs'); 12 | const path = require('path'); 13 | const fileFragmentLoader = require('./file-fragment-loader'); 14 | 15 | function loadFromFile(docDefinitionsFile) { 16 | let docDefinitions; 17 | try { 18 | docDefinitions = fs.readFileSync(docDefinitionsFile, 'utf8').trim(); 19 | } catch (ex) { 20 | if (ex.code === 'ENOENT') { 21 | console.error('ERROR: Document definitions file does not exist'); 22 | } else { 23 | console.error(`ERROR: Unable to read the document definitions file: ${ex}`); 24 | } 25 | 26 | throw ex; 27 | } 28 | 29 | const docDefinitionsDir = path.dirname(docDefinitionsFile); 30 | 31 | // Automatically replace instances of the "importDocumentDefinitionFragment" macro with the contents of the file that is specified by each 32 | return fileFragmentLoader.load(docDefinitionsDir, 'importDocumentDefinitionFragment', docDefinitions); 33 | } 34 | -------------------------------------------------------------------------------- /templates/validation-function/validation-module.js: -------------------------------------------------------------------------------- 1 | function validationModule(utils, simpleTypeFilter, typeIdValidator) { 2 | var documentConstraintsValidationModule = 3 | importValidationFunctionFragment('document-constraints-validation-module.js')(utils); 4 | var documentPropertiesValidationModule = 5 | importValidationFunctionFragment('document-properties-validation-module.js')(utils, simpleTypeFilter, typeIdValidator); 6 | 7 | return { 8 | validateDoc: function(newDoc, oldDoc, userContext, securityInfo, docDefinition, docType) { 9 | var documentConstraintValidationErrors = 10 | documentConstraintsValidationModule.validateDocument(newDoc, oldDoc, docDefinition); 11 | 12 | // Only validate the document's contents if it's being created or replaced. There's no need if it's being deleted. 13 | var propertyConstraintValidationErrors = !newDoc._deleted ? 14 | documentPropertiesValidationModule.validateProperties(newDoc, oldDoc, userContext, securityInfo, docDefinition) : 15 | [ ]; 16 | 17 | var validationErrors = documentConstraintValidationErrors.concat(propertyConstraintValidationErrors); 18 | 19 | if (validationErrors.length > 0) { 20 | throw { forbidden: 'Invalid ' + docType + ' document: ' + validationErrors.join('; ') }; 21 | } else { 22 | return true; 23 | } 24 | } 25 | }; 26 | } 27 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | Select from either the Feature Request or Bug Report templates below to file an issue. Review the project's [contribution guidelines](https://github.com/OldSneerJaw/couchster/blob/master/CONTRIBUTING.md) before proceeding. 2 | 3 | # Feature Request 4 | 5 | Complete this section to submit a request for a new feature or an enhancement to an existing feature. Delete this section if you would rather submit a bug report instead. 6 | 7 | ## Description 8 | 9 | Detail the feature and explain how it would be useful. 10 | 11 | ## Examples 12 | 13 | Provide example configuration, JSON documents, usage steps, etc. to further illustrate your request. 14 | 15 | # Bug Report 16 | 17 | Complete this section to report a bug in the application's behaviour or in the sync functions it generates. Delete this section if you would rather submit a feature request instead. 18 | 19 | ## Steps to reproduce 20 | 21 | Provide a step-by-step guide to reproduce the issue: 22 | 23 | 1. Do X 24 | 2. Do Y 25 | 3. etc. 26 | 27 | ## Actual behaviour 28 | 29 | Explain what actually happens if the steps are executed. 30 | 31 | ## Expected behaviour 32 | 33 | Explain what _should_ happen if the steps are executed. 34 | 35 | ## Environment 36 | 37 | List the version(s) of couchster, Apache CouchDB and operating system that the issue has been observed on: 38 | 39 | * Couchster: 40 | * CouchDB: 41 | * Operating system: 42 | -------------------------------------------------------------------------------- /test/resources/custom-validation-doc-definitions.js: -------------------------------------------------------------------------------- 1 | function() { 2 | return { 3 | customValidationDoc: { 4 | typeFilter: simpleTypeFilter, 5 | authorizedRoles: { write: 'write' }, 6 | propertyValidators: { 7 | baseProp: { 8 | type: 'object', 9 | propertyValidators: { 10 | failValidation: { 11 | type: 'boolean' 12 | }, 13 | customValidationProp: { 14 | type: 'string', 15 | customValidation: function(doc, oldDoc, currentItemEntry, validationItemStack, userContext, securityInfo) { 16 | var parentItemValue = validationItemStack[validationItemStack.length - 1].itemValue; 17 | if (parentItemValue && parentItemValue.failValidation) { 18 | return [ 19 | 'doc: ' + JSON.stringify(doc), 20 | 'oldDoc: ' + JSON.stringify(oldDoc), 21 | 'currentItemEntry: ' + JSON.stringify(currentItemEntry), 22 | 'validationItemStack: ' + JSON.stringify(validationItemStack), 23 | 'userContext: ' + JSON.stringify(userContext), 24 | 'securityInfo: ' + JSON.stringify(securityInfo) 25 | ]; 26 | } else { 27 | return [ ]; 28 | } 29 | } 30 | } 31 | } 32 | } 33 | } 34 | } 35 | }; 36 | } 37 | -------------------------------------------------------------------------------- /test/resources/datetime-doc-definitions.js: -------------------------------------------------------------------------------- 1 | { 2 | datetimeDoc: { 3 | authorizedRoles: { write: 'write' }, 4 | typeFilter: function(doc) { 5 | return doc._id === 'datetimeDoc'; 6 | }, 7 | propertyValidators: { 8 | inclusiveRangeValidationAsDatetimesProp: { 9 | type: 'datetime', 10 | minimumValue: new Date('2016-06-23T21:52:17.123-08:00'), 11 | maximumValue: '2016-06-24T05:52:17.123Z' // This is the same date and time, just specified as UTC 12 | }, 13 | inclusiveRangeValidationAsDatesOnlyProp: { 14 | type: 'datetime', 15 | minimumValue: new Date('2016-06-24'), 16 | maximumValue: '2016-06-24' 17 | }, 18 | exclusiveRangeValidationAsDatetimesProp: { 19 | type: 'datetime', 20 | minimumValueExclusive: '2018-02-08T12:22:37.9', 21 | maximumValueExclusive: '2018-02-08T12:22:38.1' 22 | }, 23 | formatValidationProp: { 24 | type: 'datetime' 25 | }, 26 | immutabilityValidationProp: { 27 | type: 'datetime', 28 | immutable: true 29 | } 30 | } 31 | }, 32 | datetimeMustEqualDocType: { 33 | typeFilter: function(doc) { 34 | return doc._id === 'datetimeMustEqualDoc'; 35 | }, 36 | authorizedRoles: { write: 'write' }, 37 | propertyValidators: { 38 | equalityValidationProp: { 39 | type: 'datetime', 40 | mustEqual: '2018-01-01T11:00:00.000+09:30' 41 | } 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /test/resources/dynamic-constraints-doc-definitions.js: -------------------------------------------------------------------------------- 1 | function() { 2 | function sequenceValue(doc, oldDoc, value, oldValue) { 3 | var effectiveCurrentValue = (value >= 0) ? value : 0; 4 | return oldDoc ? oldValue + 1 : effectiveCurrentValue; 5 | } 6 | 7 | return { 8 | myDoc: { 9 | typeFilter: simpleTypeFilter, 10 | authorizedRoles: { write: 'write' }, 11 | propertyValidators: { 12 | dynamicReferenceId: { 13 | type: 'integer' 14 | }, 15 | validationByDocProperty: { 16 | // This property's regex is defined by another property on the document. It is used to verify that the correct values are passed 17 | // as the dynamic validation function's "doc" and "oldDoc" parameters. 18 | type: 'string', 19 | regexPattern: function(doc, oldDoc, value, oldValue) { 20 | var dynamicId = oldDoc ? oldDoc.dynamicReferenceId : doc.dynamicReferenceId; 21 | 22 | return new RegExp('^foo-' + dynamicId + '-bar$'); 23 | } 24 | }, 25 | validationByValueProperty: { 26 | // This property's value is a function of its current or previous value. It is used to verify that the correct values are passed 27 | // as the dynamic validation functions "value" and "oldValue" parameters. 28 | type: 'integer', 29 | minimumValue: sequenceValue, 30 | maximumValue: sequenceValue 31 | } 32 | } 33 | } 34 | }; 35 | } 36 | -------------------------------------------------------------------------------- /src/testing/test-environment-maker.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const simpleMock = require('../../lib/simple-mock/index'); 3 | const stubbedEnvironmentMaker = require('../environments/stubbed-environment-maker'); 4 | 5 | /** 6 | * Creates simulated CouchDB document validation function environments for use in tests. 7 | * 8 | * @param {string} validationFunctionString The raw string contents of the validation function 9 | * @param {string} [validationFunctionFile] The optional path to the validation function file, to be used to generate 10 | * stack traces when errors occur 11 | * 12 | * @returns {Object} The simulated environment that was created for the validation function 13 | */ 14 | exports.create = function(validationFunctionString, validationFunctionFile) { 15 | // If the given file path is relative, it will be interpreted as relative to the process' current working directory. 16 | // On the other hand, if it's already absolute, it will remain unchanged. 17 | const absoluteValidationFuncFilePath = 18 | validationFunctionFile ? path.resolve(process.cwd(), validationFunctionFile) : validationFunctionFile; 19 | 20 | const envFunction = stubbedEnvironmentMaker.create( 21 | path.resolve(__dirname, '../../templates/environments/test-environment-template.js'), 22 | '$VALIDATION_FUNC_PLACEHOLDER$', 23 | validationFunctionString, 24 | absoluteValidationFuncFilePath); 25 | 26 | return envFunction(simpleMock); 27 | }; 28 | -------------------------------------------------------------------------------- /samples/fragment-payment-processor-definition.js: -------------------------------------------------------------------------------- 1 | { 2 | authorizedRoles: function(doc, oldDoc) { 3 | return toDefaultDbRoles(doc, oldDoc, 'CUSTOMER_PAYMENT_PROCESSORS'); 4 | }, 5 | typeFilter: function(doc, oldDoc) { 6 | return createBusinessEntityRegex('paymentProcessor\\.[A-Za-z0-9_-]+$').test(doc._id); 7 | }, 8 | propertyValidators: { 9 | provider: { 10 | // The payment processor type (e.g. "bluepay", "stripe") 11 | type: 'string', 12 | required: true, 13 | mustNotBeEmpty: true 14 | }, 15 | spreedlyGatewayToken: { 16 | // The unique token assigned to the payment processor when it was registered with Spreedly 17 | type: 'string', 18 | required: true, 19 | mustNotBeEmpty: true 20 | }, 21 | accountId: { 22 | // The ID of the Books account in which to record payments 23 | type: 'integer', 24 | required: true, 25 | minimumValue: 1 26 | }, 27 | displayName: { 28 | // A friendly display name for the payment processor 29 | type: 'string' 30 | }, 31 | supportedCurrencyCodes: { 32 | // A list of currency codes that are supported by the payment processor. If this property is null or undefined, it means that all 33 | // currencies are supported by the payment processor. 34 | type: 'array', 35 | arrayElementsValidator: { 36 | type: 'string', 37 | required: true, 38 | regexPattern: iso4217CurrencyCodeRegex 39 | } 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /validate-document-definitions: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const commander = require('./lib/commander/index'); 4 | const docDefinitionsLoader = require('./src/loading/document-definitions-loader'); 5 | const docDefinitionsValidator = require('./src/validation/document-definitions-validator'); 6 | 7 | const errorStatus = 1; 8 | const { version } = require('./package'); 9 | 10 | // Parse the commandline arguments 11 | commander.arguments('') 12 | .version(version, '-v, --version') 13 | .description('A utility for validating the structure and semantics of a couchster document definitions file.'); 14 | 15 | commander.on('--help', () => { 16 | // Add some extra information after the main body of the usage/help text 17 | console.info(` 18 | For example: ${commander.name()} /path/to/my-doc-definitions.js 19 | 20 | See the README for more information.`); 21 | }); 22 | 23 | commander.parse(process.argv); 24 | 25 | if (commander.args.length !== 1) { 26 | commander.outputHelp(); 27 | 28 | process.exit(errorStatus); 29 | } 30 | 31 | const docDefinitionsFilename = commander.args[0]; 32 | 33 | const rawDocDefinitionsString = docDefinitionsLoader.load(docDefinitionsFilename); 34 | 35 | const validationErrors = docDefinitionsValidator.validate(rawDocDefinitionsString, docDefinitionsFilename); 36 | 37 | validationErrors.forEach((validationError) => { 38 | console.error(validationError); 39 | }); 40 | 41 | const exitStatus = (validationErrors.length > 0) ? errorStatus : 0; 42 | process.exit(exitStatus); 43 | -------------------------------------------------------------------------------- /samples/fragment-notification-transport-processing-summary.js: -------------------------------------------------------------------------------- 1 | { 2 | authorizedRoles: { 3 | write: 'notification-transport-write' 4 | }, 5 | typeFilter: function(doc, oldDoc) { 6 | return createBusinessEntityRegex('notification\\.[A-Za-z0-9_-]+\\.processedTransport\\.[A-Za-z0-9_-]+$').test(doc._id); 7 | }, 8 | cannotDelete: true, 9 | propertyValidators: { 10 | nonce: { 11 | // A unique value that results in a unique document revision to prevent the notification's transport from being processed by 12 | // multiple instances of a notification service. If an instance encounters a conflict when saving this element, then it can be 13 | // assured that someone else is already processing it and instead move on to something else. 14 | type: 'string', 15 | required: true, 16 | mustNotBeEmpty: true, 17 | immutable: true 18 | }, 19 | processedBy: { 20 | // The name/ID of the service that processed this notification for the corresponding transport 21 | type: 'string', 22 | immutable: true 23 | }, 24 | processedAt: { 25 | // Used to indicate when the notification has been processed for transport (but not necessarily sent yet) by a 26 | // notification service 27 | type: 'datetime', 28 | required: true, 29 | immutable: true 30 | }, 31 | sentAt: { 32 | // The date/time at which the notification was actually sent. Typically distinct from the date/time at which it was processed. 33 | type: 'datetime', 34 | immutableWhenSet: true 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /etc/prepare-tests.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh -e 2 | 3 | cd "$(dirname "$0")"/.. || exit 1 4 | 5 | outputDir="build/validation-functions" 6 | 7 | echo "Linting modules and specs with JSHint...\n" 8 | node_modules/jshint/bin/jshint src test 9 | 10 | sampleDocDefinitionsPath="samples/sample-doc-definitions.js" 11 | 12 | # Validate the structure and sematics of the sample document definitions 13 | echo "Validating sample document definitions...\n" 14 | ./validate-document-definitions "$sampleDocDefinitionsPath" 15 | 16 | # Create a validation function from the sample document definitions file 17 | echo "Generating document validation functions...\n" 18 | ./make-validation-function "$sampleDocDefinitionsPath" "$outputDir"/test-sample-validation-function.js 19 | 20 | # Automatically validate and create a validation function from each document definitions file in the test resources directory 21 | definitionsDir="test/resources" 22 | for docDefinitionPath in "$definitionsDir"/*-doc-definitions.js; do 23 | # Skip entries that are not files 24 | if [ ! -f "$docDefinitionPath" ]; then continue; fi 25 | 26 | ./validate-document-definitions "$docDefinitionPath" 27 | 28 | validationFuncName=$(basename "$docDefinitionPath" "-doc-definitions.js") 29 | 30 | outputFile="$outputDir/test-$validationFuncName-validation-function.js" 31 | 32 | ./make-validation-function "$docDefinitionPath" "$outputFile" 33 | done 34 | 35 | # Set up JSHint configuration for the generated validation functions 36 | cp "etc/jshintrc-validation-function-template.json" "$outputDir/.jshintrc" 37 | 38 | echo "\nLinting generated document validation functions with JSHint...\n" 39 | node_modules/jshint/bin/jshint "$outputDir"/*.js 40 | 41 | echo "Done" 42 | -------------------------------------------------------------------------------- /test/helpers/sample-spec-helper-maker.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Initialize a collection of helper functions to be used when validating the sample document definitions. 3 | * 4 | * @param {Object} testFixture The test fixture to use 5 | */ 6 | exports.init = function(testFixture) { 7 | return { 8 | getExpectedAuthorization, 9 | 10 | verifyDocumentCreated(basePrivilegeName, businessId, doc) { 11 | testFixture.verifyDocumentCreated(doc, getExpectedAuthorization([ `${businessId}-ADD_${basePrivilegeName}` ])); 12 | }, 13 | 14 | verifyDocumentReplaced(basePrivilegeName, businessId, doc, oldDoc) { 15 | testFixture.verifyDocumentReplaced(doc, oldDoc, getExpectedAuthorization([ `${businessId}-CHANGE_${basePrivilegeName}` ])); 16 | }, 17 | 18 | verifyDocumentDeleted(basePrivilegeName, businessId, oldDoc) { 19 | testFixture.verifyDocumentDeleted(oldDoc, getExpectedAuthorization([ `${businessId}-REMOVE_${basePrivilegeName}` ])); 20 | }, 21 | 22 | verifyDocumentNotCreated(basePrivilegeName, businessId, doc, expectedDocType, expectedErrorMessages) { 23 | testFixture.verifyDocumentNotCreated( 24 | doc, 25 | expectedDocType, 26 | expectedErrorMessages, 27 | getExpectedAuthorization([ `${businessId}-ADD_${basePrivilegeName}` ])); 28 | }, 29 | 30 | verifyDocumentNotReplaced(basePrivilegeName, businessId, doc, oldDoc, expectedDocType, expectedErrorMessages) { 31 | testFixture.verifyDocumentNotReplaced( 32 | doc, 33 | oldDoc, 34 | expectedDocType, 35 | expectedErrorMessages, 36 | getExpectedAuthorization([ `${businessId}-CHANGE_${basePrivilegeName}` ])); 37 | } 38 | }; 39 | }; 40 | 41 | function getExpectedAuthorization(expectedRoles) { 42 | return { expectedRoles }; 43 | } 44 | -------------------------------------------------------------------------------- /templates/validation-function/document-constraints-validation-module.js: -------------------------------------------------------------------------------- 1 | function documentConstraintsValidationModule(utils) { 2 | return { 3 | validateDocument: function(newDoc, oldDoc, docDefinition) { 4 | var validationErrors = [ ]; 5 | 6 | validateDocImmutability(newDoc, oldDoc, docDefinition, validationErrors); 7 | validateDocumentIdRegexPattern(newDoc, oldDoc, docDefinition, validationErrors); 8 | 9 | return validationErrors; 10 | } 11 | }; 12 | 13 | function validateDocImmutability(newDoc, oldDoc, docDefinition, validationErrors) { 14 | if (!utils.isDocumentMissingOrDeleted(oldDoc)) { 15 | if (utils.resolveDocumentConstraint(docDefinition.immutable)) { 16 | validationErrors.push('documents of this type cannot be replaced or deleted'); 17 | } else if (newDoc._deleted) { 18 | if (utils.resolveDocumentConstraint(docDefinition.cannotDelete)) { 19 | validationErrors.push('documents of this type cannot be deleted'); 20 | } 21 | } else { 22 | if (utils.resolveDocumentConstraint(docDefinition.cannotReplace)) { 23 | validationErrors.push('documents of this type cannot be replaced'); 24 | } 25 | } 26 | } 27 | } 28 | 29 | function validateDocumentIdRegexPattern(newDoc, oldDoc, docDefinition, validationErrors) { 30 | if (!newDoc._deleted && !oldDoc) { 31 | // The constraint only applies when a document is created 32 | var documentIdRegexPattern = utils.resolveDocumentConstraint(docDefinition.documentIdRegexPattern); 33 | 34 | if (documentIdRegexPattern instanceof RegExp && !documentIdRegexPattern.test(newDoc._id)) { 35 | validationErrors.push('document ID must conform to expected pattern ' + documentIdRegexPattern); 36 | } 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /test/resources/custom-actions-doc-definitions.js: -------------------------------------------------------------------------------- 1 | function() { 2 | function customAction(actionType) { 3 | return function(doc, oldDoc, customActionMetadata, userContext, securityInfo) { 4 | // The most reliable means to get a result from a validation function is to throw it 5 | throw { 6 | doc: doc, 7 | oldDoc: oldDoc, 8 | customActionMetadata: customActionMetadata, 9 | userContext: userContext, 10 | securityInfo: securityInfo, 11 | actionType: actionType 12 | }; 13 | }; 14 | } 15 | 16 | var authorizedRoles = { write: 'write-role' }; 17 | var authorizedUsers = { write: 'write-user' }; 18 | 19 | return { 20 | onTypeIdentifiedDoc: { 21 | typeFilter: function(doc, oldDoc) { 22 | return doc._id === 'onTypeIdentifiedDoc'; 23 | }, 24 | authorizedRoles: authorizedRoles, 25 | authorizedUsers: authorizedUsers, 26 | propertyValidators: { }, 27 | customActions: { onTypeIdentificationSucceeded: customAction('onTypeIdentificationSucceeded') } 28 | }, 29 | onAuthorizationDoc: { 30 | typeFilter: function(doc, oldDoc) { 31 | return doc._id === 'onAuthorizationDoc'; 32 | }, 33 | authorizedRoles: authorizedRoles, 34 | authorizedUsers: authorizedUsers, 35 | propertyValidators: { }, 36 | customActions: { onAuthorizationSucceeded: customAction('onAuthorizationSucceeded') } 37 | }, 38 | onValidationDoc: { 39 | typeFilter: function(doc, oldDoc) { 40 | return doc._id === 'onValidationDoc'; 41 | }, 42 | authorizedRoles: authorizedRoles, 43 | authorizedUsers: authorizedUsers, 44 | propertyValidators: { }, 45 | customActions: { onValidationSucceeded: customAction('onValidationSucceeded') } 46 | } 47 | }; 48 | } 49 | -------------------------------------------------------------------------------- /samples/fragment-notification-transport.js: -------------------------------------------------------------------------------- 1 | { 2 | authorizedRoles: function(doc, oldDoc) { 3 | return toDefaultDbRoles(doc, oldDoc, 'NOTIFICATIONS_CONFIG'); 4 | }, 5 | typeFilter: function(doc, oldDoc) { 6 | return createBusinessEntityRegex('notificationTransport\\.[A-Za-z0-9_-]+$').test(doc._id); 7 | }, 8 | propertyValidators: { 9 | type: { 10 | // The type of notification transport (e.g. email, sms). Used by a notification service to determine how to deliver a 11 | // notification. 12 | type: 'string', 13 | required: true, 14 | mustNotBeEmpty: true 15 | }, 16 | recipient: { 17 | // The intended recipient for notifications that are configured to use this transport 18 | type: 'string', 19 | required: true, 20 | mustNotBeEmpty: true 21 | } 22 | }, 23 | customActions: { 24 | onAuthorizationSucceeded: function(doc, oldDoc, customActionMetadata, userContext, securityInfo) { 25 | var userRoles = (userContext && userContext.roles) ? userContext.roles : [ ]; 26 | if (doc._deleted) { 27 | // The document is being removed, so ensure the user has the document's "-delete" role in addition to one of the 28 | // roles defined in the document definition's "roles.remove" property 29 | if (userRoles.indexOf(doc._id + '-delete') < 0) { 30 | throw { forbidden: 'Operation forbidden' }; 31 | } 32 | } else if (oldDoc && !oldDoc._deleted) { 33 | // The document is being replaced, so ensure the user has the document's "-replace" role in addition to one of the 34 | // roles defined in the document definition's "roles.replace" property 35 | if (userRoles.indexOf(doc._id + '-replace') < 0) { 36 | throw { forbidden: 'Operation forbidden' }; 37 | } 38 | } 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /make-validation-function: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const commander = require('./lib/commander/index'); 4 | const validationFunctionLoader = require('./src/loading/validation-function-loader'); 5 | const validationFunctionWriter = require('./src/saving/validation-function-writer'); 6 | 7 | const errorStatus = 1; 8 | const { version } = require('./package'); 9 | 10 | // Parse the commandline arguments 11 | commander.arguments(' ') 12 | .version(version, '-v, --version') 13 | .description('A utility for generating document validation functions for Apache CouchDB.') 14 | .option('-j, --json-string', 'enclose the validation function contents in a JSON-compatible string'); 15 | 16 | commander.on('--help', () => { 17 | // Add some extra information after the main body of the usage/help text 18 | console.info(` 19 | For example: ${commander.name()} /path/to/my-doc-definitions.js /path/to/my-new-validation-function.js 20 | 21 | See the README for more information.`); 22 | }); 23 | 24 | commander.parse(process.argv); 25 | 26 | if (commander.args.length !== 2) { 27 | commander.outputHelp(); 28 | 29 | process.exit(errorStatus); 30 | } 31 | 32 | const docDefnFilename = commander.args[0]; 33 | const outputFilename = commander.args[1]; 34 | 35 | let validationFuncString; 36 | try { 37 | validationFuncString = validationFunctionLoader.load(docDefnFilename); 38 | } catch (ex) { 39 | process.exit(errorStatus); 40 | } 41 | 42 | try { 43 | const formatOptions = { jsonString: commander.jsonString }; 44 | validationFunctionWriter.save(outputFilename, validationFuncString, formatOptions); 45 | } catch (ex) { 46 | console.error(`ERROR: Unable to write the validation function to the output file: ${ex}`); 47 | 48 | process.exit(errorStatus); 49 | } 50 | 51 | console.info(`Validation function written to ${outputFilename}`); 52 | -------------------------------------------------------------------------------- /src/validation/validation-environment-maker.spec.js: -------------------------------------------------------------------------------- 1 | const { expect } = require('chai'); 2 | const mockRequire = require('mock-require'); 3 | const path = require('path'); 4 | const simpleMock = require('../../lib/simple-mock/index'); 5 | 6 | describe('Validation environment maker', () => { 7 | let validationEnvironmentMaker, stubbedEnvironmentMakerMock; 8 | 9 | beforeEach(() => { 10 | // Mock out the "require" calls in the module under test 11 | stubbedEnvironmentMakerMock = { create: simpleMock.stub() }; 12 | mockRequire('../environments/stubbed-environment-maker', stubbedEnvironmentMakerMock); 13 | 14 | validationEnvironmentMaker = mockRequire.reRequire('./validation-environment-maker'); 15 | }); 16 | 17 | afterEach(() => { 18 | // Restore "require" calls to their original behaviour after each test case 19 | mockRequire.stopAll(); 20 | }); 21 | 22 | it('creates a stubbed environment for schema validation', () => { 23 | const documentDefinitionsString = 'my-doc-definitions-1'; 24 | 25 | const expectedResult = { foo: 'baz' }; 26 | const mockEnvironment = simpleMock.stub(); 27 | mockEnvironment.returnWith(expectedResult); 28 | 29 | stubbedEnvironmentMakerMock.create.returnWith(mockEnvironment); 30 | 31 | const result = validationEnvironmentMaker.create(documentDefinitionsString); 32 | 33 | expect(result).to.eql(expectedResult); 34 | 35 | expect(stubbedEnvironmentMakerMock.create.callCount).to.equal(1); 36 | expect(stubbedEnvironmentMakerMock.create.calls[0].args).to.eql([ 37 | path.resolve(__dirname, '../../templates/environments/validation-environment-template.js'), 38 | '$DOC_DEFINITIONS_PLACEHOLDER$', 39 | documentDefinitionsString 40 | ]); 41 | 42 | expect(mockEnvironment.callCount).to.equal(1); 43 | expect(mockEnvironment.calls[0].args).to.eql([ simpleMock ]); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /test/resources/immutable-items-strict-doc-definitions.js: -------------------------------------------------------------------------------- 1 | function() { 2 | function isImmutable(doc, oldDoc, value, oldValue) { 3 | return doc.dynamicPropertiesAreImmutable; 4 | } 5 | 6 | return { 7 | immutableItemsDoc: { 8 | authorizedRoles: { write: 'write' }, 9 | typeFilter: function(doc) { 10 | return doc._id === 'immutableItemsDoc'; 11 | }, 12 | propertyValidators: { 13 | staticImmutableArrayProp: { 14 | type: 'array', 15 | immutableStrict: true 16 | }, 17 | staticImmutableObjectProp: { 18 | type: 'object', 19 | immutableStrict: true 20 | }, 21 | staticImmutableHashtableProp: { 22 | type: 'hashtable', 23 | immutableStrict: true 24 | }, 25 | staticImmutableDateProp: { 26 | type: 'date', 27 | immutableStrict: true 28 | }, 29 | staticImmutableDatetimeProp: { 30 | type: 'datetime', 31 | immutableStrict: true 32 | }, 33 | staticImmutableTimeProp: { 34 | type: 'time', 35 | immutableStrict: true 36 | }, 37 | staticImmutableTimezoneProp: { 38 | type: 'timezone', 39 | immutableStrict: true 40 | }, 41 | staticImmutableUuidProp: { 42 | type: 'uuid', 43 | immutableStrict: true 44 | }, 45 | dynamicPropertiesAreImmutable: { 46 | type: 'boolean' 47 | }, 48 | dynamicImmutableArrayProp: { 49 | type: 'array', 50 | immutableStrict: isImmutable 51 | }, 52 | dynamicImmutableObjectProp: { 53 | type: 'object', 54 | immutableStrict: isImmutable 55 | }, 56 | dynamicImmutableHashtableProp: { 57 | type: 'hashtable', 58 | immutableStrict: isImmutable 59 | } 60 | } 61 | } 62 | }; 63 | } 64 | -------------------------------------------------------------------------------- /src/environments/stubbed-environment-maker.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const vm = require('vm'); 3 | 4 | /** 5 | * Creates a stubbed CouchDB environment for document definitions or a validation function for use in tests, schema 6 | * validation, etc. 7 | * 8 | * @param {string} templateFile The absolute path to the template into which to insert the source contents 9 | * @param {string} macroName The name of the macro to replace with the given source contents 10 | * @param {string} sourceContents The raw string contents to be inserted into the template 11 | * @param {string} [sourceFile] The optional path to the file from which the source contents originated, to be used to 12 | * generate stack traces when errors occur 13 | * 14 | * @returns {*} The raw stubbed environment 15 | */ 16 | exports.create = function(templateFile, macroName, sourceContents, sourceFile) { 17 | const vmOptions = { 18 | filename: sourceFile, 19 | displayErrors: true 20 | }; 21 | 22 | const environmentTemplate = fs.readFileSync(templateFile, 'utf8').trim() 23 | .replace(/(?:\r\n)|(?:\r)|(?:\n)/g, () => ' '); // Compress the template to one line to ensure stack trace line numbers are correct 24 | 25 | // The test environment includes a placeholder string (a macro) that is to be replaced with the contents of the source 26 | const environmentString = environmentTemplate.replace(macroName, () => sourceContents); 27 | 28 | // The code that is compiled must be an expression or a sequence of one or more statements. Surrounding it with 29 | // parentheses makes it a valid expression. 30 | const environmentStatement = `(${environmentString});`; 31 | 32 | // Compile the environment within the current virtual machine context so it can share access to the "isArray", 33 | // "toJSON", etc. stubs as defined by the template 34 | return vm.runInThisContext(environmentStatement, vmOptions); 35 | }; 36 | -------------------------------------------------------------------------------- /samples/fragment-business.js: -------------------------------------------------------------------------------- 1 | { 2 | authorizedRoles: function(doc, oldDoc) { 3 | var businessId = getBusinessId(doc, oldDoc); 4 | 5 | // Because creating a business config document is not the same as creating a business, reuse the same permission for both creating 6 | // and updating 7 | return { 8 | add: toDbRole(businessId, 'CHANGE_BUSINESS'), 9 | replace: toDbRole(businessId, 'CHANGE_BUSINESS'), 10 | remove: toDbRole(businessId, 'REMOVE_BUSINESS') 11 | }; 12 | }, 13 | typeFilter: function(doc, oldDoc) { 14 | return /^biz\.[A-Za-z0-9_-]+$/.test(doc._id); 15 | }, 16 | allowAttachments: true, 17 | attachmentConstraints: { 18 | maximumAttachmentCount: 1, 19 | supportedExtensions: [ 'txt' ], 20 | supportedContentTypes: [ 'text/plain' ], 21 | requireAttachmentReferences: true 22 | }, 23 | propertyValidators: { 24 | businessLogoAttachment: { 25 | // The name of the CouchDB file attachment that is to be used as the business/invoice logo image 26 | type: 'attachmentReference', 27 | required: false, 28 | maximumSize: 2097152, 29 | supportedExtensions: [ 'png', 'gif', 'jpg', 'jpeg' ], 30 | supportedContentTypes: [ 'image/png', 'image/gif', 'image/jpeg' ] 31 | }, 32 | defaultInvoiceTemplate: { 33 | // Configuration for the default template to use in invoice PDFs 34 | type: 'object', 35 | required: false, 36 | propertyValidators: { 37 | templateId: { 38 | type: 'string', 39 | required: false, 40 | mustNotBeEmpty: true 41 | } 42 | } 43 | }, 44 | paymentProcessors: { 45 | // The list of payment processor IDs that are available for the business 46 | type: 'array', 47 | required: false, 48 | arrayElementsValidator: { 49 | type: 'string', 50 | required: true, 51 | mustNotBeEmpty: true 52 | } 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /test/type-id-validator.spec.js: -------------------------------------------------------------------------------- 1 | const testFixtureMaker = require('../src/testing/test-fixture-maker'); 2 | const errorFormatter = require('../src/testing/validation-error-formatter'); 3 | 4 | describe('Type identifier validator', () => { 5 | const testFixture = 6 | testFixtureMaker.initFromValidationFunction('build/validation-functions/test-type-id-validator-validation-function.js'); 7 | 8 | afterEach(() => { 9 | testFixture.resetTestEnvironment(); 10 | }); 11 | 12 | it('allows a valid string value', () => { 13 | const doc = { 14 | _id: 'typeIdDoc', 15 | typeIdProp: 'my-doc-type' 16 | }; 17 | 18 | testFixture.verifyDocumentCreated(doc); 19 | }); 20 | 21 | it('rejects a non-string value', () => { 22 | const doc = { 23 | _id: 'typeIdDoc', 24 | typeIdProp: 15 25 | }; 26 | 27 | testFixture.verifyDocumentNotCreated(doc, 'typeIdDoc', [ errorFormatter.typeConstraintViolation('typeIdProp', 'string') ]); 28 | }); 29 | 30 | it('rejects an empty string value', () => { 31 | const doc = { 32 | _id: 'typeIdDoc', 33 | typeIdProp: '' 34 | }; 35 | 36 | testFixture.verifyDocumentNotCreated(doc, 'typeIdDoc', [ errorFormatter.mustNotBeEmptyViolation('typeIdProp') ]); 37 | }); 38 | 39 | it('rejects a null value', () => { 40 | const doc = { 41 | _id: 'typeIdDoc', 42 | typeIdProp: null 43 | }; 44 | 45 | testFixture.verifyDocumentNotCreated(doc, 'typeIdDoc', [ errorFormatter.requiredValueViolation('typeIdProp') ]); 46 | }); 47 | 48 | it('rejects a value that has been modified', () => { 49 | const doc = { 50 | _id: 'typeIdDoc', 51 | typeIdProp: 'my-modified-doc-type' 52 | }; 53 | const oldDoc = { 54 | _id: 'typeIdDoc', 55 | typeIdProp: 'my-doc-type' 56 | }; 57 | 58 | testFixture.verifyDocumentNotReplaced(doc, oldDoc, 'typeIdDoc', [ errorFormatter.immutableItemViolation('typeIdProp') ]); 59 | }); 60 | }); 61 | -------------------------------------------------------------------------------- /src/testing/test-environment-maker.spec.js: -------------------------------------------------------------------------------- 1 | const { expect } = require('chai'); 2 | const mockRequire = require('mock-require'); 3 | const path = require('path'); 4 | const simpleMock = require('../../lib/simple-mock/index'); 5 | 6 | describe('Test environment maker', () => { 7 | let testEnvironmentMaker, stubbedEnvironmentMakerMock; 8 | 9 | beforeEach(() => { 10 | // Mock out the "require" calls in the module under test 11 | stubbedEnvironmentMakerMock = { create: simpleMock.stub() }; 12 | mockRequire('../environments/stubbed-environment-maker', stubbedEnvironmentMakerMock); 13 | 14 | testEnvironmentMaker = mockRequire.reRequire('./test-environment-maker'); 15 | }); 16 | 17 | afterEach(() => { 18 | // Restore "require" calls to their original behaviour after each test case 19 | mockRequire.stopAll(); 20 | }); 21 | 22 | it('creates a stubbed environment for tests', () => { 23 | const validationFunctionString = 'my-validation-func'; 24 | const validationFunctionFile = 'my-original-filename'; 25 | 26 | const expectedResult = { foo: 'baz' }; 27 | const mockEnvironment = simpleMock.stub(); 28 | mockEnvironment.returnWith(expectedResult); 29 | 30 | stubbedEnvironmentMakerMock.create.returnWith(mockEnvironment); 31 | 32 | const result = testEnvironmentMaker.create(validationFunctionString, validationFunctionFile); 33 | 34 | expect(result).to.eql(expectedResult); 35 | 36 | expect(stubbedEnvironmentMakerMock.create.callCount).to.equal(1); 37 | expect(stubbedEnvironmentMakerMock.create.calls[0].args).to.eql([ 38 | path.resolve(__dirname, '../../templates/environments/test-environment-template.js'), 39 | '$VALIDATION_FUNC_PLACEHOLDER$', 40 | validationFunctionString, 41 | path.resolve(process.cwd(), validationFunctionFile) 42 | ]); 43 | 44 | expect(mockEnvironment.callCount).to.equal(1); 45 | expect(mockEnvironment.calls[0].args).to.eql([ simpleMock ]); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /src/loading/validation-function-loader.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Generates a complete document validation function from the specified document definitions file. 3 | * 4 | * @param {string} docDefinitionsFile The path to the document definitions file 5 | * 6 | * @returns The full contents of the generated validation function as a string 7 | */ 8 | exports.load = loadFromFile; 9 | 10 | const fs = require('fs'); 11 | const path = require('path'); 12 | const indent = require('../../lib/indent.js/indent'); 13 | const docDefinitionsLoader = require('./document-definitions-loader'); 14 | const fileFragmentLoader = require('./file-fragment-loader'); 15 | 16 | function loadFromFile(docDefinitionsFile) { 17 | const validationFuncTemplateDir = path.resolve(__dirname, '../../templates/validation-function'); 18 | 19 | const validationFuncTemplatePath = path.resolve(validationFuncTemplateDir, 'template.js'); 20 | const rawValidationFuncTemplate = fs.readFileSync(validationFuncTemplatePath, 'utf8'); 21 | 22 | // Automatically replace each instance of the "importValidationFunctionFragment" macro with the contents of the file that is specified 23 | const fullValidationFuncTemplate = 24 | fileFragmentLoader.load(validationFuncTemplateDir, 'importValidationFunctionFragment', rawValidationFuncTemplate); 25 | 26 | const docDefinitions = docDefinitionsLoader.load(docDefinitionsFile); 27 | 28 | // Load the document definitions into the validation function template 29 | const rawValidationFuncString = 30 | fullValidationFuncTemplate.replace('$DOCUMENT_DEFINITIONS_PLACEHOLDER$', () => docDefinitions); 31 | 32 | return formatValidationFunction(rawValidationFuncString); 33 | } 34 | 35 | function formatValidationFunction(rawValidationFuncString) { 36 | // Normalize code block indentation, normalize line endings and then replace blank lines with empty lines 37 | return indent.js(rawValidationFuncString, { tabString: ' ' }) 38 | .replace(/(?:\r\n)|(?:\r)/g, () => '\n') 39 | .replace(/^\s+$/gm, () => ''); 40 | } 41 | -------------------------------------------------------------------------------- /src/saving/validation-function-writer.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Saves the validation function to the specified file. Recursively creates the parent directory as needed. 3 | * 4 | * @param {string} filePath The path at which to write the validation function file 5 | * @param {string} validationFunctionString The full contents of the validation function 6 | * @param {Object} [formatOptions] Controls how the validation function is formatted. Options: 7 | * - jsonString: Boolean indicating whether to return the result enclosed in a JSON-compatible string 8 | * 9 | * @throws if the output directory could not be created or the file could not be created/overwritten (e.g. access denied) 10 | */ 11 | exports.save = save; 12 | 13 | const fs = require('fs'); 14 | const path = require('path'); 15 | const mkdirp = require('../../lib/mkdirp/index'); 16 | 17 | function save(filePath, validationFunctionString, formatOptions = { }) { 18 | const outputDirectory = path.dirname(filePath); 19 | if (!fs.existsSync(outputDirectory)) { 20 | mkdirp.sync(outputDirectory); 21 | } 22 | 23 | const formattedValidationFunction = formatValidationFunction(validationFunctionString, formatOptions); 24 | 25 | fs.writeFileSync(filePath, formattedValidationFunction, 'utf8'); 26 | } 27 | 28 | function formatValidationFunction(validationFunctionString, formatOptions) { 29 | // Normalize line endings 30 | const normalizedValidationFuncString = validationFunctionString.replace(/(?:\r\n)|(?:\r)/g, () => '\n'); 31 | 32 | if (formatOptions.jsonString) { 33 | // Escape all escape sequences, backslash characters and line ending characters then wrap the result in quotes to 34 | // make it a valid JSON string 35 | const formattedValidationFuncString = normalizedValidationFuncString.replace(/\\/g, () => '\\\\') 36 | .replace(/"/g, () => '\\"') 37 | .replace(/\n/g, () => '\\n'); 38 | 39 | return `"${formattedValidationFuncString}"`; 40 | } else { 41 | return normalizedValidationFuncString; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /test/resources/attachment-reference-doc-definitions.js: -------------------------------------------------------------------------------- 1 | { 2 | attachmentReferencesDoc: { 3 | typeFilter: simpleTypeFilter, 4 | authorizedRoles: { write: 'write' }, 5 | allowAttachments: true, 6 | propertyValidators: { 7 | staticExtensionsValidationProp: { 8 | type: 'attachmentReference', 9 | supportedExtensions: [ 'html', 'htm' ] 10 | }, 11 | dynamicSupportedExtensions: { 12 | type: 'array' 13 | }, 14 | dynamicExtensionsValidationProp: { 15 | type: 'attachmentReference', 16 | supportedExtensions: function(doc, oldDoc, value, oldValue) { 17 | return doc.dynamicSupportedExtensions; 18 | } 19 | }, 20 | staticContentTypesValidationProp: { 21 | type: 'attachmentReference', 22 | supportedContentTypes: [ 'text/plain', 'text/html' ] 23 | }, 24 | dynamicSupportedContentTypes: { 25 | type: 'array' 26 | }, 27 | dynamicContentTypesValidationProp: { 28 | type: 'attachmentReference', 29 | supportedContentTypes: function(doc, oldDoc, value, oldValue) { 30 | return doc.dynamicSupportedContentTypes; 31 | } 32 | }, 33 | staticMaxSizeValidationProp: { 34 | type: 'attachmentReference', 35 | maximumSize: 200 36 | }, 37 | dynamicMaxSize: { 38 | type: 'integer' 39 | }, 40 | dynamicMaxSizeValidationProp: { 41 | type: 'attachmentReference', 42 | maximumSize: function(doc, oldDoc, value, oldValue) { 43 | return doc.dynamicMaxSize; 44 | } 45 | }, 46 | staticRegexPatternValidationProp: { 47 | type: 'attachmentReference', 48 | regexPattern: /^[a-z][a-z0-9]*\.[a-z]+$/ 49 | }, 50 | dynamicRegexPattern: { 51 | type: 'string' 52 | }, 53 | dynamicRegexPatternValidationProp: { 54 | type: 'attachmentReference', 55 | regexPattern: function(doc, oldDoc, value, oldValue) { 56 | return doc.dynamicRegexPattern ? new RegExp(doc.dynamicRegexPattern) : null; 57 | } 58 | } 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/validation/dynamic-constraint-schema-maker.spec.js: -------------------------------------------------------------------------------- 1 | const { expect } = require('chai'); 2 | const joi = require('../../lib/joi/joi.bundle'); 3 | const makeConstraintSchemaDynamic = require('./dynamic-constraint-schema-maker'); 4 | 5 | describe('Dynamic constraint schema maker:', () => { 6 | let testSchema; 7 | 8 | beforeEach(() => { 9 | testSchema = joi.object().keys({ property: joi.number().min(0) }); 10 | }); 11 | 12 | it('produces a schema that accepts a function', () => { 13 | const result = makeConstraintSchemaDynamic(testSchema, 3); 14 | 15 | const input = (a, b, c, d) => { // Notice that the function has too many parameters 16 | return d; 17 | }; 18 | 19 | result.validate( 20 | input, 21 | { abortEarly: false }, 22 | (error) => { 23 | expect(error).not.to.equal(null); 24 | expect(error.details.length).to.equal(1); 25 | expect(error.details[0].message).to.equal('"value" must have an arity lesser or equal to 3'); 26 | }); 27 | }); 28 | 29 | it('produces a schema that accepts the fallback type', () => { 30 | const result = makeConstraintSchemaDynamic(testSchema, 3); 31 | 32 | const input = { property: -1 }; // Notice that the property's value is less than the minimum amount (0) 33 | 34 | result.validate( 35 | input, 36 | { abortEarly: false }, 37 | (error) => { 38 | expect(error).not.to.equal(null); 39 | expect(error.details.length).to.equal(1); 40 | expect(error.details[0].message).to.equal('"property" must be larger than or equal to 0'); 41 | }); 42 | }); 43 | 44 | it('produces a schema that rejects values other than functions and the fallback type', () => { 45 | const result = makeConstraintSchemaDynamic(testSchema, 3); 46 | 47 | const input = 'my-input'; 48 | 49 | result.validate( 50 | input, 51 | { abortEarly: false }, 52 | (error) => { 53 | expect(error).not.to.equal(null); 54 | expect(error.details.length).to.equal(1); 55 | expect(error.details[0].message).to.equal('"value" must be an object'); 56 | }); 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /test/init.spec.js: -------------------------------------------------------------------------------- 1 | const { expect } = require('chai'); 2 | const testFixtureMaker = require('../src/testing/test-fixture-maker'); 3 | 4 | describe('Test fixture maker module initialization', () => { 5 | describe('when initialized from a generated validation function file', () => { 6 | it('loads the validation function successfully for a valid path', () => { 7 | const testFixture = 8 | testFixtureMaker.initFromValidationFunction('build/validation-functions/test-init-validation-function.js'); 9 | 10 | const doc = { 11 | _id: 'foobar', 12 | type: 'initDoc', 13 | testProp: 174.6 14 | }; 15 | 16 | testFixture.verifyDocumentCreated(doc); 17 | }); 18 | 19 | it('fails to load the validation function for a file that does not exist', () => { 20 | let validationFuncError = null; 21 | expect(() => { 22 | try { 23 | testFixtureMaker.initFromValidationFunction('build/validation-functions/test-nonexistant-validation-function.js'); 24 | } catch (ex) { 25 | validationFuncError = ex; 26 | 27 | throw ex; 28 | } 29 | }).to.throw(); 30 | 31 | expect(validationFuncError.code).to.equal('ENOENT'); 32 | }); 33 | }); 34 | 35 | describe('when initialized from a document definitions file', () => { 36 | it('loads the validation function successfully for a valid path', () => { 37 | const testFixture = testFixtureMaker.initFromDocumentDefinitions('test/resources/init-doc-definitions.js'); 38 | 39 | const doc = { 40 | _id: 'barfoo', 41 | type: 'initDoc', 42 | testProp: -97.99 43 | }; 44 | 45 | testFixture.verifyDocumentCreated(doc); 46 | }); 47 | 48 | it('fails to load the validation function for a file that does not exist', () => { 49 | let validationFuncError = null; 50 | expect(() => { 51 | try { 52 | testFixtureMaker.initFromDocumentDefinitions('test/resources/nonexistant-doc-definitions.js'); 53 | } catch (ex) { 54 | validationFuncError = ex; 55 | 56 | throw ex; 57 | } 58 | }).to.throw(); 59 | 60 | expect(validationFuncError.code).to.equal('ENOENT'); 61 | }); 62 | }); 63 | }); 64 | -------------------------------------------------------------------------------- /samples/fragment-payment-attempt.js: -------------------------------------------------------------------------------- 1 | { 2 | authorizedRoles: function(doc, oldDoc) { 3 | var businessId = getBusinessId(doc, oldDoc); 4 | 5 | // Only service users can create, replace or delete payment attempts to prevent regular users from tampering 6 | return { 7 | write: 'payment-attempt-write' 8 | }; 9 | }, 10 | typeFilter: function(doc, oldDoc) { 11 | return /^paymentAttempt\.[A-Za-z0-9_-]+$/.test(doc._id); 12 | }, 13 | immutable: true, 14 | propertyValidators: { 15 | businessId: { 16 | // The ID of the business with which the payment attempt is associated 17 | type: 'integer', 18 | required: true, 19 | minimumValue: 1 20 | }, 21 | invoiceRecordId: { 22 | // The ID of the invoice with which the payment attempt is associated 23 | type: 'integer', 24 | required: true, 25 | minimumValue: 1 26 | }, 27 | paymentRequisitionId: { 28 | // The ID of the payment requisition 29 | type: 'string', 30 | required: true, 31 | mustNotBeEmpty: true 32 | }, 33 | paymentAttemptSpreedlyToken: { 34 | // The unique token that was assigned to the payment attempt by Spreedly 35 | type: 'string', 36 | required: true, 37 | mustNotBeEmpty: true 38 | }, 39 | date: { 40 | // When the payment was attempted 41 | type: 'datetime', 42 | required: true 43 | }, 44 | internalPaymentRecordId: { 45 | // The ID of the payment record in Books' general ledger 46 | type: 'integer', 47 | minimumValue: 1 48 | }, 49 | gatewayTransactionId: { 50 | // The ID of the payment attempt as specified by the payment processor 51 | type: 'string', 52 | mustNotBeEmpty: true 53 | }, 54 | gatewayMessage: { 55 | // The message specified by the payment processor in response to the payment attempt 56 | type: 'string' 57 | }, 58 | totalAmountPaid: { 59 | // The raw amount that was paid as an integer (e.g. 19999) 60 | type: 'integer', 61 | minimumValue: 1 62 | }, 63 | totalAmountPaidFormatted: { 64 | // The formatted amount that was paid (e.g. $199.99) 65 | type: 'string', 66 | mustNotBeEmpty: true 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/environments/stubbed-environment-maker.spec.js: -------------------------------------------------------------------------------- 1 | const { expect } = require('chai'); 2 | const mockRequire = require('mock-require'); 3 | const simpleMock = require('../../lib/simple-mock/index'); 4 | 5 | describe('Stubbed environment maker', () => { 6 | let environmentMaker, fsMock, vmMock; 7 | 8 | beforeEach(() => { 9 | // Mock out the "require" calls in the module under test 10 | fsMock = { readFileSync: simpleMock.stub() }; 11 | mockRequire('fs', fsMock); 12 | 13 | vmMock = { runInThisContext: simpleMock.stub() }; 14 | mockRequire('vm', vmMock); 15 | 16 | environmentMaker = mockRequire.reRequire('./stubbed-environment-maker'); 17 | }); 18 | 19 | afterEach(() => { 20 | // Restore "require" calls to their original behaviour after each test case 21 | mockRequire.stopAll(); 22 | }); 23 | 24 | it('creates an environment from the input with a filename for stack traces', () => { 25 | verifyParse('my-validation-func-1', 'my-original-filename'); 26 | }); 27 | 28 | it('creates an environment from the input but without a filename', () => { 29 | verifyParse('my-validation-func-2'); 30 | }); 31 | 32 | function verifyParse(rawContents, originalFilename) { 33 | const templateFile = 'my-template-file-path'; 34 | const macroName = '$my-template-macro$'; 35 | 36 | const envTemplateFileContents = `template: ${macroName}`; 37 | fsMock.readFileSync.returnWith(envTemplateFileContents); 38 | 39 | const expectedEnvString = envTemplateFileContents.replace(macroName, () => rawContents); 40 | 41 | const expectedResult = { bar: 'foo' }; 42 | vmMock.runInThisContext.returnWith(expectedResult); 43 | 44 | const result = environmentMaker.create(templateFile, macroName, rawContents, originalFilename); 45 | 46 | expect(result).to.eql(expectedResult); 47 | 48 | expect(fsMock.readFileSync.callCount).to.equal(1); 49 | expect(fsMock.readFileSync.calls[0].args).to.eql([ templateFile, 'utf8' ]); 50 | 51 | expect(vmMock.runInThisContext.callCount).to.equal(1); 52 | expect(vmMock.runInThisContext.calls[0].args).to.eql([ 53 | `(${expectedEnvString});`, 54 | { 55 | filename: originalFilename, 56 | displayErrors: true 57 | } 58 | ]); 59 | } 60 | }); 61 | -------------------------------------------------------------------------------- /samples/fragment-notifications-config.js: -------------------------------------------------------------------------------- 1 | { 2 | authorizedRoles: function(doc, oldDoc) { 3 | return toDefaultDbRoles(doc, oldDoc, 'NOTIFICATIONS_CONFIG'); 4 | }, 5 | typeFilter: function(doc, oldDoc) { 6 | return createBusinessEntityRegex('notificationsConfig$').test(doc._id); 7 | }, 8 | propertyValidators: { 9 | notificationTypes: { 10 | // A map of notification types -> enabled notification transports 11 | type: 'hashtable', 12 | hashtableKeysValidator: { 13 | mustNotBeEmpty: true, 14 | regexPattern: /^[a-zA-Z]+$/ 15 | }, 16 | hashtableValuesValidator: { 17 | type: 'object', 18 | required: true, 19 | propertyValidators: { 20 | enabledTransports: { 21 | // The list of notification transports that are enabled for the notification type 22 | type: 'array', 23 | arrayElementsValidator: { 24 | type: 'conditional', 25 | required: true, 26 | validationCandidates: [ 27 | { 28 | condition: function(doc, oldDoc, currentItemEntry, validationItemStack) { 29 | return typeof currentItemEntry.itemValue === 'object'; 30 | }, 31 | validator: { 32 | type: 'object', 33 | propertyValidators: { 34 | transportId: { 35 | // The ID of the notification transport 36 | type: 'string', 37 | required: true, 38 | mustNotBeEmpty: true 39 | } 40 | } 41 | } 42 | }, 43 | { 44 | condition: function(doc, oldDoc, currentItemEntry, validationItemStack) { 45 | return typeof currentItemEntry.itemValue === 'string'; 46 | }, 47 | validator: { 48 | // The ID of the notification transport 49 | type: 'string', 50 | mustNotBeEmpty: true 51 | } 52 | } 53 | ] 54 | } 55 | } 56 | } 57 | } 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/validation/document-definitions-validator.js: -------------------------------------------------------------------------------- 1 | const validationEnvironmentMaker = require('./validation-environment-maker'); 2 | const documentDefinitionSchema = require('./document-definition-schema'); 3 | 4 | /** 5 | * Performs validation of the specified document definitions. 6 | * 7 | * @param {*} documentDefinitions The document definitions as a string (e.g. contents of a file), an object or a 8 | * function that returns an object 9 | * @param {string} [docDefinitionsFilename] The path to the document definitions file 10 | * 11 | * @returns {string[]} A list of validation error messages. Will be empty if no validation errors found. 12 | */ 13 | exports.validate = (documentDefinitions, docDefinitionsFilename) => { 14 | if (typeof documentDefinitions === 'string') { 15 | return validateDocumentDefinitionsString(documentDefinitions, docDefinitionsFilename); 16 | } else { 17 | return validateDocumentDefinitionsObjectOrFunction(documentDefinitions); 18 | } 19 | }; 20 | 21 | function validateDocumentDefinitionsString(rawDocDefinitionsString, docDefinitionsFilename) { 22 | const validationEnv = validationEnvironmentMaker.create(rawDocDefinitionsString, docDefinitionsFilename); 23 | 24 | return validateDocumentDefinitionsObjectOrFunction(validationEnv.documentDefinitions); 25 | } 26 | 27 | function validateDocumentDefinitionsObjectOrFunction(documentDefinitions) { 28 | if (typeof documentDefinitions === 'function') { 29 | return validateDocumentDefinitionsObject(documentDefinitions()); 30 | } else { 31 | return validateDocumentDefinitionsObject(documentDefinitions); 32 | } 33 | } 34 | 35 | function validateDocumentDefinitionsObject(documentDefinitions) { 36 | const validationErrors = [ ]; 37 | 38 | Object.keys(documentDefinitions).forEach((documentType) => { 39 | documentDefinitionSchema.validate( 40 | documentDefinitions[documentType], 41 | { abortEarly: false }, 42 | (error) => { 43 | if (error) { 44 | error.details.forEach((errorDetails) => { 45 | const path = [ documentType ].concat(errorDetails.path); 46 | validationErrors.push(`${path.join('.')}: ${errorDetails.message}`); 47 | }); 48 | } 49 | }); 50 | }); 51 | 52 | return validationErrors; 53 | } 54 | -------------------------------------------------------------------------------- /test/resources/array-doc-definitions.js: -------------------------------------------------------------------------------- 1 | function() { 2 | function isNonEmpty(doc, oldDoc, value, oldValue) { 3 | return doc.dynamicMustNotBeEmptyPropertiesEnforced; 4 | } 5 | 6 | function minimumDynamicLength(doc, oldDoc, value, oldValue) { 7 | return doc.dynamicLengthPropertyIsValid ? value.length : value.length + 1; 8 | } 9 | 10 | function maximumDynamicLength(doc, oldDoc, value, oldValue) { 11 | return doc.dynamicLengthPropertyIsValid ? value.length : value.length - 1; 12 | } 13 | 14 | return { 15 | arrayDoc: { 16 | authorizedRoles: { write: 'write' }, 17 | typeFilter: function(doc) { 18 | return doc._id === 'arrayDoc'; 19 | }, 20 | propertyValidators: { 21 | staticLengthValidationProp: { 22 | type: 'array', 23 | minimumLength: 2, 24 | maximumLength: 2 25 | }, 26 | dynamicLengthPropertyIsValid: { 27 | type: 'boolean' 28 | }, 29 | dynamicLengthValidationProp: { 30 | type: 'array', 31 | minimumLength: minimumDynamicLength, 32 | maximumLength: maximumDynamicLength 33 | }, 34 | staticNonEmptyValidationProp: { 35 | type: 'array', 36 | mustNotBeEmpty: true 37 | }, 38 | dynamicMustNotBeEmptyPropertiesEnforced: { 39 | type: 'boolean' 40 | }, 41 | dynamicNonEmptyValidationProp: { 42 | type: 'array', 43 | mustNotBeEmpty: isNonEmpty 44 | }, 45 | staticArrayElementsValidatorProp: { 46 | type: 'array', 47 | arrayElementsValidator: { 48 | type: 'integer', 49 | required: true, 50 | minimumValue: 0, 51 | maximumValue: 2 52 | } 53 | }, 54 | dynamicArrayElementsType: { 55 | type: 'string' 56 | }, 57 | dynamicArrayElementsRequired: { 58 | type: 'boolean' 59 | }, 60 | dynamicArrayElementsValidatorProp: { 61 | type: 'array', 62 | arrayElementsValidator: function(doc, oldDoc, value, oldValue) { 63 | return { 64 | type: doc.dynamicArrayElementsType, 65 | required: doc.dynamicArrayElementsRequired 66 | }; 67 | } 68 | } 69 | } 70 | } 71 | }; 72 | } 73 | -------------------------------------------------------------------------------- /test/resources/immutable-docs-doc-definitions.js: -------------------------------------------------------------------------------- 1 | function() { 2 | function docTypeFilter(doc, oldDoc, docType) { 3 | return doc._id === docType; 4 | } 5 | 6 | function isImmutable(doc, oldDoc) { 7 | return oldDoc ? oldDoc.applyImmutability : doc.applyImmutability; 8 | } 9 | 10 | var authorizedRoles = { write: 'write' }; 11 | 12 | return { 13 | staticImmutableDoc: { 14 | typeFilter: docTypeFilter, 15 | authorizedRoles: authorizedRoles, 16 | propertyValidators: { 17 | stringProp: { 18 | type: 'string' 19 | } 20 | }, 21 | immutable: true, 22 | allowAttachments: true 23 | }, 24 | dynamicImmutableDoc: { 25 | typeFilter: docTypeFilter, 26 | authorizedRoles: authorizedRoles, 27 | propertyValidators: { 28 | integerProp: { 29 | type: 'integer' 30 | }, 31 | applyImmutability: { 32 | type: 'boolean' 33 | } 34 | }, 35 | immutable: isImmutable 36 | }, 37 | staticCannotReplaceDoc: { 38 | typeFilter: docTypeFilter, 39 | authorizedRoles: authorizedRoles, 40 | propertyValidators: { 41 | stringProp: { 42 | type: 'string' 43 | } 44 | }, 45 | cannotReplace: true, 46 | allowAttachments: true 47 | }, 48 | dynamicCannotReplaceDoc: { 49 | typeFilter: docTypeFilter, 50 | authorizedRoles: authorizedRoles, 51 | propertyValidators: { 52 | integerProp: { 53 | type: 'integer' 54 | }, 55 | applyImmutability: { 56 | type: 'boolean' 57 | } 58 | }, 59 | cannotReplace: isImmutable 60 | }, 61 | staticCannotDeleteDoc: { 62 | typeFilter: docTypeFilter, 63 | authorizedRoles: authorizedRoles, 64 | propertyValidators: { 65 | stringProp: { 66 | type: 'string' 67 | } 68 | }, 69 | cannotDelete: true, 70 | allowAttachments: true 71 | }, 72 | dynamicCannotDeleteDoc: { 73 | typeFilter: docTypeFilter, 74 | authorizedRoles: authorizedRoles, 75 | propertyValidators: { 76 | integerProp: { 77 | type: 'integer' 78 | }, 79 | applyImmutability: { 80 | type: 'boolean' 81 | } 82 | }, 83 | cannotDelete: isImmutable 84 | } 85 | }; 86 | } 87 | -------------------------------------------------------------------------------- /src/loading/file-fragment-loader.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Replaces each instance of the specified macro in the given raw text with the contents of the referenced file fragment. 3 | * 4 | * @param {string} baseDir The base directory to use when attempting to read a file fragment as a relative path 5 | * @param {string} macroName The name of the macro to replace in the raw text 6 | * @param {string} rawText The raw content string in which to replace instances of the specified macro 7 | * 8 | * @returns A copy of the given text where each instance of the macro has been replaced with the contents of its corresponding file fragment 9 | */ 10 | exports.load = load; 11 | 12 | const fs = require('fs'); 13 | 14 | function load(baseDir, macroName, rawText) { 15 | function replacer(fullMatch, fragmentFilename) { 16 | const rawFileContents = readFileFragment(fragmentFilename, baseDir); 17 | 18 | // Recursively replace macros nested an arbitrary number of levels deep. Recursion terminates when it encounters a 19 | // template file that does not contain the specified macro (i.e. this replacer will not run when `rawText.replace` 20 | // is called without an instance of the macro in the file contents). 21 | return load(baseDir, macroName, rawFileContents); 22 | } 23 | 24 | return rawText.replace(new RegExp(`\\b${macroName}\\s*\\(\\s*"((?:\\\\"|[^"])+)"\\s*\\)`, 'g'), replacer) 25 | .replace(new RegExp(`\\b${macroName}\\s*\\(\\s*'((?:\\\\'|[^'])+)'\\s*\\)`, 'g'), replacer); 26 | } 27 | 28 | function readFileFragment(fragmentFilename, baseDir) { 29 | // The filename may have been defined with escape sequences (e.g. \\, \', \") in it, so unescape them 30 | const sanitizedFragmentFilename = fragmentFilename.replace(/\\(.)/g, (escapeSequence, escapedChar) => escapedChar); 31 | 32 | try { 33 | // Attempt to import the fragment file with a path that is relative to the base directory 34 | return fs.readFileSync(`${baseDir}/${sanitizedFragmentFilename}`, 'utf8').trim(); 35 | } catch (relativePathEx) { 36 | try { 37 | // It's possible the fragment file path was not relative so try again as an absolute path 38 | return fs.readFileSync(sanitizedFragmentFilename, 'utf8').trim(); 39 | } catch (absolutePathEx) { 40 | console.error(`ERROR: Unable to read fragment file "${sanitizedFragmentFilename}": ${absolutePathEx}`); 41 | 42 | throw absolutePathEx; 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /test/resources/general-doc-definitions.js: -------------------------------------------------------------------------------- 1 | function() { 2 | function dynamicType(doc, oldDoc, value, oldValue) { 3 | return doc.expectedDynamicType; 4 | } 5 | 6 | return { 7 | generalDoc: { 8 | authorizedRoles: { 9 | add: 'add', 10 | replace: [ 'replace', 'update' ], 11 | remove: [ 'remove', 'delete' ] 12 | }, 13 | typeFilter: function(doc) { 14 | return doc._id === 'generalDoc'; 15 | }, 16 | propertyValidators: { 17 | arrayProp: { 18 | type: 'array' 19 | }, 20 | attachmentReferenceProp: { 21 | type: 'attachmentReference' 22 | }, 23 | booleanProp: { 24 | type: 'boolean' 25 | }, 26 | dateProp: { 27 | type: 'date' 28 | }, 29 | datetimeProp: { 30 | type: 'datetime' 31 | }, 32 | floatProp: { 33 | type: 'float' 34 | }, 35 | hashtableProp: { 36 | type: 'hashtable' 37 | }, 38 | integerProp: { 39 | type: 'integer' 40 | }, 41 | objectProp: { 42 | type: 'object', 43 | propertyValidators: { 44 | foo: { 45 | type: 'string' 46 | } 47 | } 48 | }, 49 | stringProp: { 50 | type: 'string' 51 | }, 52 | expectedDynamicType: { 53 | type: 'string' 54 | }, 55 | expectedDynamicMinimumValue: { 56 | type: dynamicType 57 | }, 58 | expectedDynamicMinimumExclusiveValue: { 59 | type: dynamicType 60 | }, 61 | expectedDynamicMaximumValue: { 62 | type: dynamicType 63 | }, 64 | expectedDynamicMaximumExclusiveValue: { 65 | type: dynamicType 66 | }, 67 | dynamicTypeProp: { 68 | type: dynamicType, 69 | minimumValue: function(doc, oldDoc, value, oldValue) { 70 | return doc.expectedDynamicMinimumValue; 71 | }, 72 | minimumValueExclusive: function(doc, oldDoc, value, oldValue) { 73 | return doc.expectedDynamicMinimumExclusiveValue; 74 | }, 75 | maximumValue: function(doc, oldDoc, value, oldValue) { 76 | return doc.expectedDynamicMaximumValue; 77 | }, 78 | maximumValueExclusive: function(doc, oldDoc, value, oldValue) { 79 | return doc.expectedDynamicMaximumExclusiveValue; 80 | } 81 | } 82 | } 83 | } 84 | }; 85 | } 86 | -------------------------------------------------------------------------------- /test/simple-type-filter.spec.js: -------------------------------------------------------------------------------- 1 | const testFixtureMaker = require('../src/testing/test-fixture-maker'); 2 | 3 | describe('Simple type filter:', () => { 4 | const testFixture = 5 | testFixtureMaker.initFromValidationFunction('build/validation-functions/test-simple-type-filter-validation-function.js'); 6 | 7 | afterEach(() => { 8 | testFixture.resetTestEnvironment(); 9 | }); 10 | 11 | function testSimpleTypeFilter(docTypeId) { 12 | it('identifies a brand new document by its type property', () => { 13 | const doc = { 14 | _id: 'my-doc', 15 | type: docTypeId 16 | }; 17 | 18 | testFixture.verifyDocumentCreated(doc); 19 | }); 20 | 21 | it('identifies an updated document by its type property when it matches that of the old document', () => { 22 | const doc = { 23 | _id: 'my-doc', 24 | type: docTypeId 25 | }; 26 | const oldDoc = { 27 | _id: 'my-doc', 28 | type: docTypeId 29 | }; 30 | 31 | testFixture.verifyDocumentReplaced(doc, oldDoc); 32 | }); 33 | 34 | it('identifies a deleted document by the type property of the old document', () => { 35 | const oldDoc = { 36 | _id: 'my-doc', 37 | type: docTypeId 38 | }; 39 | 40 | testFixture.verifyDocumentDeleted(oldDoc); 41 | }); 42 | 43 | it('refuses to identify an updated document by its type property when it differs from that of the old document', () => { 44 | const doc = { 45 | _id: 'my-doc', 46 | type: docTypeId 47 | }; 48 | const oldDoc = { 49 | _id: 'my-doc', 50 | type: 'somethingElse' 51 | }; 52 | 53 | testFixture.verifyUnknownDocumentType(doc, oldDoc); 54 | }); 55 | 56 | it('cannot identify a document when the type property is not set', () => { 57 | const doc = { 58 | _id: 'my-doc' 59 | }; 60 | 61 | testFixture.verifyUnknownDocumentType(doc); 62 | }); 63 | 64 | it('cannot identify a document when the type property is set to an unknown type', () => { 65 | const doc = { 66 | _id: 'my-doc', 67 | type: 'somethingElse' 68 | }; 69 | 70 | testFixture.verifyUnknownDocumentType(doc); 71 | }); 72 | } 73 | 74 | describe('when a type property validator is explicitly defined', () => { 75 | testSimpleTypeFilter('myExplicitTypeValidatorDoc'); 76 | }); 77 | 78 | describe('when a type property validator is implied (i.e. not defined)', () => { 79 | testSimpleTypeFilter('myImplicitTypeValidatorDoc'); 80 | }); 81 | }); 82 | -------------------------------------------------------------------------------- /test/resources/property-validators-doc-definitions.js: -------------------------------------------------------------------------------- 1 | function() { 2 | function docTypeFilter(doc, oldDoc, docType) { 3 | return doc._id === docType; 4 | } 5 | 6 | var authorizedRoles = { write: 'write' }; 7 | 8 | return { 9 | staticAllowUnknownDoc: { 10 | typeFilter: docTypeFilter, 11 | authorizedRoles: authorizedRoles, 12 | allowUnknownProperties: true, 13 | propertyValidators: { 14 | preventUnknownProp: { 15 | type: 'object', 16 | allowUnknownProperties: false, 17 | propertyValidators: { 18 | myStringProp: { 19 | type: 'string' 20 | } 21 | } 22 | } 23 | } 24 | }, 25 | staticPreventUnknownDoc: { 26 | typeFilter: docTypeFilter, 27 | authorizedRoles: authorizedRoles, 28 | allowUnknownProperties: false, 29 | propertyValidators: { 30 | allowUnknownProp: { 31 | type: 'object', 32 | allowUnknownProperties: true, 33 | propertyValidators: { 34 | myStringProp: { 35 | type: 'string' 36 | } 37 | } 38 | } 39 | } 40 | }, 41 | dynamicPropertiesValidationDoc: { 42 | typeFilter: simpleTypeFilter, 43 | authorizedRoles: authorizedRoles, 44 | allowUnknownProperties: function(doc, oldDoc) { 45 | return doc.unknownPropertiesAllowed; 46 | }, 47 | propertyValidators: function(doc, oldDoc) { 48 | var props = { 49 | unknownPropertiesAllowed: { type: 'boolean' } 50 | }; 51 | 52 | if (doc._id === 'foobar') { 53 | props.extraProperty = { type: 'float' }; 54 | } else { 55 | props.extraProperty = { type: 'string' }; 56 | } 57 | 58 | return props; 59 | } 60 | }, 61 | dynamicObjectValidationDoc: { 62 | typeFilter: simpleTypeFilter, 63 | authorizedRoles: authorizedRoles, 64 | propertyValidators: { 65 | subObject: { 66 | type: 'object', 67 | allowUnknownProperties: function(doc, oldDoc, value, oldValue) { 68 | return doc.subObject.unknownPropertiesAllowed; 69 | }, 70 | propertyValidators: function(doc, oldDoc, value, oldValue) { 71 | var props = { 72 | unknownPropertiesAllowed: { type: 'boolean' } 73 | }; 74 | 75 | if (doc._id === 'foobar') { 76 | props.extraProperty = { type: 'float' }; 77 | } else { 78 | props.extraProperty = { type: 'string' }; 79 | } 80 | 81 | return props; 82 | } 83 | } 84 | } 85 | } 86 | }; 87 | } 88 | -------------------------------------------------------------------------------- /test/custom-validation.spec.js: -------------------------------------------------------------------------------- 1 | const { expect } = require('chai'); 2 | const testFixtureMaker = require('../src/testing/test-fixture-maker'); 3 | 4 | describe('Custom validation constraint:', () => { 5 | const testFixture = 6 | testFixtureMaker.initFromValidationFunction('build/validation-functions/test-custom-validation-validation-function.js'); 7 | 8 | afterEach(() => { 9 | testFixture.resetTestEnvironment(); 10 | }); 11 | 12 | it('allows a document if custom validation succeeds', () => { 13 | const doc = { 14 | _id: 'my-doc', 15 | type: 'customValidationDoc', 16 | baseProp: { 17 | failValidation: false, 18 | customValidationProp: 'foo' 19 | } 20 | }; 21 | 22 | testFixture.verifyDocumentCreated(doc); 23 | }); 24 | 25 | it('blocks a document if custom validation fails', () => { 26 | const oldDoc = { 27 | _id: 'my-doc', 28 | type: 'customValidationDoc', 29 | baseProp: { } 30 | }; 31 | 32 | const doc = { 33 | _id: 'my-doc', 34 | type: 'customValidationDoc', 35 | baseProp: { 36 | failValidation: true, 37 | customValidationProp: 'foo' 38 | } 39 | }; 40 | 41 | const expectedCurrentItemEntry = { 42 | itemValue: doc.baseProp.customValidationProp, 43 | itemName: 'customValidationProp' 44 | }; 45 | const expectedValidationItemStack = [ 46 | { // The document (root) 47 | itemValue: doc, 48 | oldItemValue: oldDoc, 49 | itemName: null 50 | }, 51 | { // The parent of the property with the customValidation constraint 52 | itemValue: doc.baseProp, 53 | oldItemValue: oldDoc.baseProp, 54 | itemName: 'baseProp' 55 | } 56 | ]; 57 | const testUserContext = { 58 | name: 'me', 59 | roles: [ 'write' ] 60 | }; 61 | const testSecurityInfo = { 62 | members: { names: [ 'me' ]} 63 | }; 64 | 65 | let validationFuncError = null; 66 | expect(() => { 67 | try { 68 | testFixture.testEnvironment.validationFunction(doc, oldDoc, testUserContext, testSecurityInfo); 69 | } catch (ex) { 70 | validationFuncError = ex; 71 | throw ex; 72 | } 73 | }).to.throw(); 74 | 75 | testFixture.verifyValidationErrors( 76 | 'customValidationDoc', 77 | [ 78 | `doc: ${JSON.stringify(doc)}`, 79 | `oldDoc: ${JSON.stringify(oldDoc)}`, 80 | `currentItemEntry: ${JSON.stringify(expectedCurrentItemEntry)}`, 81 | `validationItemStack: ${JSON.stringify(expectedValidationItemStack)}`, 82 | `userContext: ${JSON.stringify(testUserContext)}`, 83 | `securityInfo: ${JSON.stringify(testSecurityInfo)}` 84 | ], 85 | validationFuncError); 86 | }); 87 | }); 88 | -------------------------------------------------------------------------------- /samples/fragment-payment-processor-settlement.js: -------------------------------------------------------------------------------- 1 | function() { 2 | return { 3 | typeFilter: function(doc, oldDoc) { 4 | var typeRegex = 5 | createBusinessEntityRegex('paymentProcessor\\.[A-Za-z0-9_-]+\\.processedSettlement\\.[A-Za-z0-9_-]+'); 6 | 7 | return typeRegex.test(doc._id); 8 | }, 9 | authorizedRoles: function(doc, oldDoc) { 10 | // Only staff/service users can create, replace or delete payment processor settlements to prevent regular users from tampering 11 | return { 12 | write: 'payment-settlement-write' 13 | }; 14 | }, 15 | documentIdRegexPattern: function(doc) { 16 | // Note that this regex uses double quotes rather than single quotes as a workaround to https://github.com/Kashoo/synctos/issues/116 17 | return new RegExp("^biz\\." + doc.businessId + "\\.paymentProcessor\\." + doc.processorId + "\\.processedSettlement\\." + doc.settlementId + "$"); 18 | }, 19 | immutable: true, 20 | propertyValidators: { 21 | businessId: { 22 | // The ID of the business with which the settlement is associated 23 | type: 'integer', 24 | required: true, 25 | immutable: true, 26 | minimumValue: 1 27 | }, 28 | transferId: { 29 | // The ID of the Books transfer record that represents the settlement 30 | type: 'integer', 31 | required: true, 32 | minimumValue: 1, 33 | immutable: true 34 | }, 35 | settlementId: { 36 | // The ID of the settlement as provided by the payment processor 37 | type: 'string', 38 | required: true, 39 | immutable: true 40 | }, 41 | processorId: { 42 | // The Kashoo payment processor associated with the settlement 43 | type: 'string', 44 | required: true, 45 | immutable: true 46 | }, 47 | capturedAt: { 48 | // The date that the settlement was completed (captured) 49 | type: 'datetime', 50 | required: true, 51 | immutable: true 52 | }, 53 | processedAt: { 54 | // The date/time at which the settlement was processed/imported to Kashoo 55 | type: 'datetime', 56 | required: true, 57 | immutable: true 58 | }, 59 | amount: { 60 | // The raw amount that was paid as an integer (e.g. 19999) 61 | type: 'integer', 62 | minimumValue: 1, 63 | required: true 64 | }, 65 | currency: { 66 | // The currency of the settlement amount 67 | type: 'string', 68 | required: true, 69 | regexPattern: iso4217CurrencyCodeRegex 70 | }, 71 | processorMessage: { 72 | // The message specified by the payment processor as part of the settlement 73 | type: 'string' 74 | } 75 | } 76 | }; 77 | } 78 | -------------------------------------------------------------------------------- /src/saving/validation-function-writer.spec.js: -------------------------------------------------------------------------------- 1 | const { expect } = require('chai'); 2 | const simpleMock = require('../../lib/simple-mock/index'); 3 | const mockRequire = require('mock-require'); 4 | 5 | describe('Validation function loader', () => { 6 | let validationFunctionWriter, fsMock, mkdirpMock; 7 | 8 | beforeEach(() => { 9 | // Mock out the "require" calls in the module under test 10 | fsMock = { existsSync: simpleMock.stub(), writeFileSync: simpleMock.stub() }; 11 | mockRequire('fs', fsMock); 12 | 13 | mkdirpMock = { sync: simpleMock.stub() }; 14 | mockRequire('../../lib/mkdirp/index', mkdirpMock); 15 | 16 | validationFunctionWriter = mockRequire.reRequire('./validation-function-writer'); 17 | }); 18 | 19 | afterEach(() => { 20 | // Restore "require" calls to their original behaviour after each test case 21 | mockRequire.stopAll(); 22 | }); 23 | 24 | it('should create an output directory that does not exist and save the validation function to the correct file', () => { 25 | const outputDirectory = '/foo/bar/baz'; 26 | const filePath = `${outputDirectory}/qux.js`; 27 | const validationFuncString = '"my" validation \\function\\:\r\ndo\rsomething\nhere'; 28 | 29 | // The output dir does not exist 30 | fsMock.existsSync.returnWith(false); 31 | 32 | validationFunctionWriter.save(filePath, validationFuncString); 33 | 34 | expect(fsMock.existsSync.callCount).to.equal(1); 35 | expect(fsMock.existsSync.calls[0].args).to.deep.equal([ outputDirectory ]); 36 | 37 | expect(mkdirpMock.sync.callCount).to.equal(1); 38 | expect(mkdirpMock.sync.calls[0].args).to.deep.equal([ outputDirectory ]); 39 | 40 | expect(fsMock.writeFileSync.callCount).to.equal(1); 41 | expect(fsMock.writeFileSync.calls[0].args) 42 | .to.deep.equal([ filePath, '"my" validation \\function\\:\ndo\nsomething\nhere', 'utf8' ]); 43 | }); 44 | 45 | it('should save the validation function to the correct file enclosed in a JSON-compatible string', () => { 46 | const outputDirectory = '/foo/bar/baz'; 47 | const filePath = `${outputDirectory}/qux.js`; 48 | const validationFuncString = '"my" validation \\function\\:\r\ndo\rsomething\nhere'; 49 | 50 | // The output dir does exist 51 | fsMock.existsSync.returnWith(true); 52 | 53 | validationFunctionWriter.save(filePath, validationFuncString, { jsonString: true }); 54 | 55 | expect(fsMock.existsSync.callCount).to.equal(1); 56 | expect(fsMock.existsSync.calls[0].args).to.deep.equal([ outputDirectory ]); 57 | 58 | expect(mkdirpMock.sync.callCount).to.equal(0); 59 | 60 | expect(fsMock.writeFileSync.callCount).to.equal(1); 61 | expect(fsMock.writeFileSync.calls[0].args) 62 | .to.deep.equal([ filePath, '"\\"my\\" validation \\\\function\\\\:\\ndo\\nsomething\\nhere"', 'utf8' ]); 63 | }); 64 | }); 65 | -------------------------------------------------------------------------------- /src/loading/file-fragment-loader.spec.js: -------------------------------------------------------------------------------- 1 | const { expect } = require('chai'); 2 | const simpleMock = require('../../lib/simple-mock/index'); 3 | const mockRequire = require('mock-require'); 4 | 5 | describe('File fragment loader', () => { 6 | let fileFragmentLoader, fsMock; 7 | 8 | beforeEach(() => { 9 | // Mock out the "require" calls in the module under test 10 | fsMock = { readFileSync: simpleMock.stub() }; 11 | mockRequire('fs', fsMock); 12 | fileFragmentLoader = mockRequire.reRequire('./file-fragment-loader'); 13 | }); 14 | 15 | afterEach(() => { 16 | // Restore "require" calls to their original behaviour after each test case 17 | mockRequire.stopAll(); 18 | }); 19 | 20 | it('should replace instances of the macro with the correct file contents', () => { 21 | const baseDir = '/my/base/dir'; 22 | const macroName = 'myFileFragmentMacro'; 23 | const rawText = 'doSomething();\nnotmyFileFragmentMacro("foo.js");myFileFragmentMacro("bar.js");\tmyFileFragmentMacro(\'foo\\ baz.js\');'; 24 | 25 | const fileFragment1Contents = ' somethingElseGoesHere()\n'; 26 | const fileFragment2Contents = '\nyetAnotherThingHere(\'qux\') '; 27 | fsMock.readFileSync.withActions([ 28 | { returnValue: fileFragment1Contents }, // First call successfully reads bar.js with a relative path 29 | { throwError: new Error('') }, // Second call attempts and fails to read baz.js with a relative path 30 | { returnValue: fileFragment2Contents } // Third call retries baz.js with an absolute path and succeeds 31 | ]); 32 | 33 | const result = fileFragmentLoader.load(baseDir, macroName, rawText); 34 | 35 | expect(result).to.equal(`doSomething();\nnotmyFileFragmentMacro("foo.js");${fileFragment1Contents.trim()};\t${fileFragment2Contents.trim()};`); 36 | 37 | expect(fsMock.readFileSync.callCount).to.equal(3); 38 | expect(fsMock.readFileSync.calls[0].args).to.eql([ `${baseDir}/bar.js`, 'utf8' ]); 39 | expect(fsMock.readFileSync.calls[1].args).to.eql([ `${baseDir}/foo baz.js`, 'utf8' ]); 40 | expect(fsMock.readFileSync.calls[2].args).to.eql([ 'foo baz.js', 'utf8' ]); 41 | }); 42 | 43 | it('should throw an exception if the file fragment cannot be found', () => { 44 | const baseDir = '/my/base/dir'; 45 | const macroName = 'myFileFragmentMacro'; 46 | const rawText = 'doSomething();\nmyFileFragmentMacro("foo.js");'; 47 | 48 | const expectedException = new Error('my-expected-exception'); 49 | fsMock.readFileSync.throwWith(expectedException); 50 | 51 | expect(() => { 52 | fileFragmentLoader.load(baseDir, macroName, rawText); 53 | }).to.throw(expectedException.message); 54 | 55 | expect(fsMock.readFileSync.callCount).to.equal(2); 56 | expect(fsMock.readFileSync.calls[0].args).to.eql([ `${baseDir}/foo.js`, 'utf8' ]); 57 | expect(fsMock.readFileSync.calls[1].args).to.eql([ 'foo.js', 'utf8' ]); 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /test/resources/hashtable-doc-definitions.js: -------------------------------------------------------------------------------- 1 | function() { 2 | function isNonEmpty(doc, oldDoc, value, oldValue) { 3 | return doc.dynamicKeysMustNotBeEmpty; 4 | } 5 | 6 | function dynamicRegexPattern(doc, oldDoc, value, oldValue) { 7 | return new RegExp(doc.dynamicKeyRegex); 8 | } 9 | 10 | function dynamicSizeConstraint(doc, oldDoc, value, oldValue) { 11 | return doc.dynamicSize; 12 | } 13 | 14 | return { 15 | hashtableDoc: { 16 | authorizedRoles: { write: 'write' }, 17 | typeFilter: function(doc) { 18 | return doc._id === 'hashtableDoc'; 19 | }, 20 | propertyValidators: { 21 | staticSizeValidationProp: { 22 | type: 'hashtable', 23 | minimumSize: 2, 24 | maximumSize: 2 25 | }, 26 | dynamicSize: { 27 | type: 'integer' 28 | }, 29 | dynamicSizeValidationProp: { 30 | type: 'hashtable', 31 | minimumSize: dynamicSizeConstraint, 32 | maximumSize: dynamicSizeConstraint 33 | }, 34 | staticNonEmptyKeyValidationProp: { 35 | type: 'hashtable', 36 | hashtableKeysValidator: { 37 | mustNotBeEmpty: true 38 | } 39 | }, 40 | dynamicKeysMustNotBeEmpty: { 41 | type: 'boolean' 42 | }, 43 | dynamicNonEmptyKeyValidationProp: { 44 | type: 'hashtable', 45 | hashtableKeysValidator: { 46 | mustNotBeEmpty: isNonEmpty 47 | } 48 | }, 49 | staticKeyRegexPatternValidationProp: { 50 | type: 'hashtable', 51 | hashtableKeysValidator: { 52 | regexPattern: /^[a-zA-Z]+(`[a-zA-Z]+)?$/ 53 | } 54 | }, 55 | dynamicKeyRegex: { 56 | type: 'string' 57 | }, 58 | dynamicKeyRegexPatternValidationProp: { 59 | type: 'hashtable', 60 | hashtableKeysValidator: { 61 | regexPattern: dynamicRegexPattern 62 | } 63 | }, 64 | dynamicKeysValidatorProp: { 65 | type: 'hashtable', 66 | hashtableKeysValidator: function(doc, oldDoc, value, oldValue) { 67 | var itemCount = 0; 68 | for (var itemKey in value) { 69 | itemCount++; 70 | } 71 | 72 | return { 73 | mustNotBeEmpty: itemCount > 1 ? true : false 74 | }; 75 | } 76 | }, 77 | staticValuesValidatorProp: { 78 | type: 'hashtable', 79 | hashtableValuesValidator: { 80 | type: 'string', 81 | required: true, 82 | mustNotBeEmpty: true 83 | } 84 | }, 85 | dynamicValuesType: { 86 | type: 'string' 87 | }, 88 | dynamicValuesValidatorProp: { 89 | type: 'hashtable', 90 | hashtableValuesValidator: function(doc, oldDoc, value, oldValue) { 91 | return { type: doc.dynamicValuesType }; 92 | } 93 | } 94 | } 95 | } 96 | }; 97 | } 98 | -------------------------------------------------------------------------------- /lib/mkdirp/index.js: -------------------------------------------------------------------------------- 1 | var path = require('path'); 2 | var fs = require('fs'); 3 | var _0777 = parseInt('0777', 8); 4 | 5 | module.exports = mkdirP.mkdirp = mkdirP.mkdirP = mkdirP; 6 | 7 | function mkdirP (p, opts, f, made) { 8 | if (typeof opts === 'function') { 9 | f = opts; 10 | opts = {}; 11 | } 12 | else if (!opts || typeof opts !== 'object') { 13 | opts = { mode: opts }; 14 | } 15 | 16 | var mode = opts.mode; 17 | var xfs = opts.fs || fs; 18 | 19 | if (mode === undefined) { 20 | mode = _0777 & (~process.umask()); 21 | } 22 | if (!made) made = null; 23 | 24 | var cb = f || function () {}; 25 | p = path.resolve(p); 26 | 27 | xfs.mkdir(p, mode, function (er) { 28 | if (!er) { 29 | made = made || p; 30 | return cb(null, made); 31 | } 32 | switch (er.code) { 33 | case 'ENOENT': 34 | mkdirP(path.dirname(p), opts, function (er, made) { 35 | if (er) cb(er, made); 36 | else mkdirP(p, opts, cb, made); 37 | }); 38 | break; 39 | 40 | // In the case of any other error, just see if there's a dir 41 | // there already. If so, then hooray! If not, then something 42 | // is borked. 43 | default: 44 | xfs.stat(p, function (er2, stat) { 45 | // if the stat fails, then that's super weird. 46 | // let the original error be the failure reason. 47 | if (er2 || !stat.isDirectory()) cb(er, made) 48 | else cb(null, made); 49 | }); 50 | break; 51 | } 52 | }); 53 | } 54 | 55 | mkdirP.sync = function sync (p, opts, made) { 56 | if (!opts || typeof opts !== 'object') { 57 | opts = { mode: opts }; 58 | } 59 | 60 | var mode = opts.mode; 61 | var xfs = opts.fs || fs; 62 | 63 | if (mode === undefined) { 64 | mode = _0777 & (~process.umask()); 65 | } 66 | if (!made) made = null; 67 | 68 | p = path.resolve(p); 69 | 70 | try { 71 | xfs.mkdirSync(p, mode); 72 | made = made || p; 73 | } 74 | catch (err0) { 75 | switch (err0.code) { 76 | case 'ENOENT' : 77 | made = sync(path.dirname(p), opts, made); 78 | sync(p, opts, made); 79 | break; 80 | 81 | // In the case of any other error, just see if there's a dir 82 | // there already. If so, then hooray! If not, then something 83 | // is borked. 84 | default: 85 | var stat; 86 | try { 87 | stat = xfs.statSync(p); 88 | } 89 | catch (err1) { 90 | throw err0; 91 | } 92 | if (!stat.isDirectory()) throw err0; 93 | break; 94 | } 95 | } 96 | 97 | return made; 98 | }; 99 | -------------------------------------------------------------------------------- /src/loading/document-definitions-loader.spec.js: -------------------------------------------------------------------------------- 1 | const { expect } = require('chai'); 2 | const simpleMock = require('../../lib/simple-mock/index'); 3 | const mockRequire = require('mock-require'); 4 | 5 | describe('Document definitions loader', () => { 6 | let docDefinitionsLoader, fsMock, pathMock, vmMock, fileFragmentLoaderMock; 7 | 8 | const expectedMacroName = 'importDocumentDefinitionFragment'; 9 | 10 | beforeEach(() => { 11 | // Mock out the "require" calls in the module under test 12 | fsMock = { readFileSync: simpleMock.stub() }; 13 | mockRequire('fs', fsMock); 14 | 15 | pathMock = { dirname: simpleMock.stub() }; 16 | mockRequire('path', pathMock); 17 | 18 | vmMock = { runInNewContext: simpleMock.stub() }; 19 | mockRequire('vm', vmMock); 20 | 21 | fileFragmentLoaderMock = { load: simpleMock.stub() }; 22 | mockRequire('./file-fragment-loader.js', fileFragmentLoaderMock); 23 | 24 | docDefinitionsLoader = mockRequire.reRequire('./document-definitions-loader'); 25 | }); 26 | 27 | afterEach(() => { 28 | // Restore "require" calls to their original behaviour after each test case 29 | mockRequire.stopAll(); 30 | }); 31 | 32 | it('should load the contents of a document definitions file that exists', () => { 33 | const docDefinitionsFile = 'my/doc-definitions.js'; 34 | const expectedDir = '/an/arbitrary/directory'; 35 | const originalFileContents = '\tmy-original-doc-definitions\n'; 36 | const expectedFileContents = 'my-expected-doc-definitions'; 37 | 38 | fsMock.readFileSync.returnWith(originalFileContents); 39 | pathMock.dirname.returnWith(expectedDir); 40 | fileFragmentLoaderMock.load.returnWith(expectedFileContents); 41 | 42 | const result = docDefinitionsLoader.load(docDefinitionsFile); 43 | 44 | expect(result).to.equal(expectedFileContents); 45 | 46 | expect(fsMock.readFileSync.callCount).to.equal(1); 47 | expect(fsMock.readFileSync.calls[0].args).to.eql([ docDefinitionsFile, 'utf8' ]); 48 | 49 | expect(pathMock.dirname.callCount).to.equal(1); 50 | expect(pathMock.dirname.calls[0].args).to.eql([ docDefinitionsFile ]); 51 | 52 | expect(fileFragmentLoaderMock.load.callCount).to.equal(1); 53 | expect(fileFragmentLoaderMock.load.calls[0].args).to.eql([ expectedDir, expectedMacroName, originalFileContents.trim() ]); 54 | }); 55 | 56 | it('should throw an exception if the document definitions file does not exist', () => { 57 | const docDefinitionsFile = 'my/doc-definitions.js'; 58 | const expectedException = new Error('my-expected-exception'); 59 | 60 | fsMock.readFileSync.throwWith(expectedException); 61 | pathMock.dirname.returnWith(''); 62 | fileFragmentLoaderMock.load.returnWith(''); 63 | 64 | expect(() => { 65 | docDefinitionsLoader.load(docDefinitionsFile); 66 | }).to.throw(expectedException.message); 67 | 68 | expect(fsMock.readFileSync.callCount).to.equal(1); 69 | expect(fsMock.readFileSync.calls[0].args).to.eql([ docDefinitionsFile, 'utf8' ]); 70 | 71 | expect(pathMock.dirname.callCount).to.equal(0); 72 | 73 | expect(fileFragmentLoaderMock.load.callCount).to.equal(0); 74 | }); 75 | }); 76 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | This project adheres to [Semantic Versioning](http://semver.org/). All notable changes will be documented in this file. 3 | 4 | ## [Unreleased] 5 | Nothing yet. 6 | 7 | ## [1.2.1] - 2019-05-21 8 | ### Fixed 9 | - [#46](https://github.com/OldSneerJaw/couchster/issues/46): Broken error equality test in Node.js 12 10 | 11 | ### Security 12 | - [#43](https://github.com/OldSneerJaw/couchster/issues/43): Security vulnerability in lodash dev dependency 13 | 14 | ## [1.2.0] - 2018-09-06 15 | ### Added 16 | - [#33](https://github.com/OldSneerJaw/couchster/issues/33): Option to ignore item validation errors when value is unchanged 17 | - [#34](https://github.com/OldSneerJaw/couchster/issues/34): Validation type that accepts any type of value 18 | - [#38](https://github.com/OldSneerJaw/couchster/issues/38): Conditional validation type 19 | 20 | ## [1.1.0] - 2018-06-04 21 | ### Added 22 | - [#24](https://github.com/OldSneerJaw/couchster/issues/24): Attachment filename regular expression constraint 23 | - [#25](https://github.com/OldSneerJaw/couchster/issues/25): Attachment reference regular expression constraint 24 | 25 | ## [1.0.0] - 2018-05-10 26 | ### Added 27 | - [#8](https://github.com/OldSneerJaw/couchster/issues/8): Regular expression pattern constraint for document ID 28 | - [#10](https://github.com/OldSneerJaw/couchster/issues/10): Extended year format in date strings 29 | - [#11](https://github.com/OldSneerJaw/couchster/issues/11): Mechanism to reset test environment between test cases 30 | - [#13](https://github.com/OldSneerJaw/couchster/issues/13): Dedicated module for writing validation functions 31 | - [#15](https://github.com/OldSneerJaw/couchster/issues/15): Throw an Error object when there is an authorization or validation failure 32 | - [#17](https://github.com/OldSneerJaw/couchster/issues/17): Case insensitive equality constraint for strings 33 | 34 | ## [0.2.0] - 2018-03-08 35 | ### Added 36 | - [#2](https://github.com/OldSneerJaw/couchster/issues/2): Option to allow any database member to write revisions for a specific document type 37 | - [#3](https://github.com/OldSneerJaw/couchster/issues/3): Option to output a generated validation function as a single-line JSON string 38 | - [#4](https://github.com/OldSneerJaw/couchster/issues/4): Allow a document with an unknown type to be deleted by an admin 39 | - [#6](https://github.com/OldSneerJaw/couchster/issues/6): Provide database name to authorization constraint functions 40 | 41 | ### Changed 42 | - [#5](https://github.com/OldSneerJaw/couchster/issues/5): Isolate test fixtures 43 | 44 | ## [0.1.0] - 2018-02-28 45 | Adapted from [synctos](https://github.com/Kashoo/synctos) for use with CouchDB 46 | 47 | [Unreleased]: https://github.com/OldSneerJaw/couchster/compare/v1.2.1...HEAD 48 | [1.2.1]: https://github.com/OldSneerJaw/couchster/compare/v1.2.0...v1.2.1 49 | [1.2.0]: https://github.com/OldSneerJaw/couchster/compare/v1.1.0...v1.2.0 50 | [1.1.0]: https://github.com/OldSneerJaw/couchster/compare/v1.0.0...v1.1.0 51 | [1.0.0]: https://github.com/OldSneerJaw/couchster/compare/v0.2.0...v1.0.0 52 | [0.2.0]: https://github.com/OldSneerJaw/couchster/compare/v0.1.0...v0.2.0 53 | [0.1.0]: https://github.com/OldSneerJaw/couchster/compare/73ba6a5...v0.1.0 54 | -------------------------------------------------------------------------------- /test/resources/immutable-nested-properties-doc-definitions.js: -------------------------------------------------------------------------------- 1 | { 2 | objectNestedInArrayDoc: { 3 | typeFilter: simpleTypeFilter, 4 | authorizedRoles: { write: 'write' }, 5 | propertyValidators: { 6 | elementList: { 7 | type: 'array', 8 | arrayElementsValidator: { 9 | type: 'object', 10 | propertyValidators: { 11 | id: { 12 | type: 'string', 13 | immutable: true 14 | }, 15 | content: { 16 | type: 'string' 17 | } 18 | } 19 | } 20 | } 21 | } 22 | }, 23 | objectNestedInHashtableDoc: { 24 | typeFilter: simpleTypeFilter, 25 | authorizedRoles: { write: 'write' }, 26 | propertyValidators: { 27 | hash: { 28 | type: 'hashtable', 29 | hashtableValuesValidator: { 30 | type: 'object', 31 | propertyValidators: { 32 | id: { 33 | type: 'string', 34 | immutable: true 35 | }, 36 | content: { 37 | type: 'string' 38 | } 39 | } 40 | } 41 | } 42 | } 43 | }, 44 | objectNestedInObjectDoc: { 45 | typeFilter: simpleTypeFilter, 46 | authorizedRoles: { write: 'write' }, 47 | propertyValidators: { 48 | object: { 49 | type: 'object', 50 | propertyValidators: { 51 | value: { 52 | type: 'object', 53 | propertyValidators: { 54 | id: { 55 | type: 'string', 56 | immutable: true 57 | }, 58 | content: { 59 | type: 'string' 60 | } 61 | } 62 | } 63 | } 64 | } 65 | } 66 | }, 67 | hashtableNestedInArrayDoc: { 68 | typeFilter: simpleTypeFilter, 69 | authorizedRoles: { write: 'write' }, 70 | propertyValidators: { 71 | elementList: { 72 | type: 'array', 73 | arrayElementsValidator: { 74 | type: 'hashtable', 75 | hashtableValuesValidator: { 76 | type: 'integer', 77 | immutable: true 78 | } 79 | } 80 | } 81 | } 82 | }, 83 | hashtableNestedInObjectDoc: { 84 | typeFilter: simpleTypeFilter, 85 | authorizedRoles: { write: 'write' }, 86 | propertyValidators: { 87 | object: { 88 | type: 'object', 89 | propertyValidators: { 90 | hash: { 91 | type: 'hashtable', 92 | hashtableValuesValidator: { 93 | type: 'integer', 94 | immutable: true 95 | } 96 | } 97 | } 98 | } 99 | } 100 | }, 101 | hashtableNestedInHashtableDoc: { 102 | typeFilter: simpleTypeFilter, 103 | authorizedRoles: { write: 'write' }, 104 | propertyValidators: { 105 | hash: { 106 | type: 'hashtable', 107 | hashtableValuesValidator: { 108 | type: 'hashtable', 109 | hashtableValuesValidator: { 110 | type: 'integer', 111 | immutable: true 112 | } 113 | } 114 | } 115 | } 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /test/enum.spec.js: -------------------------------------------------------------------------------- 1 | const testFixtureMaker = require('../src/testing/test-fixture-maker'); 2 | const errorFormatter = require('../src/testing/validation-error-formatter'); 3 | 4 | describe('Enum validation type', () => { 5 | const testFixture = 6 | testFixtureMaker.initFromValidationFunction('build/validation-functions/test-enum-validation-function.js'); 7 | 8 | afterEach(() => { 9 | testFixture.resetTestEnvironment(); 10 | }); 11 | 12 | describe('static validation', () => { 13 | it('accepts an allowed string', () => { 14 | const doc = { 15 | _id: 'enumDoc', 16 | staticEnumProp: 'value1' 17 | }; 18 | 19 | testFixture.verifyDocumentCreated(doc); 20 | }); 21 | 22 | it('accepts an allowed integer', () => { 23 | const doc = { 24 | _id: 'enumDoc', 25 | staticEnumProp: 2 26 | }; 27 | 28 | testFixture.verifyDocumentCreated(doc); 29 | }); 30 | 31 | it('rejects a string value that is not in the list of predefined values', () => { 32 | const doc = { 33 | _id: 'enumDoc', 34 | staticEnumProp: 'value2' 35 | }; 36 | 37 | testFixture.verifyDocumentNotCreated( 38 | doc, 39 | 'enumDoc', 40 | errorFormatter.enumPredefinedValueViolation('staticEnumProp', [ 'value1', 2 ])); 41 | }); 42 | 43 | it('rejects an integer value that is not in the list of predefined values', () => { 44 | const doc = { 45 | _id: 'enumDoc', 46 | staticEnumProp: 1 47 | }; 48 | 49 | testFixture.verifyDocumentNotCreated( 50 | doc, 51 | 'enumDoc', 52 | errorFormatter.enumPredefinedValueViolation('staticEnumProp', [ 'value1', 2 ])); 53 | }); 54 | 55 | it('rejects a value when the property does not declare a list of predefined values', () => { 56 | const doc = { 57 | _id: 'enumDoc', 58 | invalidEnumProp: 2 59 | }; 60 | 61 | testFixture.verifyDocumentNotCreated( 62 | doc, 63 | 'enumDoc', 64 | 'item "invalidEnumProp" belongs to an enum that has no predefined values'); 65 | }); 66 | }); 67 | 68 | describe('dynamic validation', () => { 69 | it('accepts an allowed string', () => { 70 | const doc = { 71 | _id: 'enumDoc', 72 | dynamicEnumProp: 'value1', 73 | dynamicPredefinedValues: [ 'value1', 'value2' ] 74 | }; 75 | 76 | testFixture.verifyDocumentCreated(doc); 77 | }); 78 | 79 | it('accepts an allowed integer', () => { 80 | const doc = { 81 | _id: 'enumDoc', 82 | dynamicEnumProp: 2, 83 | dynamicPredefinedValues: [ 1, 2 ] 84 | }; 85 | 86 | testFixture.verifyDocumentCreated(doc); 87 | }); 88 | 89 | it('rejects a value that is not in the list of predefined values', () => { 90 | const doc = { 91 | _id: 'enumDoc', 92 | dynamicEnumProp: 'value3', 93 | dynamicPredefinedValues: [ 'value1', 2 ] 94 | }; 95 | 96 | testFixture.verifyDocumentNotCreated( 97 | doc, 98 | 'enumDoc', 99 | errorFormatter.enumPredefinedValueViolation('dynamicEnumProp', [ 'value1', 2 ])); 100 | }); 101 | }); 102 | }); 103 | -------------------------------------------------------------------------------- /test/resources/attachment-constraints-doc-definitions.js: -------------------------------------------------------------------------------- 1 | { 2 | staticRegularAttachmentsDoc: { 3 | typeFilter: simpleTypeFilter, 4 | authorizedRoles: { write: 'write' }, 5 | allowAttachments: true, 6 | attachmentConstraints: { 7 | maximumAttachmentCount: 3, 8 | supportedExtensions: [ 'html', 'jpg', 'pdf', 'txt', 'xml' ], 9 | supportedContentTypes: [ 'text/html', 'image/jpeg', 'application/pdf', 'text/plain', 'application/xml' ], 10 | filenameRegexPattern: /^(foo|ba[rz]|qux)\.[a-z]+$/ 11 | }, 12 | propertyValidators: { 13 | attachmentRefProp: { 14 | type: 'attachmentReference', 15 | maximumSize: 40, 16 | supportedExtensions: [ 'foo', 'html', 'jpg', 'pdf', 'txt', 'xml' ], 17 | supportedContentTypes: [ 'text/bar', 'text/html', 'image/jpeg', 'application/pdf', 'text/plain', 'application/xml' ], 18 | regexPattern: /^[a-z]+\.[a-z]+$/ 19 | } 20 | } 21 | }, 22 | staticAttachmentRefsOnlyDoc: { 23 | typeFilter: simpleTypeFilter, 24 | authorizedRoles: { write: 'write' }, 25 | allowAttachments: true, 26 | attachmentConstraints: { 27 | requireAttachmentReferences: true 28 | }, 29 | propertyValidators: { 30 | attachmentRefProp: { 31 | type: 'attachmentReference' 32 | } 33 | } 34 | }, 35 | staticAttachmentFilenameRegexPatternDoc: { 36 | typeFilter: simpleTypeFilter, 37 | authorizedRoles: { write: 'write' }, 38 | allowAttachments: true, 39 | attachmentConstraints: { 40 | filenameRegexPattern: /^(foo|bar)\.(xls|xlsx)$/ 41 | }, 42 | propertyValidators: { } 43 | }, 44 | dynamicAttachmentsDoc: { 45 | typeFilter: simpleTypeFilter, 46 | authorizedRoles: { write: 'write' }, 47 | allowAttachments: function(doc, oldDoc) { 48 | return doc.attachmentsEnabled; 49 | }, 50 | attachmentConstraints: function(doc, oldDoc) { 51 | return { 52 | maximumAttachmentCount: function(doc, oldDoc) { 53 | return doc.maximumAttachmentCount; 54 | }, 55 | supportedExtensions: function(doc, oldDoc) { 56 | return doc.supportedExtensions; 57 | }, 58 | supportedContentTypes: function(doc, oldDoc) { 59 | return doc.supportedContentTypes; 60 | }, 61 | requireAttachmentReferences: function(doc, oldDoc) { 62 | return doc.requireAttachmentReferences; 63 | }, 64 | filenameRegexPattern: function(doc, oldDoc) { 65 | return doc.filenameRegexPattern ? new RegExp(doc.filenameRegexPattern) : null; 66 | } 67 | }; 68 | }, 69 | propertyValidators: { 70 | attachmentsEnabled: { 71 | type: 'boolean' 72 | }, 73 | maximumAttachmentCount: { 74 | type: 'integer' 75 | }, 76 | supportedExtensions: { 77 | type: 'array' 78 | }, 79 | supportedContentTypes: { 80 | type: 'array' 81 | }, 82 | requireAttachmentReferences: { 83 | type: 'boolean' 84 | }, 85 | attachmentReferences: { 86 | type: 'array', 87 | arrayElementsValidator: { 88 | type: 'attachmentReference' 89 | } 90 | }, 91 | filenameRegexPattern: { 92 | type: 'string' 93 | } 94 | } 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /test/dynamic-constraints.spec.js: -------------------------------------------------------------------------------- 1 | const testFixtureMaker = require('../src/testing/test-fixture-maker'); 2 | const errorFormatter = require('../src/testing/validation-error-formatter'); 3 | 4 | describe('Dynamic constraints', () => { 5 | const testFixture = 6 | testFixtureMaker.initFromValidationFunction('build/validation-functions/test-dynamic-constraints-validation-function.js'); 7 | 8 | afterEach(() => { 9 | testFixture.resetTestEnvironment(); 10 | }); 11 | 12 | it('allows a new doc to be created when the property constraints are satisfied', () => { 13 | const doc = { 14 | _id: 'my-doc', 15 | type: 'myDoc', 16 | dynamicReferenceId: 7, 17 | validationByDocProperty: 'foo-7-bar', 18 | validationByValueProperty: 119 19 | }; 20 | 21 | testFixture.verifyDocumentCreated(doc); 22 | }); 23 | 24 | it('allows an existing doc to be replaced when the property constraints are satisfied', () => { 25 | const doc = { 26 | _id: 'my-doc', 27 | type: 'myDoc', 28 | dynamicReferenceId: 5, 29 | validationByDocProperty: 'foo-0-bar', // Note that the new value must be constructed from the old doc's dynamicReferenceId 30 | validationByValueProperty: -34 // Note that the new value must equal the old value + 1 31 | }; 32 | const oldDoc = { 33 | _id: 'my-doc', 34 | type: 'myDoc', 35 | dynamicReferenceId: 0, 36 | validationByDocProperty: 'foo-0-bar', 37 | validationByValueProperty: -35 38 | }; 39 | 40 | testFixture.verifyDocumentReplaced(doc, oldDoc); 41 | }); 42 | 43 | it('blocks a doc from being created when the property constraints are violated', () => { 44 | const doc = { 45 | _id: 'my-doc', 46 | type: 'myDoc', 47 | dynamicReferenceId: 83, 48 | validationByDocProperty: 'foo-38-bar', 49 | validationByValueProperty: -1 50 | }; 51 | 52 | testFixture.verifyDocumentNotCreated( 53 | doc, 54 | doc.type, 55 | [ 56 | // If the current value of validationByValueProperty is less than zero (as it is in this case), the constraint will be set to zero 57 | errorFormatter.minimumValueViolation('validationByValueProperty', 0), 58 | errorFormatter.regexPatternItemViolation('validationByDocProperty', /^foo-83-bar$/) 59 | ]); 60 | }); 61 | 62 | it('blocks a doc from being replaced when the property constraints are violated', () => { 63 | const doc = { 64 | _id: 'my-doc', 65 | type: 'myDoc', 66 | dynamicReferenceId: 2, 67 | validationByDocProperty: 'foo-2-bar', // Note that the new value must be constructed from the old doc's dynamicReferenceId 68 | validationByValueProperty: 20 // Note that the new value must equal the old value + 1 69 | }; 70 | const oldDoc = { 71 | _id: 'my-doc', 72 | type: 'myDoc', 73 | dynamicReferenceId: 1, 74 | validationByDocProperty: 'foo-1-bar', 75 | validationByValueProperty: 18 76 | }; 77 | 78 | testFixture.verifyDocumentNotReplaced( 79 | doc, 80 | oldDoc, 81 | doc.type, 82 | [ 83 | errorFormatter.maximumValueViolation('validationByValueProperty', 19), 84 | errorFormatter.regexPatternItemViolation('validationByDocProperty', /^foo-1-bar$/) 85 | ]); 86 | }); 87 | }); 88 | -------------------------------------------------------------------------------- /src/loading/validation-function-loader.spec.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const { expect } = require('chai'); 3 | const simpleMock = require('../../lib/simple-mock/index'); 4 | const mockRequire = require('mock-require'); 5 | 6 | describe('Validation function loader', () => { 7 | let validationFunctionLoader, fsMock, indentMock, fileFragmentLoaderMock, docDefinitionsLoaderMock; 8 | 9 | const expectedMacroName = 'importValidationFunctionFragment'; 10 | const validationFuncTemplateDir = path.resolve(__dirname, '../../templates/validation-function'); 11 | const validationFuncTemplateFile = path.resolve(validationFuncTemplateDir, 'template.js'); 12 | 13 | beforeEach(() => { 14 | // Mock out the "require" calls in the module under test 15 | fsMock = { readFileSync: simpleMock.stub() }; 16 | mockRequire('fs', fsMock); 17 | 18 | indentMock = { js: simpleMock.stub() }; 19 | mockRequire('../../lib/indent.js/indent.js', indentMock); 20 | 21 | fileFragmentLoaderMock = { load: simpleMock.stub() }; 22 | mockRequire('./file-fragment-loader.js', fileFragmentLoaderMock); 23 | 24 | docDefinitionsLoaderMock = { load: simpleMock.stub() }; 25 | mockRequire('./document-definitions-loader.js', docDefinitionsLoaderMock); 26 | 27 | validationFunctionLoader = mockRequire.reRequire('./validation-function-loader'); 28 | }); 29 | 30 | afterEach(() => { 31 | // Restore "require" calls to their original behaviour after each test case 32 | mockRequire.stopAll(); 33 | }); 34 | 35 | it('should load a validation function from valid document definitions', () => { 36 | const docDefinitionsFile = 'my/doc-definitions.js'; 37 | const docDefinitionsContent = 'my-doc-definitions'; 38 | const originalValidationFuncTemplate = 'my-original-validation-func-template'; 39 | const updatedValidationFuncTemplate = 40 | 'function my-validation-func-template() { $DOCUMENT_DEFINITIONS_PLACEHOLDER$; }'; 41 | const indentedValidationFunction = 'my\n \r\nfinal\rvalidation `func`'; 42 | const expectedValidationFunction = 'my\n\nfinal\nvalidation `func`'; 43 | 44 | fsMock.readFileSync.returnWith(originalValidationFuncTemplate); 45 | fileFragmentLoaderMock.load.returnWith(updatedValidationFuncTemplate); 46 | docDefinitionsLoaderMock.load.returnWith(docDefinitionsContent); 47 | indentMock.js.returnWith(indentedValidationFunction); 48 | 49 | const result = validationFunctionLoader.load(docDefinitionsFile); 50 | 51 | expect(result).to.equal(expectedValidationFunction); 52 | 53 | expect(fsMock.readFileSync.callCount).to.equal(1); 54 | expect(fsMock.readFileSync.calls[0].args).to.eql([ validationFuncTemplateFile, 'utf8' ]); 55 | 56 | expect(fileFragmentLoaderMock.load.callCount).to.equal(1); 57 | expect(fileFragmentLoaderMock.load.calls[0].args) 58 | .to.eql([ validationFuncTemplateDir, expectedMacroName, originalValidationFuncTemplate ]); 59 | 60 | expect(docDefinitionsLoaderMock.load.callCount).to.equal(1); 61 | expect(docDefinitionsLoaderMock.load.calls[0].args).to.eql([ docDefinitionsFile ]); 62 | 63 | expect(indentMock.js.callCount).to.equal(1); 64 | expect(indentMock.js.calls[0].args).to.eql( 65 | [ `function my-validation-func-template() { ${docDefinitionsContent}; }`, { tabString: ' ' } ]); 66 | }); 67 | }); 68 | -------------------------------------------------------------------------------- /test/sample-notifications-reference.spec.js: -------------------------------------------------------------------------------- 1 | const sampleSpecHelperMaker = require('./helpers/sample-spec-helper-maker'); 2 | const testFixtureMaker = require('../src/testing/test-fixture-maker'); 3 | 4 | describe('Sample business notifications reference doc definition', () => { 5 | const testFixture = 6 | testFixtureMaker.initFromValidationFunction('build/validation-functions/test-sample-validation-function.js'); 7 | const errorFormatter = testFixture.validationErrorFormatter; 8 | const sampleSpecHelper = sampleSpecHelperMaker.init(testFixture); 9 | 10 | afterEach(() => { 11 | testFixture.resetTestEnvironment(); 12 | }); 13 | 14 | const expectedDocType = 'notificationsReference'; 15 | const expectedBasePrivilege = 'NOTIFICATIONS'; 16 | 17 | it('successfully creates a valid notifications reference document', () => { 18 | const doc = { 19 | _id: 'biz.4.notifications', 20 | allNotificationIds: [ 'X', 'Y', 'Z' ], 21 | unreadNotificationIds: [ 'X', 'Z' ] 22 | }; 23 | 24 | sampleSpecHelper.verifyDocumentCreated(expectedBasePrivilege, 4, doc); 25 | }); 26 | 27 | it('cannot create a notifications reference document when the properties are invalid', () => { 28 | const doc = { 29 | _id: 'biz.123.notifications', 30 | allNotificationIds: [ 23, 'Y', 'Z' ], 31 | unreadNotificationIds: [ 'Z', '' ] 32 | }; 33 | 34 | sampleSpecHelper.verifyDocumentNotCreated( 35 | expectedBasePrivilege, 36 | 123, 37 | doc, 38 | expectedDocType, 39 | [ 40 | errorFormatter.typeConstraintViolation('allNotificationIds[0]', 'string'), 41 | errorFormatter.mustNotBeEmptyViolation('unreadNotificationIds[1]') 42 | ]); 43 | }); 44 | 45 | it('successfully replaces a valid notifications reference document', () => { 46 | const doc = { 47 | _id: 'biz.44.notifications', 48 | allNotificationIds: [ 'X', 'Y', 'Z', 'A' ], 49 | unreadNotificationIds: [ 'X', 'Z', 'A' ] 50 | }; 51 | const oldDoc = { 52 | _id: 'biz.44.notifications', 53 | allNotificationIds: [ 'X', 'Y', 'Z' ], 54 | unreadNotificationIds: [ 'X', 'Z' ] 55 | }; 56 | 57 | sampleSpecHelper.verifyDocumentReplaced(expectedBasePrivilege, 44, doc, oldDoc); 58 | }); 59 | 60 | it('cannot replace a notifications reference document when the properties are invalid', () => { 61 | const doc = { 62 | _id: 'biz.29.notifications', 63 | allNotificationIds: [ 'X', 'Y', 'Z', '' ], 64 | unreadNotificationIds: [ 'X', 'Z', 5 ] 65 | }; 66 | const oldDoc = { 67 | _id: 'biz.29.notifications', 68 | allNotificationIds: [ 'X', 'Y', 'Z' ], 69 | unreadNotificationIds: [ 'X', 'Z' ] 70 | }; 71 | 72 | sampleSpecHelper.verifyDocumentNotReplaced( 73 | expectedBasePrivilege, 74 | 29, 75 | doc, 76 | oldDoc, 77 | expectedDocType, 78 | [ 79 | errorFormatter.mustNotBeEmptyViolation('allNotificationIds[3]'), 80 | errorFormatter.typeConstraintViolation('unreadNotificationIds[2]', 'string') 81 | ]); 82 | }); 83 | 84 | it('successfully deletes a notifications reference document', () => { 85 | const oldDoc = { 86 | _id: 'biz.369.notifications', 87 | allNotificationIds: [ 'X', 'Y', 'Z' ], 88 | unreadNotificationIds: [ 'X', 'Z' ] 89 | }; 90 | 91 | sampleSpecHelper.verifyDocumentDeleted(expectedBasePrivilege, 369, oldDoc); 92 | }); 93 | }); 94 | -------------------------------------------------------------------------------- /test/sample-payment-requisitions-reference.spec.js: -------------------------------------------------------------------------------- 1 | const sampleSpecHelperMaker = require('./helpers/sample-spec-helper-maker'); 2 | const testFixtureMaker = require('../src/testing/test-fixture-maker'); 3 | 4 | describe('Sample payment requisitions reference doc definition', () => { 5 | const testFixture = 6 | testFixtureMaker.initFromValidationFunction('build/validation-functions/test-sample-validation-function.js'); 7 | const errorFormatter = testFixture.validationErrorFormatter; 8 | const sampleSpecHelper = sampleSpecHelperMaker.init(testFixture); 9 | 10 | afterEach(() => { 11 | testFixture.resetTestEnvironment(); 12 | }); 13 | 14 | const expectedDocType = 'paymentRequisitionsReference'; 15 | const expectedBasePrivilege = 'INVOICE_PAYMENT_REQUISITIONS'; 16 | 17 | it('successfully creates a valid payment requisitions reference document', () => { 18 | const doc = { _id: 'biz.92.invoice.15.paymentRequisitions', paymentProcessorId: 'foo', paymentRequisitionIds: [ 'req1', 'req2' ] }; 19 | 20 | sampleSpecHelper.verifyDocumentCreated(expectedBasePrivilege, 92, doc); 21 | }); 22 | 23 | it('cannot create a payment requisitions reference document when the properties are invalid', () => { 24 | const doc = { 25 | _id: 'biz.18.invoice.7.paymentRequisitions', 26 | paymentRequisitionIds: [ ], 27 | 'unrecognized-property5': 'foo', 28 | paymentAttemptIds: 79 29 | }; 30 | 31 | sampleSpecHelper.verifyDocumentNotCreated( 32 | expectedBasePrivilege, 33 | 18, 34 | doc, 35 | expectedDocType, 36 | [ 37 | errorFormatter.requiredValueViolation('paymentProcessorId'), 38 | errorFormatter.mustNotBeEmptyViolation('paymentRequisitionIds'), 39 | errorFormatter.typeConstraintViolation('paymentAttemptIds', 'array'), 40 | errorFormatter.unsupportedProperty('unrecognized-property5') 41 | ]); 42 | }); 43 | 44 | it('successfully replaces a valid payment requisitions reference document', () => { 45 | const doc = { _id: 'biz.3612.invoice.222.paymentRequisitions', paymentProcessorId: 'bar', paymentRequisitionIds: [ 'req2' ] }; 46 | const oldDoc = { _id: 'biz.3612.invoice.222.paymentRequisitions', paymentProcessorId: 'foo', paymentRequisitionIds: [ 'req1' ] }; 47 | 48 | sampleSpecHelper.verifyDocumentReplaced(expectedBasePrivilege, 3612, doc, oldDoc); 49 | }); 50 | 51 | it('cannot replace a payment requisitions reference document when the properties are invalid', () => { 52 | const doc = { 53 | _id: 'biz.666.invoice.3.paymentRequisitions', 54 | paymentProcessorId: '', 55 | paymentRequisitionIds: [ 'foo', 15 ], 56 | 'unrecognized-property6': 'bar', 57 | paymentAttemptIds: [ 73, 'bar' ] 58 | }; 59 | const oldDoc = { _id: 'biz.666.invoice.3.paymentRequisitions', paymentProcessorId: 'foo', paymentRequisitionIds: [ 'req1' ] }; 60 | 61 | sampleSpecHelper.verifyDocumentNotReplaced( 62 | expectedBasePrivilege, 63 | 666, 64 | doc, 65 | oldDoc, 66 | expectedDocType, 67 | [ 68 | errorFormatter.mustNotBeEmptyViolation('paymentProcessorId'), 69 | errorFormatter.typeConstraintViolation('paymentRequisitionIds[1]', 'string'), 70 | errorFormatter.typeConstraintViolation('paymentAttemptIds[0]', 'string'), 71 | errorFormatter.unsupportedProperty('unrecognized-property6') 72 | ]); 73 | }); 74 | 75 | it('successfully deletes a payment requisitions reference document', () => { 76 | const oldDoc = { _id: 'biz.987.invoice.2.paymentRequisitions' }; 77 | 78 | sampleSpecHelper.verifyDocumentDeleted(expectedBasePrivilege, 987, oldDoc); 79 | }); 80 | }); 81 | -------------------------------------------------------------------------------- /test/sample-payment-requisition.spec.js: -------------------------------------------------------------------------------- 1 | const sampleSpecHelperMaker = require('./helpers/sample-spec-helper-maker'); 2 | const testFixtureMaker = require('../src/testing/test-fixture-maker'); 3 | 4 | describe('Sample invoice payment requisition doc definition', () => { 5 | const testFixture = 6 | testFixtureMaker.initFromValidationFunction('build/validation-functions/test-sample-validation-function.js'); 7 | const errorFormatter = testFixture.validationErrorFormatter; 8 | const sampleSpecHelper = sampleSpecHelperMaker.init(testFixture); 9 | 10 | afterEach(() => { 11 | testFixture.resetTestEnvironment(); 12 | }); 13 | 14 | const expectedDocType = 'paymentRequisition'; 15 | const expectedBasePrivilege = 'INVOICE_PAYMENT_REQUISITIONS'; 16 | 17 | it('successfully creates a valid payment requisition document', () => { 18 | const doc = { 19 | _id: 'paymentRequisition.foo-bar', 20 | invoiceRecordId: 10, 21 | businessId: 20, 22 | issuedAt: '2016-02-29T17:13:43.666Z', 23 | issuedByUserId: 42, 24 | invoiceRecipients: 'foo@bar.baz' 25 | }; 26 | 27 | sampleSpecHelper.verifyDocumentCreated(expectedBasePrivilege, 20, doc); 28 | }); 29 | 30 | it('cannot create a payment requisition document when the properties are invalid', () => { 31 | const doc = { 32 | _id: 'paymentRequisition.foo-bar', 33 | invoiceRecordId: 0, 34 | businessId: '6', 35 | issuedAt: '2016-13-29T17:13:43.666Z', // The month is invalid 36 | issuedByUserId: 0, 37 | invoiceRecipients: [ 'foo@bar.baz' ], 38 | 'unrecognized-property7': 'foo' 39 | }; 40 | 41 | sampleSpecHelper.verifyDocumentNotCreated( 42 | expectedBasePrivilege, 43 | 6, 44 | doc, 45 | expectedDocType, 46 | [ 47 | errorFormatter.typeConstraintViolation('businessId', 'integer'), 48 | errorFormatter.minimumValueViolation('invoiceRecordId', 1), 49 | errorFormatter.datetimeFormatInvalid('issuedAt'), 50 | errorFormatter.minimumValueViolation('issuedByUserId', 1), 51 | errorFormatter.typeConstraintViolation('invoiceRecipients', 'string'), 52 | errorFormatter.unsupportedProperty('unrecognized-property7') 53 | ]); 54 | }); 55 | 56 | it('cannot replace a payment requisition document because it is marked as irreplaceable', () => { 57 | const doc = { 58 | _id: 'paymentRequisition.foo-bar', 59 | invoiceRecordId: '7', 60 | businessId: 0, 61 | issuedAt: '2016-02-29T25:13:43.666Z', // The hour is invalid 62 | issuedByUserId: '42', 63 | invoiceRecipients: 15, 64 | 'unrecognized-property8': 'bar' 65 | }; 66 | const oldDoc = { _id: 'paymentRequisition.foo-bar', invoiceRecordId: 10, businessId: 20 }; 67 | 68 | sampleSpecHelper.verifyDocumentNotReplaced( 69 | expectedBasePrivilege, 70 | 20, 71 | doc, 72 | oldDoc, 73 | expectedDocType, 74 | [ 75 | 'cannot change "businessId" property', 76 | errorFormatter.minimumValueViolation('businessId', 1), 77 | errorFormatter.typeConstraintViolation('invoiceRecordId', 'integer'), 78 | errorFormatter.datetimeFormatInvalid('issuedAt'), 79 | errorFormatter.typeConstraintViolation('issuedByUserId', 'integer'), 80 | errorFormatter.typeConstraintViolation('invoiceRecipients', 'string'), 81 | errorFormatter.unsupportedProperty('unrecognized-property8'), 82 | errorFormatter.cannotReplaceDocViolation() 83 | ]); 84 | }); 85 | 86 | it('successfully deletes a payment requisition document', () => { 87 | const oldDoc = { _id: 'paymentRequisition.foo-bar', invoiceRecordId: 10, businessId: 17 }; 88 | 89 | sampleSpecHelper.verifyDocumentDeleted(expectedBasePrivilege, 17, oldDoc); 90 | }); 91 | }); 92 | -------------------------------------------------------------------------------- /test/document-id-regex-pattern.spec.js: -------------------------------------------------------------------------------- 1 | const testFixtureMaker = require('../src/testing/test-fixture-maker'); 2 | const errorFormatter = require('../src/testing/validation-error-formatter'); 3 | 4 | describe('Document ID regular expression pattern constraint:', () => { 5 | const testFixture = 6 | testFixtureMaker.initFromValidationFunction('build/validation-functions/test-document-id-regex-pattern-validation-function.js'); 7 | 8 | afterEach(() => { 9 | testFixture.resetTestEnvironment(); 10 | }); 11 | 12 | describe('with a static constraint value', () => { 13 | it('allows creation of a document whose ID matches the pattern', () => { 14 | const doc = { 15 | _id: 'my-doc.12345', 16 | type: 'staticDocumentIdRegexPatternDoc' 17 | }; 18 | 19 | testFixture.verifyDocumentCreated(doc); 20 | }); 21 | 22 | it('allows replacement of a document even if the ID does not match the pattern', () => { 23 | const oldDoc = { 24 | _id: 'invalid-doc-id', 25 | type: 'staticDocumentIdRegexPatternDoc' 26 | }; 27 | const doc = { 28 | _id: 'invalid-doc-id', 29 | type: 'staticDocumentIdRegexPatternDoc' 30 | }; 31 | 32 | testFixture.verifyDocumentReplaced(doc, oldDoc); 33 | }); 34 | 35 | it('allows deletion of a document even if the ID does not match the pattern', () => { 36 | const oldDoc = { 37 | _id: 'invalid-doc-id', 38 | type: 'staticDocumentIdRegexPatternDoc' 39 | }; 40 | 41 | testFixture.verifyDocumentDeleted(oldDoc); 42 | }); 43 | 44 | it('rejects creation of a document whose ID does not match the pattern', () => { 45 | const doc = { 46 | _id: 'my-doc.1abcd', 47 | type: 'staticDocumentIdRegexPatternDoc' 48 | }; 49 | 50 | testFixture.verifyDocumentNotCreated( 51 | doc, 52 | 'staticDocumentIdRegexPatternDoc', 53 | [ errorFormatter.documentIdRegexPatternViolation(/^my-doc\.\d+$/) ]); 54 | }); 55 | }); 56 | 57 | describe('with a dynamic constraint value', () => { 58 | it('allows creation of a document whose ID matches the pattern', () => { 59 | const entityId = 'my-arbitrary-entity'; 60 | const doc = { 61 | _id: `entity.${entityId}`, 62 | type: 'dynamicDocumentIdRegexPatternDoc', 63 | entityId: entityId 64 | }; 65 | 66 | testFixture.verifyDocumentCreated(doc); 67 | }); 68 | 69 | it('allows replacement of a document even if the ID does not match the pattern', () => { 70 | const oldDoc = { 71 | _id: 'entity.mismatched-id', 72 | type: 'dynamicDocumentIdRegexPatternDoc', 73 | entityId: 'my-entity' 74 | }; 75 | const doc = { 76 | _id: 'entity.mismatched-id', 77 | type: 'dynamicDocumentIdRegexPatternDoc', 78 | entityId: 'my-entity' 79 | }; 80 | 81 | testFixture.verifyDocumentReplaced(doc, oldDoc); 82 | }); 83 | 84 | it('allows deletion of a document even if the ID does not match the pattern', () => { 85 | const oldDoc = { 86 | _id: 'entity.mismatched-id', 87 | type: 'dynamicDocumentIdRegexPatternDoc', 88 | entityId: 'my-entity' 89 | }; 90 | 91 | testFixture.verifyDocumentDeleted(oldDoc); 92 | }); 93 | 94 | it('rejects creation of a document whose ID does not match the pattern', () => { 95 | const entityId = 'my-entity'; 96 | const doc = { 97 | _id: 'entity.mismatched-id', 98 | type: 'dynamicDocumentIdRegexPatternDoc', 99 | entityId: entityId 100 | }; 101 | 102 | testFixture.verifyDocumentNotCreated( 103 | doc, 104 | 'dynamicDocumentIdRegexPatternDoc', 105 | [ errorFormatter.documentIdRegexPatternViolation(new RegExp(`^entity\\.${entityId}$`)) ]); 106 | }); 107 | }); 108 | }); 109 | -------------------------------------------------------------------------------- /test/resources/authorization-doc-definitions.js: -------------------------------------------------------------------------------- 1 | { 2 | explicitRolesDoc: { 3 | authorizedRoles: { 4 | add: 'add', 5 | replace: [ 'replace', 'update' ], 6 | remove: [ 'remove', 'delete' ] 7 | }, 8 | typeFilter: function(doc) { 9 | return doc._id === 'explicitRolesDoc'; 10 | }, 11 | propertyValidators: { 12 | stringProp: { 13 | type: 'string' 14 | } 15 | } 16 | }, 17 | writeOnlyRolesDoc: { 18 | authorizedRoles: { 19 | write: [ 'edit', 'modify', 'write' ] 20 | }, 21 | typeFilter: function(doc) { 22 | return doc._id === 'writeOnlyRolesDoc'; 23 | }, 24 | propertyValidators: { 25 | stringProp: { 26 | type: 'string' 27 | } 28 | } 29 | }, 30 | writeAndAddRolesDoc: { 31 | authorizedRoles: { 32 | write: 'edit', 33 | add: 'add' 34 | }, 35 | typeFilter: function(doc) { 36 | return doc._id === 'writeAndAddRolesDoc'; 37 | }, 38 | propertyValidators: { 39 | stringProp: { 40 | type: 'string' 41 | } 42 | } 43 | }, 44 | dynamicRolesAndUsersDoc: { 45 | typeFilter: function(doc) { 46 | return doc._id === 'dynamicRolesAndUsersDoc'; 47 | }, 48 | authorizedRoles: function(doc, oldDoc, dbName) { 49 | var rolesList = oldDoc ? oldDoc.roles : doc.roles; 50 | 51 | return { 52 | write: rolesList.map(function(role) { return dbName + '-' + role; }) 53 | }; 54 | }, 55 | authorizedUsers: function(doc, oldDoc, dbName) { 56 | var usersList = oldDoc ? oldDoc.users : doc.users; 57 | 58 | return { 59 | write: usersList.map(function(username) { return dbName + '-' + username; }) 60 | }; 61 | }, 62 | propertyValidators: { 63 | stringProp: { 64 | type: 'string' 65 | }, 66 | roles: { 67 | type: 'array' 68 | }, 69 | users: { 70 | type: 'array' 71 | } 72 | } 73 | }, 74 | explicitUsernamesDoc: { 75 | typeFilter: function(doc) { 76 | return doc._id === 'explicitUsernamesDoc'; 77 | }, 78 | authorizedUsers: { 79 | add: [ 'add1', 'add2' ], 80 | replace: [ 'replace1', 'replace2' ], 81 | remove: [ 'remove1', 'remove2' ] 82 | }, 83 | propertyValidators: { 84 | stringProp: { 85 | type: 'string' 86 | } 87 | } 88 | }, 89 | replaceOnlyRoleDoc: { 90 | authorizedRoles: { 91 | replace: 'replace' 92 | }, 93 | typeFilter: function(doc) { 94 | return doc._id === 'replaceOnlyRoleDoc'; 95 | }, 96 | propertyValidators: { 97 | stringProp: { 98 | type: 'string' 99 | } 100 | } 101 | }, 102 | addOnlyRoleDoc: { 103 | authorizedRoles: { 104 | add: 'add' 105 | }, 106 | typeFilter: function(doc) { 107 | return doc._id === 'addOnlyRoleDoc'; 108 | }, 109 | propertyValidators: { 110 | stringProp: { 111 | type: 'string' 112 | } 113 | } 114 | }, 115 | staticUniversalAccessDoc: { 116 | typeFilter: function(doc) { 117 | return doc._id === 'staticUniversalAccessDoc'; 118 | }, 119 | grantAllMembersWriteAccess: true, 120 | propertyValidators: { 121 | floatProp: { 122 | type: 'float' 123 | } 124 | } 125 | }, 126 | dynamicUniversalAccessDoc: { 127 | typeFilter: function(doc) { 128 | return doc._id === 'dynamicUniversalAccessDoc'; 129 | }, 130 | grantAllMembersWriteAccess: function(doc, oldDoc, dbName) { 131 | if (dbName === 'all-members-write-access-db') { 132 | return true; 133 | } else if (oldDoc) { 134 | return oldDoc.allowAccess; 135 | } else { 136 | return doc.allowAccess; 137 | } 138 | }, 139 | propertyValidators: { 140 | allowAccess: { 141 | type: 'boolean' 142 | } 143 | } 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /test/sample-payment-processor-definition.spec.js: -------------------------------------------------------------------------------- 1 | const sampleSpecHelperMaker = require('./helpers/sample-spec-helper-maker'); 2 | const testFixtureMaker = require('../src/testing/test-fixture-maker'); 3 | 4 | describe('Sample payment processor definition doc definition', () => { 5 | const testFixture = 6 | testFixtureMaker.initFromValidationFunction('build/validation-functions/test-sample-validation-function.js'); 7 | const errorFormatter = testFixture.validationErrorFormatter; 8 | const sampleSpecHelper = sampleSpecHelperMaker.init(testFixture); 9 | 10 | afterEach(() => { 11 | testFixture.resetTestEnvironment(); 12 | }); 13 | 14 | const expectedDocType = 'paymentProcessorDefinition'; 15 | const expectedBasePrivilege = 'CUSTOMER_PAYMENT_PROCESSORS'; 16 | 17 | it('successfully creates a valid payment processor document', () => { 18 | const doc = { 19 | _id: 'biz.3.paymentProcessor.2', 20 | provider: 'foo', 21 | spreedlyGatewayToken: 'bar', 22 | accountId: 555, 23 | displayName: 'Foo Bar', 24 | supportedCurrencyCodes: [ 'CAD', 'USD' ] 25 | }; 26 | 27 | sampleSpecHelper.verifyDocumentCreated(expectedBasePrivilege, 3, doc); 28 | }); 29 | 30 | it('cannot create a payment processor document when the properties are invalid', () => { 31 | const doc = { 32 | _id: 'biz.1.paymentProcessor.2', 33 | provider: '', 34 | spreedlyGatewayToken: '', 35 | accountId: 0, 36 | displayName: 7, 37 | supportedCurrencyCodes: '', 38 | 'unrecognized-property3': 'foo' 39 | }; 40 | 41 | sampleSpecHelper.verifyDocumentNotCreated( 42 | expectedBasePrivilege, 43 | 1, 44 | doc, 45 | expectedDocType, 46 | [ 47 | errorFormatter.mustNotBeEmptyViolation('provider'), 48 | errorFormatter.mustNotBeEmptyViolation('spreedlyGatewayToken'), 49 | errorFormatter.minimumValueViolation('accountId', 1), 50 | errorFormatter.typeConstraintViolation('displayName', 'string'), 51 | errorFormatter.typeConstraintViolation('supportedCurrencyCodes', 'array'), 52 | errorFormatter.unsupportedProperty('unrecognized-property3') 53 | ]); 54 | }); 55 | 56 | it('successfully replaces a valid payment processor document', () => { 57 | const doc = { 58 | _id: 'biz.5.paymentProcessor.2', 59 | provider: 'foobar', 60 | spreedlyGatewayToken: 'barfoo', 61 | accountId: 1 62 | }; 63 | const oldDoc = { _id: 'biz.5.paymentProcessor.2', provider: 'bar' }; 64 | 65 | sampleSpecHelper.verifyDocumentReplaced(expectedBasePrivilege, 5, doc, oldDoc); 66 | }); 67 | 68 | it('cannot replace a payment processor document when the properties are invalid', () => { 69 | const doc = { 70 | _id: 'biz.2.paymentProcessor.2', 71 | accountId: 555.9, 72 | displayName: [ ], 73 | supportedCurrencyCodes: [ '666', 'CAD' ], 74 | 'unrecognized-property4': 'bar' 75 | }; 76 | const oldDoc = { _id: 'biz.2.paymentProcessor.2', provider: 'foo' }; 77 | 78 | sampleSpecHelper.verifyDocumentNotReplaced( 79 | expectedBasePrivilege, 80 | 2, 81 | doc, 82 | oldDoc, 83 | expectedDocType, 84 | [ 85 | errorFormatter.regexPatternItemViolation('supportedCurrencyCodes[0]', /^[A-Z]{3}$/), 86 | errorFormatter.typeConstraintViolation('accountId', 'integer'), 87 | errorFormatter.typeConstraintViolation('displayName', 'string'), 88 | errorFormatter.requiredValueViolation('provider'), 89 | errorFormatter.requiredValueViolation('spreedlyGatewayToken'), 90 | errorFormatter.unsupportedProperty('unrecognized-property4') 91 | ]); 92 | }); 93 | 94 | it('successfully deletes a payment processor document', () => { 95 | const oldDoc = { _id: 'biz.8.paymentProcessor.2' }; 96 | 97 | sampleSpecHelper.verifyDocumentDeleted(expectedBasePrivilege, 8, oldDoc); 98 | }); 99 | }); 100 | -------------------------------------------------------------------------------- /test/resources/skip-validation-when-value-unchanged-doc-definitions.js: -------------------------------------------------------------------------------- 1 | function() { 2 | return { 3 | staticSkipValidationWhenValueUnchangedDoc: { 4 | typeFilter: simpleTypeFilter, 5 | authorizedRoles: { write: 'write' }, 6 | propertyValidators: { 7 | integerProp: { 8 | type: 'integer', 9 | skipValidationWhenValueUnchanged: true, 10 | minimumValue: 0 11 | }, 12 | floatProp: { 13 | type: 'float', 14 | skipValidationWhenValueUnchanged: true, 15 | maximumValue: 0 16 | }, 17 | stringProp: { 18 | type: 'string', 19 | skipValidationWhenValueUnchanged: true, 20 | minimumLength: 4 21 | }, 22 | booleanProp: { 23 | type: 'boolean', 24 | skipValidationWhenValueUnchanged: true, 25 | customValidation: function(doc, oldDoc, currentItemEntry) { 26 | if (isValueNullOrUndefined(currentItemEntry.itemValue) || currentItemEntry.itemValue) { 27 | return [ ]; 28 | } else { 29 | return [ currentItemEntry.itemName + ' must be true' ]; 30 | } 31 | } 32 | }, 33 | dateProp: { 34 | type: 'date', 35 | skipValidationWhenValueUnchanged: true, 36 | maximumValue: '1953-01-14' 37 | }, 38 | datetimeProp: { 39 | type: 'datetime', 40 | skipValidationWhenValueUnchanged: true, 41 | minimumValue: '2018-06-13T23:33+00:00', 42 | maximumValue: '2018-06-13T23:33Z' 43 | }, 44 | timeProp: { 45 | type: 'time', 46 | skipValidationWhenValueUnchanged: true, 47 | minimumValueExclusive: '17:45:53.911' 48 | }, 49 | timezoneProp: { 50 | type: 'timezone', 51 | skipValidationWhenValueUnchanged: true, 52 | maximumValueExclusive: '+15:30' 53 | }, 54 | enumProp: { 55 | type: 'enum', 56 | predefinedValues: [ 1, 2, 3 ], 57 | skipValidationWhenValueUnchanged: true 58 | }, 59 | uuidProp: { 60 | type: 'uuid', 61 | skipValidationWhenValueUnchanged: true, 62 | maximumValueExclusive: '10000000-0000-0000-0000-000000000000' 63 | }, 64 | attachmentReferenceProp: { 65 | type: 'attachmentReference', 66 | skipValidationWhenValueUnchanged: true, 67 | regexPattern: /^[a-z]+\.txt$/ 68 | }, 69 | arrayProp: { 70 | type: 'array', 71 | skipValidationWhenValueUnchanged: true, 72 | maximumLength: 3 73 | }, 74 | objectProp: { 75 | type: 'object', 76 | skipValidationWhenValueUnchanged: true, 77 | propertyValidators: { 78 | nestedProp: { 79 | type: 'string' 80 | } 81 | } 82 | }, 83 | hashtableProp: { 84 | type: 'hashtable', 85 | skipValidationWhenValueUnchanged: true, 86 | hashtableValuesValidator: { 87 | type: 'integer' 88 | } 89 | } 90 | } 91 | }, 92 | dynamicSkipValidationWhenValueUnchangedDoc: { 93 | typeFilter: simpleTypeFilter, 94 | authorizedRoles: { write: 'write' }, 95 | propertyValidators: { 96 | allowValidationSkip: { 97 | type: 'boolean' 98 | }, 99 | minimumUuidValue: { 100 | type: 'uuid' 101 | }, 102 | uuidProp: { 103 | type: 'uuid', 104 | skipValidationWhenValueUnchanged: function(doc, oldDoc, value, oldValue) { 105 | return !isDocumentMissingOrDeleted(oldDoc) ? oldDoc.allowValidationSkip : doc.allowValidationSkip; 106 | }, 107 | minimumValue: function(doc, oldDoc, value, oldValue) { 108 | return doc.minimumUuidValue; 109 | } 110 | } 111 | } 112 | } 113 | }; 114 | } 115 | -------------------------------------------------------------------------------- /test/resources/must-equal-doc-definitions.js: -------------------------------------------------------------------------------- 1 | function() { 2 | var authorizedRoles = { write: 'write' }; 3 | 4 | function sharedTypeFilter(doc, oldDoc, docType) { 5 | return doc._id === docType; 6 | } 7 | 8 | function getExpectedDynamicValue(doc, oldDoc, value, oldValue) { 9 | return doc.expectedDynamicValue; 10 | } 11 | 12 | return { 13 | staticArrayDoc: { 14 | typeFilter: sharedTypeFilter, 15 | authorizedRoles: authorizedRoles, 16 | propertyValidators: { 17 | arrayProp: { 18 | type: 'array', 19 | mustEqual: [ 16.2, [ 'foobar', 3, false ], [ 45.9 ], null, { foo: 'bar' }, [ ] ] 20 | } 21 | } 22 | }, 23 | dynamicArrayDoc: { 24 | typeFilter: sharedTypeFilter, 25 | authorizedRoles: authorizedRoles, 26 | propertyValidators: { 27 | expectedDynamicValue: { 28 | type: 'array' 29 | }, 30 | arrayProp: { 31 | type: 'array', 32 | mustEqual: getExpectedDynamicValue 33 | } 34 | } 35 | }, 36 | staticObjectDoc: { 37 | typeFilter: sharedTypeFilter, 38 | authorizedRoles: authorizedRoles, 39 | propertyValidators: { 40 | objectProp: { 41 | type: 'object', 42 | mustEqual: { 43 | myStringProp: 'foobar', 44 | myIntegerProp: 8, 45 | myBooleanProp: true, 46 | myFloatProp: 88.92, 47 | myArrayProp: [ 'foobar', 3, false, 45.9, [ null ], { } ], 48 | myObjectProp: { foo: 'bar', baz: 73, qux: [ ] } 49 | } 50 | } 51 | } 52 | }, 53 | dynamicObjectDoc: { 54 | typeFilter: sharedTypeFilter, 55 | authorizedRoles: authorizedRoles, 56 | propertyValidators: { 57 | expectedDynamicValue: { 58 | type: 'object' 59 | }, 60 | objectProp: { 61 | type: 'object', 62 | mustEqual: getExpectedDynamicValue 63 | } 64 | } 65 | }, 66 | staticHashtableDoc: { 67 | typeFilter: sharedTypeFilter, 68 | authorizedRoles: authorizedRoles, 69 | propertyValidators: { 70 | hashtableProp: { 71 | type: 'hashtable', 72 | mustEqual: { 73 | myArrayProp: [ 'foobar', 3, false, 45.9, [ null ], { foobar: 18 } ], 74 | myObjectProp: { foo: 'bar', baz: 73, qux: [ ] }, 75 | myStringProp: 'foobar', 76 | myIntegerProp: 8, 77 | myBooleanProp: true, 78 | myFloatProp: 88.92 79 | } 80 | } 81 | } 82 | }, 83 | dynamicHashtableDoc: { 84 | typeFilter: sharedTypeFilter, 85 | authorizedRoles: authorizedRoles, 86 | propertyValidators: { 87 | expectedDynamicValue: { 88 | type: 'hashtable' 89 | }, 90 | hashtableProp: { 91 | type: 'hashtable', 92 | mustEqual: getExpectedDynamicValue 93 | } 94 | } 95 | }, 96 | arrayElementConstraintDoc: { 97 | typeFilter: sharedTypeFilter, 98 | authorizedRoles: authorizedRoles, 99 | propertyValidators: { 100 | arrayProp: { 101 | type: 'array', 102 | arrayElementsValidator: { 103 | type: 'string', 104 | mustEqual: 'foobar' 105 | } 106 | } 107 | } 108 | }, 109 | hashtableElementConstraintDoc: { 110 | typeFilter: sharedTypeFilter, 111 | authorizedRoles: authorizedRoles, 112 | propertyValidators: { 113 | hashtableProp: { 114 | type: 'hashtable', 115 | hashtableValuesValidator: { 116 | type: 'integer', 117 | mustEqual: -15 118 | } 119 | } 120 | } 121 | }, 122 | nullExpectedValueDoc: { 123 | typeFilter: sharedTypeFilter, 124 | authorizedRoles: authorizedRoles, 125 | propertyValidators: { 126 | stringProp: { 127 | type: 'string', 128 | mustEqual: null 129 | } 130 | } 131 | } 132 | }; 133 | } 134 | -------------------------------------------------------------------------------- /test/resources/string-doc-definitions.js: -------------------------------------------------------------------------------- 1 | function() { 2 | function isNonEmpty(doc, oldDoc, value, oldValue) { 3 | return doc.dynamicMustNotBeEmptyPropertiesEnforced; 4 | } 5 | 6 | function minimumDynamicLength(doc, oldDoc, value, oldValue) { 7 | return doc.dynamicLengthPropertyIsValid ? value.length : value.length + 1; 8 | } 9 | 10 | function maximumDynamicLength(doc, oldDoc, value, oldValue) { 11 | return doc.dynamicLengthPropertyIsValid ? value.length : value.length - 1; 12 | } 13 | 14 | function dynamicRegexPattern(doc, oldDoc, value, oldValue) { 15 | return new RegExp(doc.dynamicRegex); 16 | } 17 | 18 | function dynamicMustBeTrimmed(doc, oldDoc, value, oldValue) { 19 | return doc.dynamicMustBeTrimmedState; 20 | } 21 | 22 | function dynamicMinimumValue(doc, oldDoc, value, oldValue) { 23 | return doc.dynamicMinimumValue; 24 | } 25 | 26 | function dynamicMaximumValue(doc, oldDoc, value, oldValue) { 27 | return doc.dynamicMaximumValue; 28 | } 29 | 30 | return { 31 | stringDoc: { 32 | authorizedRoles: { write: 'write' }, 33 | typeFilter: function(doc) { 34 | return doc._id === 'stringDoc'; 35 | }, 36 | propertyValidators: { 37 | staticLengthValidationProp: { 38 | type: 'string', 39 | minimumLength: 3, 40 | maximumLength: 3 41 | }, 42 | dynamicLengthPropertyIsValid: { 43 | type: 'boolean' 44 | }, 45 | dynamicLengthValidationProp: { 46 | type: 'string', 47 | minimumLength: minimumDynamicLength, 48 | maximumLength: maximumDynamicLength 49 | }, 50 | staticNonEmptyValidationProp: { 51 | type: 'string', 52 | mustNotBeEmpty: true 53 | }, 54 | dynamicMustNotBeEmptyPropertiesEnforced: { 55 | type: 'boolean' 56 | }, 57 | dynamicNonEmptyValidationProp: { 58 | type: 'string', 59 | mustNotBeEmpty: isNonEmpty 60 | }, 61 | staticRegexPatternValidationProp: { 62 | type: 'string', 63 | regexPattern: /^\d+`[a-z]+$/ 64 | }, 65 | dynamicRegex: { 66 | type: 'string' 67 | }, 68 | dynamicRegexPatternValidationProp: { 69 | type: 'string', 70 | regexPattern: dynamicRegexPattern 71 | }, 72 | staticMustBeTrimmedValidationProp: { 73 | type: 'string', 74 | mustBeTrimmed: true 75 | }, 76 | dynamicMustBeTrimmedState: { 77 | type: 'boolean' 78 | }, 79 | dynamicMustBeTrimmedValidationProp: { 80 | type: 'string', 81 | mustBeTrimmed: dynamicMustBeTrimmed 82 | }, 83 | staticInclusiveRangeValidationProp: { 84 | type: 'string', 85 | minimumValue: 'A', 86 | maximumValue: 'Z' 87 | }, 88 | staticExclusiveRangeValidationProp: { 89 | type: 'string', 90 | minimumValueExclusive: 'aa', 91 | maximumValueExclusive: 'c' 92 | }, 93 | dynamicMinimumValue: { 94 | type: 'string' 95 | }, 96 | dynamicMaximumValue: { 97 | type: 'string' 98 | }, 99 | dynamicInclusiveRangeValidationProp: { 100 | type: 'string', 101 | minimumValue: dynamicMinimumValue, 102 | maximumValue: dynamicMaximumValue 103 | }, 104 | dynamicExclusiveRangeValidationProp: { 105 | type: 'string', 106 | minimumValueExclusive: dynamicMinimumValue, 107 | maximumValueExclusive: dynamicMaximumValue 108 | }, 109 | dynamicCaseInsensitiveEqualityValue: { 110 | type: 'string' 111 | }, 112 | dynamicMustEqualIgnoreCaseValidationProp: { 113 | type: 'string', 114 | mustEqualIgnoreCase: function(doc, oldDoc, value, oldValue) { 115 | return doc.dynamicCaseInsensitiveEqualityValue; 116 | } 117 | } 118 | } 119 | } 120 | }; 121 | } 122 | -------------------------------------------------------------------------------- /test/any-type.spec.js: -------------------------------------------------------------------------------- 1 | const testFixtureMaker = require('../src/testing/test-fixture-maker'); 2 | const errorFormatter = require('../src/testing/validation-error-formatter'); 3 | 4 | describe('Any validation type:', () => { 5 | const testFixture = 6 | testFixtureMaker.initFromValidationFunction('build/validation-functions/test-any-type-validation-function.js'); 7 | 8 | afterEach(() => { 9 | testFixture.resetTestEnvironment(); 10 | }); 11 | 12 | describe('for array elements', () => { 13 | it('allows string, number, boolean, array and object values in an array', () => { 14 | const doc = { 15 | _id: 'my-doc', 16 | type: 'anyTypeDoc', 17 | arrayProp: [ 18 | 'a-string', 19 | -117.8, 20 | true, 21 | [ 'foo', 'bar' ], 22 | { baz: 'qux' } 23 | ] 24 | }; 25 | 26 | testFixture.verifyDocumentCreated(doc); 27 | }); 28 | 29 | it('respects universal constraints (e.g. "required")', () => { 30 | const doc = { 31 | _id: 'my-doc', 32 | type: 'anyTypeDoc', 33 | arrayProp: [ 34 | '', 35 | 0, 36 | null 37 | ] 38 | }; 39 | 40 | testFixture.verifyDocumentNotCreated(doc, 'anyTypeDoc', errorFormatter.requiredValueViolation('arrayProp[2]')); 41 | }); 42 | }); 43 | 44 | describe('for hashtable elements', () => { 45 | it('allows string, number, boolean, array and object values in a hashtable', () => { 46 | const doc = { 47 | _id: 'my-doc', 48 | type: 'anyTypeDoc', 49 | hashtableProp: { 50 | 1: 'another-string', 51 | 2: 13, 52 | 3: false, 53 | 4: [ 0, 1, 2 ], 54 | 5: { } 55 | } 56 | }; 57 | 58 | testFixture.verifyDocumentCreated(doc); 59 | }); 60 | 61 | it('respects universal constraints (e.g. "immutableWhenSet")', () => { 62 | const oldDoc = { 63 | _id: 'my-doc', 64 | type: 'anyTypeDoc', 65 | hashtableProp: { 66 | 1: 1.9, 67 | 2: true, 68 | 3: 'one-more-string', 69 | 4: null // Can be changed since it doesn't have a value yet 70 | } 71 | }; 72 | 73 | const doc = { 74 | _id: 'my-doc', 75 | type: 'anyTypeDoc', 76 | hashtableProp: { 77 | 1: 85, // Changed 78 | 2: true, 79 | 3: 'one-more-string', 80 | 4: [ ] 81 | } 82 | }; 83 | 84 | testFixture.verifyDocumentNotReplaced( 85 | doc, 86 | oldDoc, 87 | 'anyTypeDoc', 88 | errorFormatter.immutableItemViolation('hashtableProp[1]')); 89 | }); 90 | }); 91 | 92 | describe('for object properties', () => { 93 | it('allows a string value', () => { 94 | const doc = { 95 | _id: 'my-doc', 96 | type: 'anyTypeDoc', 97 | anyProp: 'a-string' 98 | }; 99 | 100 | testFixture.verifyDocumentCreated(doc); 101 | }); 102 | 103 | it('allows a numeric value', () => { 104 | const doc = { 105 | _id: 'my-doc', 106 | type: 'anyTypeDoc', 107 | anyProp: -115.8 108 | }; 109 | 110 | testFixture.verifyDocumentCreated(doc); 111 | }); 112 | 113 | it('allows a boolean value', () => { 114 | const doc = { 115 | _id: 'my-doc', 116 | type: 'anyTypeDoc', 117 | anyProp: false 118 | }; 119 | 120 | testFixture.verifyDocumentCreated(doc); 121 | }); 122 | 123 | it('allows an array value', () => { 124 | const doc = { 125 | _id: 'my-doc', 126 | type: 'anyTypeDoc', 127 | anyProp: [ 'foo', 'bar' ] 128 | }; 129 | 130 | testFixture.verifyDocumentCreated(doc); 131 | }); 132 | 133 | it('allows an object value', () => { 134 | const doc = { 135 | _id: 'my-doc', 136 | type: 'anyTypeDoc', 137 | anyProp: { foo: 'bar' } 138 | }; 139 | 140 | testFixture.verifyDocumentCreated(doc); 141 | }); 142 | }); 143 | }); 144 | -------------------------------------------------------------------------------- /samples/fragment-notification.js: -------------------------------------------------------------------------------- 1 | { 2 | authorizedRoles: function(doc, oldDoc) { 3 | var businessId = getBusinessId(doc, oldDoc); 4 | 5 | // Only service users can create new notifications 6 | return { 7 | add: [ 'notification-add' ], 8 | replace: [ toDbRole(businessId, 'CHANGE_NOTIFICATIONS') ], 9 | remove: [ toDbRole(businessId, 'REMOVE_NOTIFICATIONS') ] 10 | }; 11 | }, 12 | typeFilter: function(doc, oldDoc) { 13 | return createBusinessEntityRegex('notification\\.[A-Za-z0-9_-]+$').test(doc._id); 14 | }, 15 | propertyValidators: { 16 | eventId: { 17 | type: 'uuid', 18 | required: true, 19 | immutable: true 20 | }, 21 | sender: { 22 | // Which Kashoo app/service generated the notification 23 | type: 'string', 24 | required: true, 25 | mustNotBeEmpty: true, 26 | immutable: true 27 | }, 28 | users: { 29 | type: 'array', 30 | immutable: true, 31 | arrayElementsValidator: { 32 | type: 'string', 33 | required: true, 34 | mustNotBeEmpty: true 35 | } 36 | }, 37 | groups: { 38 | type: 'array', 39 | immutable: true, 40 | arrayElementsValidator: { 41 | type: 'string', 42 | required: true, 43 | mustNotBeEmpty: true 44 | } 45 | }, 46 | type: { 47 | // The type of notification. Corresponds to an entry in the business' notificationsConfig.notificationTypes property. 48 | type: 'string', 49 | required: true, 50 | mustNotBeEmpty: true, 51 | immutable: true 52 | }, 53 | subject: { 54 | // The subject line of the notification 55 | type: 'string', 56 | required: true, 57 | mustNotBeEmpty: true, 58 | immutable: true 59 | }, 60 | message: { 61 | // The message body of the notification 62 | type: 'string', 63 | required: true, 64 | mustNotBeEmpty: true, 65 | immutable: true 66 | }, 67 | createdAt: { 68 | // When the notification was first created 69 | type: 'datetime', 70 | required: true, 71 | immutable: true 72 | }, 73 | firstReadAt: { 74 | // When the notification was first read 75 | type: 'datetime', 76 | immutableWhenSet: true 77 | }, 78 | siteName: { 79 | // The name of the white label site/brand for which the notification was generated 80 | type: 'string', 81 | mustNotBeEmpty: true, 82 | immutable: true 83 | }, 84 | actions: { 85 | // A list of actions that are available to the recipient of the notification 86 | type: 'array', 87 | immutable: true, 88 | arrayElementsValidator: { 89 | type: 'conditional', 90 | required: true, 91 | validationCandidates: [ 92 | { 93 | condition: function(doc, oldDoc, currentItemEntry, validationItemStack) { 94 | return typeof currentItemEntry.itemValue === 'object'; 95 | }, 96 | validator: { 97 | type: 'object', 98 | propertyValidators: { 99 | url: { 100 | // The URL of the action 101 | type: 'string', 102 | required: true, 103 | mustNotBeEmpty: true 104 | }, 105 | label: { 106 | // A plain text label for the action 107 | type: 'string', 108 | required: true, 109 | mustNotBeEmpty: true 110 | } 111 | } 112 | } 113 | }, 114 | { 115 | condition: function(doc, oldDoc, currentItemEntry, validationItemStack) { 116 | return typeof currentItemEntry.itemValue === 'string'; 117 | }, 118 | validator: { 119 | // The URL of the action 120 | type: 'string', 121 | mustNotBeEmpty: true 122 | } 123 | } 124 | ] 125 | } 126 | } 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /test/resources/skip-validation-when-value-unchanged-strict-doc-definitions.js: -------------------------------------------------------------------------------- 1 | function() { 2 | return { 3 | staticSkipValidationWhenValueUnchangedStrictDoc: { 4 | typeFilter: simpleTypeFilter, 5 | authorizedRoles: { write: 'write' }, 6 | propertyValidators: { 7 | integerProp: { 8 | type: 'integer', 9 | skipValidationWhenValueUnchangedStrict: true, 10 | minimumValue: 0 11 | }, 12 | floatProp: { 13 | type: 'float', 14 | skipValidationWhenValueUnchangedStrict: true, 15 | maximumValue: 0 16 | }, 17 | stringProp: { 18 | type: 'string', 19 | skipValidationWhenValueUnchangedStrict: true, 20 | minimumLength: 4 21 | }, 22 | booleanProp: { 23 | type: 'boolean', 24 | skipValidationWhenValueUnchangedStrict: true, 25 | customValidation: function(doc, oldDoc, currentItemEntry) { 26 | if (isValueNullOrUndefined(currentItemEntry.itemValue) || currentItemEntry.itemValue) { 27 | return [ ]; 28 | } else { 29 | return [ currentItemEntry.itemName + ' must be true' ]; 30 | } 31 | } 32 | }, 33 | dateProp: { 34 | type: 'date', 35 | skipValidationWhenValueUnchangedStrict: true, 36 | maximumValue: '1953-01-14' 37 | }, 38 | datetimeProp: { 39 | type: 'datetime', 40 | skipValidationWhenValueUnchangedStrict: true, 41 | minimumValue: '2018-06-13T23:33+00:00', 42 | maximumValue: '2018-06-13T23:33Z' 43 | }, 44 | timeProp: { 45 | type: 'time', 46 | skipValidationWhenValueUnchangedStrict: true, 47 | minimumValueExclusive: '17:45:53.911' 48 | }, 49 | timezoneProp: { 50 | type: 'timezone', 51 | skipValidationWhenValueUnchangedStrict: true, 52 | maximumValueExclusive: '+15:30' 53 | }, 54 | enumProp: { 55 | type: 'enum', 56 | predefinedValues: [ 1, 2, 3 ], 57 | skipValidationWhenValueUnchangedStrict: true 58 | }, 59 | uuidProp: { 60 | type: 'uuid', 61 | skipValidationWhenValueUnchangedStrict: true, 62 | maximumValueExclusive: '10000000-0000-0000-0000-000000000000' 63 | }, 64 | attachmentReferenceProp: { 65 | type: 'attachmentReference', 66 | skipValidationWhenValueUnchangedStrict: true, 67 | regexPattern: /^[a-z]+\.txt$/ 68 | }, 69 | arrayProp: { 70 | type: 'array', 71 | skipValidationWhenValueUnchangedStrict: true, 72 | maximumLength: 3 73 | }, 74 | objectProp: { 75 | type: 'object', 76 | skipValidationWhenValueUnchangedStrict: true, 77 | propertyValidators: { 78 | nestedProp: { 79 | type: 'string' 80 | } 81 | } 82 | }, 83 | hashtableProp: { 84 | type: 'hashtable', 85 | skipValidationWhenValueUnchangedStrict: true, 86 | hashtableValuesValidator: { 87 | type: 'integer' 88 | } 89 | } 90 | } 91 | }, 92 | dynamicSkipValidationWhenValueUnchangedStrictDoc: { 93 | typeFilter: simpleTypeFilter, 94 | authorizedRoles: { write: 'write' }, 95 | propertyValidators: { 96 | allowValidationSkip: { 97 | type: 'boolean' 98 | }, 99 | minimumUuidValue: { 100 | type: 'uuid' 101 | }, 102 | uuidProp: { 103 | type: 'uuid', 104 | skipValidationWhenValueUnchangedStrict: function(doc, oldDoc, value, oldValue) { 105 | return !isDocumentMissingOrDeleted(oldDoc) ? oldDoc.allowValidationSkip : doc.allowValidationSkip; 106 | }, 107 | minimumValue: function(doc, oldDoc, value, oldValue) { 108 | return doc.minimumUuidValue; 109 | } 110 | } 111 | } 112 | } 113 | }; 114 | } 115 | -------------------------------------------------------------------------------- /test/resources/required-doc-definitions.js: -------------------------------------------------------------------------------- 1 | function() { 2 | function customTypeFilter(doc, oldDoc, docType) { 3 | return doc._id === docType; 4 | } 5 | 6 | function isRequired(doc, oldDoc, value, oldValue) { 7 | return oldDoc ? oldDoc.dynamicPropsRequired : doc.dynamicPropsRequired; 8 | } 9 | 10 | return { 11 | staticDoc: { 12 | typeFilter: customTypeFilter, 13 | authorizedRoles: { write: 'write' }, 14 | propertyValidators: { 15 | stringProp: { 16 | type: 'string', 17 | required: true 18 | }, 19 | integerProp: { 20 | type: 'integer', 21 | required: true 22 | }, 23 | floatProp: { 24 | type: 'float', 25 | required: true 26 | }, 27 | booleanProp: { 28 | type: 'boolean', 29 | required: true 30 | }, 31 | datetimeProp: { 32 | type: 'datetime', 33 | required: true 34 | }, 35 | dateProp: { 36 | type: 'date', 37 | required: true 38 | }, 39 | enumProp: { 40 | type: 'enum', 41 | required: true, 42 | predefinedValues: [ 0, 1, 2 ] 43 | }, 44 | attachmentReferenceProp: { 45 | type: 'attachmentReference', 46 | required: true 47 | }, 48 | arrayProp: { 49 | type: 'array', 50 | required: true, 51 | arrayElementsValidator: { 52 | type: 'string', 53 | required: true 54 | } 55 | }, 56 | objectProp: { 57 | type: 'object', 58 | required: true, 59 | propertyValidators: { 60 | subProp: { 61 | type: 'integer', 62 | required: true 63 | } 64 | } 65 | }, 66 | hashtableProp: { 67 | type: 'hashtable', 68 | required: true, 69 | hashtableValuesValidator: { 70 | type: 'float', 71 | required: true 72 | } 73 | } 74 | } 75 | }, 76 | dynamicDoc: { 77 | typeFilter: customTypeFilter, 78 | authorizedRoles: { write: 'write' }, 79 | propertyValidators: { 80 | dynamicPropsRequired: { 81 | type: 'boolean' 82 | }, 83 | stringProp: { 84 | type: 'string', 85 | required: isRequired 86 | }, 87 | integerProp: { 88 | type: 'integer', 89 | required: isRequired 90 | }, 91 | floatProp: { 92 | type: 'float', 93 | required: isRequired 94 | }, 95 | booleanProp: { 96 | type: 'boolean', 97 | required: isRequired 98 | }, 99 | datetimeProp: { 100 | type: 'datetime', 101 | required: isRequired 102 | }, 103 | dateProp: { 104 | type: 'date', 105 | required: isRequired 106 | }, 107 | enumProp: { 108 | type: 'enum', 109 | required: isRequired, 110 | predefinedValues: [ 0, 1, 2 ] 111 | }, 112 | attachmentReferenceProp: { 113 | type: 'attachmentReference', 114 | required: isRequired 115 | }, 116 | arrayProp: { 117 | type: 'array', 118 | required: isRequired, 119 | arrayElementsValidator: { 120 | type: 'string', 121 | required: isRequired 122 | } 123 | }, 124 | objectProp: { 125 | type: 'object', 126 | required: isRequired, 127 | propertyValidators: { 128 | subProp: { 129 | type: 'integer', 130 | required: isRequired 131 | } 132 | } 133 | }, 134 | hashtableProp: { 135 | type: 'hashtable', 136 | required: isRequired, 137 | hashtableValuesValidator: { 138 | type: 'float', 139 | required: isRequired 140 | } 141 | } 142 | } 143 | } 144 | }; 145 | } 146 | -------------------------------------------------------------------------------- /test/resources/range-constraint-doc-definitions.js: -------------------------------------------------------------------------------- 1 | function() { 2 | function minimumNumericValue(doc, oldDoc, value, oldValue) { 3 | return doc.dynamicPropertyValuesAllowed ? value - 1 : value + 1; 4 | } 5 | 6 | function maximumNumericValue(doc, oldDoc, value, oldValue) { 7 | return doc.dynamicPropertyValuesAllowed ? value + 1 : value - 1; 8 | } 9 | 10 | function minimumDateValue(doc, oldDoc, value, oldValue) { 11 | return doc.dynamicPropertyValuesAllowed ? '0000-01-01' : '9999-12-31'; 12 | } 13 | 14 | function maximumDateValue(doc, oldDoc, value, oldValue) { 15 | return doc.dynamicPropertyValuesAllowed ? '9999-12-31' : '0000-01-01'; 16 | } 17 | 18 | return { 19 | inclusiveRangeDocType: { 20 | authorizedRoles: { write: 'write' }, 21 | typeFilter: function(doc) { 22 | return doc._id === 'inclusiveRangeDocType'; 23 | }, 24 | propertyValidators: { 25 | staticIntegerProp: { 26 | type: 'integer', 27 | minimumValue: -5, 28 | maximumValue: -5 29 | }, 30 | staticFloatProp: { 31 | type: 'float', 32 | minimumValue: 7.5, 33 | maximumValue: 7.5 34 | }, 35 | staticDatetimeProp: { 36 | type: 'datetime', 37 | minimumValue: '2016-07-19T19:24:38.920-07:00', 38 | maximumValue: '2016-07-19T19:24:38.920-07:00' 39 | }, 40 | staticDateProp: { 41 | type: 'date', 42 | minimumValue: '2016-07-19', 43 | maximumValue: '2016-07-19' 44 | }, 45 | dynamicPropertyValuesAllowed: { 46 | type: 'boolean' 47 | }, 48 | dynamicIntegerProp: { 49 | type: 'integer', 50 | minimumValue: minimumNumericValue, 51 | maximumValue: maximumNumericValue 52 | }, 53 | dynamicFloatProp: { 54 | type: 'float', 55 | minimumValue: minimumNumericValue, 56 | maximumValue: maximumNumericValue 57 | }, 58 | dynamicDatetimeProp: { 59 | type: 'datetime', 60 | minimumValue: minimumDateValue, 61 | maximumValue: maximumDateValue 62 | }, 63 | dynamicDateProp: { 64 | type: 'date', 65 | minimumValue: minimumDateValue, 66 | maximumValue: maximumDateValue 67 | } 68 | } 69 | }, 70 | exclusiveRangeDocType: { 71 | authorizedRoles: { write: 'write' }, 72 | typeFilter: function(doc) { 73 | return doc._id === 'exclusiveRangeDocType'; 74 | }, 75 | propertyValidators: { 76 | staticIntegerProp: { 77 | type: 'integer', 78 | minimumValueExclusive: 51, 79 | maximumValueExclusive: 53 80 | }, 81 | staticFloatProp: { 82 | type: 'float', 83 | minimumValueExclusive: -14.001, 84 | maximumValueExclusive: -13.999 85 | }, 86 | staticDatetimeProp: { 87 | type: 'datetime', 88 | minimumValueExclusive: '2016-07-19T19:24:38.919-07:00', 89 | maximumValueExclusive: '2016-07-19T19:24:38.921-07:00' 90 | }, 91 | staticDateProp: { 92 | type: 'date', 93 | minimumValueExclusive: '2016-07-18', 94 | maximumValueExclusive: '2016-07-20' 95 | }, 96 | dynamicPropertyValuesAllowed: { 97 | type: 'boolean' 98 | }, 99 | dynamicIntegerProp: { 100 | type: 'integer', 101 | minimumValueExclusive: minimumNumericValue, 102 | maximumValueExclusive: maximumNumericValue 103 | }, 104 | dynamicFloatProp: { 105 | type: 'float', 106 | minimumValueExclusive: minimumNumericValue, 107 | maximumValueExclusive: maximumNumericValue 108 | }, 109 | dynamicDatetimeProp: { 110 | type: 'datetime', 111 | minimumValueExclusive: minimumDateValue, 112 | maximumValueExclusive: maximumDateValue 113 | }, 114 | dynamicDateProp: { 115 | type: 'date', 116 | minimumValueExclusive: minimumDateValue, 117 | maximumValueExclusive: maximumDateValue 118 | } 119 | } 120 | } 121 | }; 122 | } 123 | -------------------------------------------------------------------------------- /test/resources/must-not-be-null-doc-definitions.js: -------------------------------------------------------------------------------- 1 | function() { 2 | function sharedTypeFilter(doc, oldDoc, docType) { 3 | return doc._id === docType; 4 | } 5 | 6 | function isRequired(doc, oldDoc, value, oldValue) { 7 | return oldDoc ? oldDoc.dynamicPropsRequired : doc.dynamicPropsRequired; 8 | } 9 | 10 | return { 11 | staticDoc: { 12 | typeFilter: sharedTypeFilter, 13 | authorizedRoles: { write: 'write' }, 14 | propertyValidators: { 15 | stringProp: { 16 | type: 'string', 17 | mustNotBeNull: true 18 | }, 19 | integerProp: { 20 | type: 'integer', 21 | mustNotBeNull: true 22 | }, 23 | floatProp: { 24 | type: 'float', 25 | mustNotBeNull: true 26 | }, 27 | booleanProp: { 28 | type: 'boolean', 29 | mustNotBeNull: true 30 | }, 31 | datetimeProp: { 32 | type: 'datetime', 33 | mustNotBeNull: true 34 | }, 35 | dateProp: { 36 | type: 'date', 37 | mustNotBeNull: true 38 | }, 39 | enumProp: { 40 | type: 'enum', 41 | mustNotBeNull: true, 42 | predefinedValues: [ 0, 1, 2 ] 43 | }, 44 | attachmentReferenceProp: { 45 | type: 'attachmentReference', 46 | mustNotBeNull: true 47 | }, 48 | arrayProp: { 49 | type: 'array', 50 | mustNotBeNull: true, 51 | arrayElementsValidator: { 52 | type: 'string', 53 | mustNotBeNull: true 54 | } 55 | }, 56 | objectProp: { 57 | type: 'object', 58 | mustNotBeNull: true, 59 | propertyValidators: { 60 | subProp: { 61 | type: 'integer', 62 | mustNotBeNull: true 63 | } 64 | } 65 | }, 66 | hashtableProp: { 67 | type: 'hashtable', 68 | mustNotBeNull: true, 69 | hashtableValuesValidator: { 70 | type: 'float', 71 | mustNotBeNull: true 72 | } 73 | } 74 | } 75 | }, 76 | dynamicDoc: { 77 | typeFilter: sharedTypeFilter, 78 | authorizedRoles: { write: 'write' }, 79 | propertyValidators: { 80 | dynamicPropsRequired: { 81 | type: 'boolean' 82 | }, 83 | stringProp: { 84 | type: 'string', 85 | mustNotBeNull: isRequired 86 | }, 87 | integerProp: { 88 | type: 'integer', 89 | mustNotBeNull: isRequired 90 | }, 91 | floatProp: { 92 | type: 'float', 93 | mustNotBeNull: isRequired 94 | }, 95 | booleanProp: { 96 | type: 'boolean', 97 | mustNotBeNull: isRequired 98 | }, 99 | datetimeProp: { 100 | type: 'datetime', 101 | mustNotBeNull: isRequired 102 | }, 103 | dateProp: { 104 | type: 'date', 105 | mustNotBeNull: isRequired 106 | }, 107 | enumProp: { 108 | type: 'enum', 109 | mustNotBeNull: isRequired, 110 | predefinedValues: [ 0, 1, 2 ] 111 | }, 112 | attachmentReferenceProp: { 113 | type: 'attachmentReference', 114 | mustNotBeNull: isRequired 115 | }, 116 | arrayProp: { 117 | type: 'array', 118 | mustNotBeNull: isRequired, 119 | arrayElementsValidator: { 120 | type: 'string', 121 | mustNotBeNull: isRequired 122 | } 123 | }, 124 | objectProp: { 125 | type: 'object', 126 | mustNotBeNull: isRequired, 127 | propertyValidators: { 128 | subProp: { 129 | type: 'integer', 130 | mustNotBeNull: isRequired 131 | } 132 | } 133 | }, 134 | hashtableProp: { 135 | type: 'hashtable', 136 | mustNotBeNull: isRequired, 137 | hashtableValuesValidator: { 138 | type: 'float', 139 | mustNotBeNull: isRequired 140 | } 141 | } 142 | } 143 | } 144 | }; 145 | } 146 | -------------------------------------------------------------------------------- /test/resources/must-not-be-missing-doc-definitions.js: -------------------------------------------------------------------------------- 1 | function() { 2 | function sharedTypeFilter(doc, oldDoc, docType) { 3 | return doc._id === docType; 4 | } 5 | 6 | function isRequired(doc, oldDoc, value, oldValue) { 7 | return oldDoc ? oldDoc.dynamicPropsRequired : doc.dynamicPropsRequired; 8 | } 9 | 10 | return { 11 | staticDoc: { 12 | typeFilter: sharedTypeFilter, 13 | authorizedRoles: { write: 'write' }, 14 | propertyValidators: { 15 | stringProp: { 16 | type: 'string', 17 | mustNotBeMissing: true 18 | }, 19 | integerProp: { 20 | type: 'integer', 21 | mustNotBeMissing: true 22 | }, 23 | floatProp: { 24 | type: 'float', 25 | mustNotBeMissing: true 26 | }, 27 | booleanProp: { 28 | type: 'boolean', 29 | mustNotBeMissing: true 30 | }, 31 | datetimeProp: { 32 | type: 'datetime', 33 | mustNotBeMissing: true 34 | }, 35 | dateProp: { 36 | type: 'date', 37 | mustNotBeMissing: true 38 | }, 39 | enumProp: { 40 | type: 'enum', 41 | mustNotBeMissing: true, 42 | predefinedValues: [ 0, 1, 2 ] 43 | }, 44 | attachmentReferenceProp: { 45 | type: 'attachmentReference', 46 | mustNotBeMissing: true 47 | }, 48 | arrayProp: { 49 | type: 'array', 50 | mustNotBeMissing: true, 51 | arrayElementsValidator: { 52 | type: 'string', 53 | mustNotBeMissing: true 54 | } 55 | }, 56 | objectProp: { 57 | type: 'object', 58 | mustNotBeMissing: true, 59 | propertyValidators: { 60 | subProp: { 61 | type: 'integer', 62 | mustNotBeMissing: true 63 | } 64 | } 65 | }, 66 | hashtableProp: { 67 | type: 'hashtable', 68 | mustNotBeMissing: true, 69 | hashtableValuesValidator: { 70 | type: 'float', 71 | mustNotBeMissing: true 72 | } 73 | } 74 | } 75 | }, 76 | dynamicDoc: { 77 | typeFilter: sharedTypeFilter, 78 | authorizedRoles: { write: 'write' }, 79 | propertyValidators: { 80 | dynamicPropsRequired: { 81 | type: 'boolean' 82 | }, 83 | stringProp: { 84 | type: 'string', 85 | mustNotBeMissing: isRequired 86 | }, 87 | integerProp: { 88 | type: 'integer', 89 | mustNotBeMissing: isRequired 90 | }, 91 | floatProp: { 92 | type: 'float', 93 | mustNotBeMissing: isRequired 94 | }, 95 | booleanProp: { 96 | type: 'boolean', 97 | mustNotBeMissing: isRequired 98 | }, 99 | datetimeProp: { 100 | type: 'datetime', 101 | mustNotBeMissing: isRequired 102 | }, 103 | dateProp: { 104 | type: 'date', 105 | mustNotBeMissing: isRequired 106 | }, 107 | enumProp: { 108 | type: 'enum', 109 | mustNotBeMissing: isRequired, 110 | predefinedValues: [ 0, 1, 2 ] 111 | }, 112 | attachmentReferenceProp: { 113 | type: 'attachmentReference', 114 | mustNotBeMissing: isRequired 115 | }, 116 | arrayProp: { 117 | type: 'array', 118 | mustNotBeMissing: isRequired, 119 | arrayElementsValidator: { 120 | type: 'string', 121 | mustNotBeMissing: isRequired 122 | } 123 | }, 124 | objectProp: { 125 | type: 'object', 126 | mustNotBeMissing: isRequired, 127 | propertyValidators: { 128 | subProp: { 129 | type: 'integer', 130 | mustNotBeMissing: isRequired 131 | } 132 | } 133 | }, 134 | hashtableProp: { 135 | type: 'hashtable', 136 | mustNotBeMissing: isRequired, 137 | hashtableValuesValidator: { 138 | type: 'float', 139 | mustNotBeMissing: isRequired 140 | } 141 | } 142 | } 143 | } 144 | }; 145 | } 146 | -------------------------------------------------------------------------------- /test/sample-notification-transport-processing-summary.spec.js: -------------------------------------------------------------------------------- 1 | const sampleSpecHelperMaker = require('./helpers/sample-spec-helper-maker'); 2 | const testFixtureMaker = require('../src/testing/test-fixture-maker'); 3 | 4 | describe('Sample notification transport processing summary doc definition', () => { 5 | const testFixture = 6 | testFixtureMaker.initFromValidationFunction('build/validation-functions/test-sample-validation-function.js'); 7 | const errorFormatter = testFixture.validationErrorFormatter; 8 | const sampleSpecHelper = sampleSpecHelperMaker.init(testFixture); 9 | 10 | afterEach(() => { 11 | testFixture.resetTestEnvironment(); 12 | }); 13 | 14 | function verifyProcessingSummaryWritten(doc, oldDoc) { 15 | testFixture.verifyDocumentAccepted(doc, oldDoc, sampleSpecHelper.getExpectedAuthorization('notification-transport-write')); 16 | } 17 | 18 | function verifyProcessingSummaryNotWritten(doc, oldDoc, expectedErrorMessages) { 19 | testFixture.verifyDocumentRejected( 20 | doc, 21 | oldDoc, 22 | 'notificationTransportProcessingSummary', 23 | expectedErrorMessages, 24 | sampleSpecHelper.getExpectedAuthorization('notification-transport-write')); 25 | } 26 | 27 | it('successfully creates a valid notification transport processing summary document', () => { 28 | const doc = { 29 | _id: 'biz.901.notification.ABC.processedTransport.XYZ', 30 | nonce: 'my-nonce', 31 | processedAt: '2016-06-04T21:02:19.013Z', 32 | processedBy: 'foobar', 33 | sentAt: '2016-06-04T21:02:55.013Z' 34 | }; 35 | 36 | verifyProcessingSummaryWritten(doc); 37 | }); 38 | 39 | it('cannot create a notification transport processing summary document when the properties are invalid', () => { 40 | const doc = { 41 | _id: 'biz.109.notification.ABC.processedTransport.XYZ', 42 | processedBy: [ ], 43 | sentAt: '2016-06-04T21:02:55.9999Z' // too many digits in the millisecond segment 44 | }; 45 | 46 | verifyProcessingSummaryNotWritten( 47 | doc, 48 | void 0, 49 | [ 50 | errorFormatter.requiredValueViolation('nonce'), 51 | errorFormatter.typeConstraintViolation('processedBy', 'string'), 52 | errorFormatter.requiredValueViolation('processedAt'), 53 | errorFormatter.datetimeFormatInvalid('sentAt') 54 | ]); 55 | }); 56 | 57 | it('successfully replaces a valid notification transport processing summary document', () => { 58 | const doc = { 59 | _id: 'biz.119.notification.ABC.processedTransport.XYZ', 60 | nonce: 'my-nonce', 61 | processedAt: '2016-06-04T21:02:19.013Z' 62 | }; 63 | const oldDoc = { 64 | _id: 'biz.119.notification.ABC.processedTransport.XYZ', 65 | nonce: 'my-nonce', 66 | processedBy: null, 67 | processedAt: '2016-06-04T21:02:19.013Z' 68 | }; 69 | 70 | verifyProcessingSummaryWritten(doc, oldDoc); 71 | }); 72 | 73 | it('cannot replace a notification transport processing summary document when the properties are invalid', () => { 74 | const doc = { 75 | _id: 'biz.275.notification.ABC.processedTransport.XYZ', 76 | nonce: 471, 77 | processedAt: '2016-06-04T09:27:07.514Z', 78 | sentAt: '' 79 | }; 80 | const oldDoc = { 81 | _id: 'biz.275.notification.ABC.processedTransport.XYZ', 82 | processedBy: 'foobar', 83 | processedAt: '2016-06-03T21:02:19.013Z', 84 | sentAt: '2016-07-15' 85 | }; 86 | 87 | verifyProcessingSummaryNotWritten( 88 | doc, 89 | oldDoc, 90 | [ 91 | errorFormatter.immutableItemViolation('nonce'), 92 | errorFormatter.typeConstraintViolation('nonce', 'string'), 93 | errorFormatter.immutableItemViolation('processedBy'), 94 | errorFormatter.immutableItemViolation('processedAt'), 95 | errorFormatter.datetimeFormatInvalid('sentAt'), 96 | errorFormatter.immutableItemViolation('sentAt') 97 | ]); 98 | }); 99 | 100 | it('cannot delete a notification transport processing summary document because it is marked as undeletable', () => { 101 | const doc = { _id: 'biz.317.notification.ABC.processedTransport.XYZ', _deleted: true }; 102 | const oldDoc = { 103 | _id: 'biz.317.notification.ABC.processedTransport.XYZ', 104 | processedBy: 'foobar', 105 | processedAt: '2016-06-04T21:02:19.013Z' 106 | }; 107 | 108 | verifyProcessingSummaryNotWritten(doc, oldDoc, errorFormatter.cannotDeleteDocViolation()); 109 | }); 110 | }); 111 | -------------------------------------------------------------------------------- /test/sample-payment-processor-settlement.spec.js: -------------------------------------------------------------------------------- 1 | const sampleSpecHelperMaker = require('./helpers/sample-spec-helper-maker'); 2 | const testFixtureMaker = require('../src/testing/test-fixture-maker'); 3 | 4 | describe('Sample payment processor settlement doc definition', () => { 5 | const testFixture = 6 | testFixtureMaker.initFromValidationFunction('build/validation-functions/test-sample-validation-function.js'); 7 | const errorFormatter = testFixture.validationErrorFormatter; 8 | const sampleSpecHelper = sampleSpecHelperMaker.init(testFixture); 9 | 10 | afterEach(() => { 11 | testFixture.resetTestEnvironment(); 12 | }); 13 | 14 | function verifySettlementWritten(businessId, doc, oldDoc) { 15 | testFixture.verifyDocumentAccepted(doc, oldDoc, sampleSpecHelper.getExpectedAuthorization('payment-settlement-write')); 16 | } 17 | 18 | function verifySettlementNotWritten(businessId, doc, oldDoc, expectedErrorMessages) { 19 | testFixture.verifyDocumentRejected( 20 | doc, 21 | oldDoc, 22 | 'paymentProcessorSettlement', 23 | expectedErrorMessages, sampleSpecHelper.getExpectedAuthorization('payment-settlement-write')); 24 | } 25 | 26 | it('can successfully create a valid payment processor settlement document', () => { 27 | const doc = { 28 | _id: 'biz.12345.paymentProcessor.XYZ.processedSettlement.some-settlement-id', 29 | _attachments: { }, 30 | businessId: 12345, 31 | transferId: 10, 32 | settlementId: 'some-settlement-id', 33 | processorId: 'XYZ', 34 | capturedAt: '2016-02-29', 35 | processedAt: '2016-03-03', 36 | amount: 300, 37 | currency: 'USD', 38 | processorMessage: 'my-processor-message' 39 | }; 40 | 41 | verifySettlementWritten(20, doc); 42 | }); 43 | 44 | it('cannot create a payment processor settlement document when the properties are invalid', () => { 45 | const doc = { 46 | _id: 'biz.12345.paymentProcessor.XYZ.processedSettlement.foo-bar', 47 | businessId: 54321, 48 | settlementId: 'not-foo-bar', 49 | processorId: 'ZYX', 50 | capturedAt: 'not-a-capturedAt', 51 | processedAt: 2123, 52 | currency: 'invalid-iso-4217-currency', 53 | processorMessage: 'my-processor-message' 54 | }; 55 | 56 | verifySettlementNotWritten( 57 | 'my-business', 58 | doc, 59 | void 0, 60 | [ 61 | errorFormatter.documentIdRegexPatternViolation(/^biz\.54321\.paymentProcessor\.ZYX\.processedSettlement\.not-foo-bar$/), 62 | errorFormatter.requiredValueViolation('transferId'), 63 | errorFormatter.datetimeFormatInvalid('capturedAt'), 64 | errorFormatter.typeConstraintViolation('processedAt', 'datetime'), 65 | errorFormatter.requiredValueViolation('amount'), 66 | errorFormatter.regexPatternItemViolation('currency', /^[A-Z]{3}$/) 67 | ]); 68 | }); 69 | 70 | it('cannot replace a payment processor settlement document because it is immutable', () => { 71 | const doc = { 72 | _id: 'biz.12345.paymentProcessor.XYZ.processedSettlement.foo-bar', 73 | businessId: 12345, 74 | transferId: -5, 75 | settlementId: 'foo-bar', 76 | processorId: 'XYZ', 77 | capturedAt: '2016-02-29', 78 | processedAt: '2016-03-03', 79 | amount: 300, 80 | currency: 'USD', 81 | processorMessage: 'my-other-processor-message', 82 | whatPropIsThis: 'some value' 83 | }; 84 | const oldDoc = { 85 | _id: 'biz.12345.paymentProcessor.XYZ.processedSettlement.foo-bar', 86 | businessId: 12345, 87 | transferId: 10, 88 | settlementId: 'foo-bar', 89 | processorId: 'XYZ', 90 | capturedAt: '2016-02-29', 91 | processedAt: '2016-03-03', 92 | amount: 300, 93 | currency: 'USD', 94 | processorMessage: 'my-processor-message' 95 | }; 96 | 97 | verifySettlementNotWritten( 98 | 12345, 99 | doc, 100 | oldDoc, 101 | [ 102 | errorFormatter.immutableDocViolation(), 103 | errorFormatter.immutableItemViolation('transferId'), 104 | errorFormatter.minimumValueViolation('transferId', 1), 105 | errorFormatter.unsupportedProperty('whatPropIsThis') 106 | ]); 107 | }); 108 | 109 | it('cannot delete a valid payment processing attempt document because it is immutable', () => { 110 | const doc = { _id: 'biz.12345.paymentProcessor.XYZ.processedSettlement.foo-bar', _deleted: true }; 111 | const oldDoc = { _id: 'biz.12345.paymentProcessor.XYZ.processedSettlement.foo-bar', businessId: 12345 }; 112 | 113 | verifySettlementNotWritten(12345, doc, oldDoc, [ errorFormatter.immutableDocViolation() ]); 114 | }); 115 | }); 116 | --------------------------------------------------------------------------------