diff --git a/CHANGELOG.md b/CHANGELOG.md index ce855ea..f83670a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ This project adheres to [Semantic Versioning](http://semver.org/). All notable c ## [Unreleased] ### Added - [#24](https://github.com/OldSneerJaw/couchster/issues/24): Attachment filename regular expression constraint +- [#25](https://github.com/OldSneerJaw/couchster/issues/25): Attachment reference regular expression constraint ## [1.0.0] - 2018-05-10 ### Added diff --git a/README.md b/README.md index 89602e8..737ecf0 100644 --- a/README.md +++ b/README.md @@ -341,6 +341,7 @@ Validation for simple data types (e.g. strings, booleans, integers, floating poi * `attachmentReference`: The value is the name of one of the document's file attachments. Note that, because the addition of an attachment is often a separate CouchDB API operation from the creation/replacement of the associated document, this validation type is only applied if the attachment is actually present in the document. However, since the validation function is run twice in such situations (i.e. once when the _document_ is created/replaced and once when the _attachment_ is created/replaced), the validation will be performed eventually. The top-level `allowAttachments` document constraint should be `true` so that documents of this type can actually store attachments. Additional parameters: * `supportedExtensions`: An array of case-insensitive file extensions that are allowed for the attachment's filename (e.g. "txt", "jpg", "pdf"). Takes precedence over the document-wide `supportedExtensions` constraint for the referenced attachment. No restriction by default. * `supportedContentTypes`: An array of content/MIME types that are allowed for the attachment's contents (e.g. "image/png", "text/html", "application/xml"). Takes precedence over the document-wide `supportedContentTypes` constraint for the referenced attachment. No restriction by default. + * `regexPattern`: A regular expression pattern that must be satisfied by the value. Takes precedence over the document-wide `attachmentConstraints.filenameRegexPattern` constraint for the referenced attachment. No restriction by default. ##### Complex type validation diff --git a/src/testing/validation-error-formatter.js b/src/testing/validation-error-formatter.js index 07f9355..55e3226 100644 --- a/src/testing/validation-error-formatter.js +++ b/src/testing/validation-error-formatter.js @@ -15,6 +15,16 @@ exports.allowAttachmentsViolation = () => 'document type does not support attach exports.attachmentFilenameRegexPatternViolation = (attachmentName, expectedRegex) => `attachment "${attachmentName}" must conform to expected pattern ${expectedRegex}`; +/** + * Formats a message for the error that occurs when an attachment reference's value does not match the expected regular + * expression pattern. + * + * @param {string} itemPath The full path of the property or element in which the error occurs (e.g. "objectProp.attachmentRefProp") + * @param {RegExp} expectedRegex The regular expression pattern to which the value must conform + */ +exports.attachmentReferenceRegexPatternViolation = + (itemPath, expectedRegex) => `attachment reference "${itemPath}" must conform to expected pattern ${expectedRegex}`; + /** * Formats a message for the error that occurs when there is an attempt to delete a document that cannot be deleted. */ diff --git a/src/testing/validation-error-formatter.spec.js b/src/testing/validation-error-formatter.spec.js index 5bffff6..f425db9 100644 --- a/src/testing/validation-error-formatter.spec.js +++ b/src/testing/validation-error-formatter.spec.js @@ -66,6 +66,12 @@ describe('Validation error formatter', () => { describe('at the property/element level', () => { const fakeItemPath = 'my.fake[item]'; + it('produces attachment reference regex pattern violation messages', () => { + const expectedRegex = /^[A-Za-z][A-Za-z0-9]*\.[a-z]{3}$/; + expect(errorFormatter.attachmentReferenceRegexPatternViolation(fakeItemPath, expectedRegex)) + .to.equal(`attachment reference "${fakeItemPath}" must conform to expected pattern ${expectedRegex}`); + }); + it('produces invalid date format messages', () => { expect(errorFormatter.dateFormatInvalid(fakeItemPath)) .to.equal(`item "${fakeItemPath}" must be an ECMAScript simplified ISO 8601 date string with no time or time zone components`); diff --git a/src/validation/document-definitions-validator.spec.js b/src/validation/document-definitions-validator.spec.js index 052f9a4..71c652f 100644 --- a/src/validation/document-definitions-validator.spec.js +++ b/src/validation/document-definitions-validator.spec.js @@ -20,7 +20,7 @@ describe('Document definitions validator:', () => { allowUnknownProperties: 1, // Must be a boolean immutable: true, cannotDelete: true, // Must not be defined if "immutable" is also defined - attachmentConstraints: (a, b) => b, // "allowAttachments" must also be defined, + attachmentConstraints: (a, b) => b, // "allowAttachments" must also be defined customActions: { onTypeIdentificationSucceeded: (a, b, c, d, e, extraParam) => extraParam, // Too many parameters onAuthorizationSucceeded: 5, // Must be a function @@ -111,6 +111,10 @@ describe('Document definitions validator:', () => { mustEqual: true, // Must be an integer or string mustEqualStrict: 3 // Must not be defined in conjunction with "mustEqual" }, + attachmentReferenceProperty: { + type: 'attachmentReference', + regexPattern: '^abc$' // Must be a RegExp object + }, hashtableProperty: { type: 'hashtable', minimumSize: 2, @@ -240,6 +244,7 @@ describe('Document definitions validator:', () => { 'myDoc1.propertyValidators.nestedObject.propertyValidators.enumProperty.mustEqualStrict: \"mustEqualStrict\" conflict with forbidden peer \"mustEqual\"', 'myDoc1.propertyValidators.nestedObject.propertyValidators.enumProperty.mustEqual: \"mustEqual\" must be a string', 'myDoc1.propertyValidators.nestedObject.propertyValidators.enumProperty.mustEqual: \"mustEqual\" must be a number', + 'myDoc1.propertyValidators.nestedObject.propertyValidators.attachmentReferenceProperty.regexPattern: \"regexPattern\" must be an object', 'myDoc1.propertyValidators.nestedObject.propertyValidators.hashtableProperty.maximumSize: \"maximumSize\" must be larger than or equal to 2', 'myDoc1.propertyValidators.nestedObject.propertyValidators.hashtableProperty.hashtableKeysValidator.regexPattern: "regexPattern" must be an object', 'myDoc1.propertyValidators.nestedObject.propertyValidators.hashtableProperty.hashtableValuesValidator.minimumValue: \"minimumValue\" with value \"Mon, 25 Dec 1995 13:30:00 +0430\" fails to match the required pattern: /^([+-]\\d{6}|\\d{4})(-(0[1-9]|1[0-2])(-(0[1-9]|[12]\\d|3[01]))?)?(T((([01]\\d|2[0-3])(:[0-5]\\d)(:[0-5]\\d(\\.\\d{1,3})?)?)|(24:00(:00(\\.0{1,3})?)?))(Z|([+-])([01]\\d|2[0-3]):([0-5]\\d))?)?$/', diff --git a/src/validation/property-validator-schema.js b/src/validation/property-validator-schema.js index 52b9cb2..9486f48 100644 --- a/src/validation/property-validator-schema.js +++ b/src/validation/property-validator-schema.js @@ -168,7 +168,8 @@ function typeSpecificConstraintSchemas() { attachmentReference: { maximumSize: dynamicConstraintSchema(integerSchema.min(1).max(20971520)), supportedExtensions: dynamicConstraintSchema(joi.array().min(1).items(joi.string())), - supportedContentTypes: dynamicConstraintSchema(joi.array().min(1).items(joi.string().min(1))) + supportedContentTypes: dynamicConstraintSchema(joi.array().min(1).items(joi.string().min(1))), + regexPattern: dynamicConstraintSchema(regexSchema) }, array: { mustNotBeEmpty: dynamicConstraintSchema(joi.boolean()), diff --git a/templates/validation-function/attachments-validation-module.js b/templates/validation-function/attachments-validation-module.js index 301330e..2f99ca5 100644 --- a/templates/validation-function/attachments-validation-module.js +++ b/templates/validation-function/attachments-validation-module.js @@ -24,6 +24,11 @@ function attachmentsValidationModule(utils, buildItemPath, resolveItemConstraint } } + var regexPattern = resolveItemConstraint(validator.regexPattern); + if (regexPattern && !regexPattern.test(itemValue)) { + validationErrors.push('attachment reference "' + buildItemPath(itemStack) + '" must conform to expected pattern ' + regexPattern); + } + // Because the addition of an attachment is typically a separate operation from the creation/update of the associated document, we // can't guarantee that the attachment is present when the attachment reference property is created/updated for it, so only // validate it if it's present. The good news is that, because adding an attachment is a two part operation (create/update the @@ -102,7 +107,12 @@ function attachmentsValidationModule(utils, buildItemPath, resolveItemConstraint } if (filenameRegexPattern && !filenameRegexPattern.test(attachmentName)) { - validationErrors.push('attachment "' + attachmentName + '" must conform to expected pattern ' + filenameRegexPattern); + // If this attachment is owned by an attachment reference property, that property's regular expression pattern + // constraint (if any) takes precedence + if (utils.isValueNullOrUndefined(attachmentRefValidator) || + utils.isValueNullOrUndefined(attachmentRefValidator.supportedContentTypes)) { + validationErrors.push('attachment "' + attachmentName + '" must conform to expected pattern ' + filenameRegexPattern); + } } } diff --git a/test/attachment-constraints.spec.js b/test/attachment-constraints.spec.js index 7289db7..286593f 100644 --- a/test/attachment-constraints.spec.js +++ b/test/attachment-constraints.spec.js @@ -23,13 +23,13 @@ describe('File attachment constraints:', () => { length: 15, content_type: 'text/html' }, - 'baz.foo': { + 'other.foo': { length: 5, content_type: 'text/bar' } }, type: 'staticRegularAttachmentsDoc', - attachmentRefProp: 'baz.foo' // The attachmentReference overrides the document's supported extensions and content types + attachmentRefProp: 'other.foo' // The attachmentReference overrides supported extensions, content types and the regex pattern }; testFixture.verifyDocumentCreated(doc); @@ -56,7 +56,7 @@ describe('File attachment constraints:', () => { }); describe('maximum attachment count constraint', () => { - it('should block creation of a document whose attachments exceed the limit', () => { + it('should block creation of a document whose number of attachments exceed the limit', () => { const doc = { _id: 'myDoc', _attachments: { diff --git a/test/attachment-reference.spec.js b/test/attachment-reference.spec.js index f85e323..2f0855d 100644 --- a/test/attachment-reference.spec.js +++ b/test/attachment-reference.spec.js @@ -14,7 +14,7 @@ describe('Attachment reference validation type', () => { it('allows an attachment reference with a valid file extension', () => { const doc = { _id: 'foo', - type: 'attachmentsDoc', + type: 'attachmentReferencesDoc', staticExtensionsValidationProp: 'bar.htm' }; @@ -24,13 +24,13 @@ describe('Attachment reference validation type', () => { it('rejects an attachment reference with an invalid file extension', () => { const doc = { _id: 'foo', - type: 'attachmentsDoc', + type: 'attachmentReferencesDoc', staticExtensionsValidationProp: 'bar.pdf' }; testFixture.verifyDocumentNotCreated( doc, - 'attachmentsDoc', + 'attachmentReferencesDoc', errorFormatter.supportedExtensionsAttachmentReferenceViolation('staticExtensionsValidationProp', [ 'html', 'htm' ])); }); }); @@ -39,7 +39,7 @@ describe('Attachment reference validation type', () => { it('allows an attachment reference with a valid file extension', () => { const doc = { _id: 'foo', - type: 'attachmentsDoc', + type: 'attachmentReferencesDoc', dynamicExtensionsValidationProp: 'bar.txt', dynamicSupportedExtensions: [ 'txt' ] }; @@ -51,14 +51,14 @@ describe('Attachment reference validation type', () => { const expectedSupportedExtensions = [ 'png', 'jpg', 'gif' ]; const doc = { _id: 'foo', - type: 'attachmentsDoc', + type: 'attachmentReferencesDoc', dynamicExtensionsValidationProp: 'bar.ico', dynamicSupportedExtensions: expectedSupportedExtensions }; testFixture.verifyDocumentNotCreated( doc, - 'attachmentsDoc', + 'attachmentReferencesDoc', errorFormatter.supportedExtensionsAttachmentReferenceViolation('dynamicExtensionsValidationProp', expectedSupportedExtensions)); }); }); @@ -72,7 +72,7 @@ describe('Attachment reference validation type', () => { _attachments: { 'foo.bar': { content_type: 'text/plain' } }, - type: 'attachmentsDoc', + type: 'attachmentReferencesDoc', staticContentTypesValidationProp: 'foo.bar' }; @@ -85,13 +85,13 @@ describe('Attachment reference validation type', () => { _attachments: { 'foo.bar': { content_type: 'application/pdf' } }, - type: 'attachmentsDoc', + type: 'attachmentReferencesDoc', staticContentTypesValidationProp: 'foo.bar' }; testFixture.verifyDocumentNotCreated( doc, - 'attachmentsDoc', + 'attachmentReferencesDoc', errorFormatter.supportedContentTypesAttachmentReferenceViolation('staticContentTypesValidationProp', [ 'text/plain', 'text/html' ])); }); }); @@ -103,7 +103,7 @@ describe('Attachment reference validation type', () => { _attachments: { 'foo.bar': { content_type: 'text/plain' } }, - type: 'attachmentsDoc', + type: 'attachmentReferencesDoc', dynamicContentTypesValidationProp: 'foo.bar', dynamicSupportedContentTypes: [ 'text/plain' ] }; @@ -118,14 +118,14 @@ describe('Attachment reference validation type', () => { _attachments: { 'foo.bar': { content_type: 'application/pdf' } }, - type: 'attachmentsDoc', + type: 'attachmentReferencesDoc', dynamicContentTypesValidationProp: 'foo.bar', dynamicSupportedContentTypes: expectedSupportedContentTypes }; testFixture.verifyDocumentNotCreated( doc, - 'attachmentsDoc', + 'attachmentReferencesDoc', errorFormatter.supportedContentTypesAttachmentReferenceViolation('dynamicContentTypesValidationProp', expectedSupportedContentTypes)); }); }); @@ -139,7 +139,7 @@ describe('Attachment reference validation type', () => { _attachments: { 'foo.bar': { length: 200 } }, - type: 'attachmentsDoc', + type: 'attachmentReferencesDoc', staticMaxSizeValidationProp: 'foo.bar' }; @@ -152,13 +152,13 @@ describe('Attachment reference validation type', () => { _attachments: { 'foo.bar': { length: 201 } }, - type: 'attachmentsDoc', + type: 'attachmentReferencesDoc', staticMaxSizeValidationProp: 'foo.bar' }; testFixture.verifyDocumentNotCreated( doc, - 'attachmentsDoc', + 'attachmentReferencesDoc', errorFormatter.maximumSizeAttachmentViolation('staticMaxSizeValidationProp', 200)); }); }); @@ -170,7 +170,7 @@ describe('Attachment reference validation type', () => { _attachments: { 'foo.bar': { length: 150 } }, - type: 'attachmentsDoc', + type: 'attachmentReferencesDoc', dynamicMaxSizeValidationProp: 'foo.bar', dynamicMaxSize: 150 }; @@ -184,16 +184,72 @@ describe('Attachment reference validation type', () => { _attachments: { 'foo.bar': { length: 151 } }, - type: 'attachmentsDoc', + type: 'attachmentReferencesDoc', dynamicMaxSizeValidationProp: 'foo.bar', dynamicMaxSize: 150 }; testFixture.verifyDocumentNotCreated( doc, - 'attachmentsDoc', + 'attachmentReferencesDoc', errorFormatter.maximumSizeAttachmentViolation('dynamicMaxSizeValidationProp', 150)); }); }); }); + + describe('regular expression pattern constraint', () => { + describe('with static validation', () => { + it('allows an attachment whose name matches the pattern', () => { + const doc = { + _id: 'my-doc', + type: 'attachmentReferencesDoc', + staticRegexPatternValidationProp: 'a03.hc' + }; + + testFixture.verifyDocumentCreated(doc); + }); + + it('rejects an attachment whose name violates the pattern', () => { + const doc = { + _id: 'my-doc', + type: 'attachmentReferencesDoc', + staticRegexPatternValidationProp: '123ABC' + }; + + testFixture.verifyDocumentNotCreated( + doc, + 'attachmentReferencesDoc', + errorFormatter.attachmentReferenceRegexPatternViolation('staticRegexPatternValidationProp', /^[a-z][a-z0-9]*\.[a-z]+$/)); + }); + }); + + describe('with dynamic validation', () => { + const expectedRegexString = '^\\d+\\.[a-z]+$'; + + it('allows an attachment whose name matches the pattern', () => { + const doc = { + _id: 'my-doc', + type: 'attachmentReferencesDoc', + dynamicRegexPattern: expectedRegexString, + dynamicRegexPatternValidationProp: '66134.txt' + }; + + testFixture.verifyDocumentCreated(doc); + }); + + it('rejects an attachment whose name violates the pattern', () => { + const doc = { + _id: 'my-doc', + type: 'attachmentReferencesDoc', + dynamicRegexPattern: expectedRegexString, + dynamicRegexPatternValidationProp: 'd.foo' + }; + + testFixture.verifyDocumentNotCreated( + doc, + 'attachmentReferencesDoc', + errorFormatter.attachmentReferenceRegexPatternViolation('dynamicRegexPatternValidationProp', new RegExp(expectedRegexString))); + }); + }); + }); }); diff --git a/test/resources/attachment-constraints-doc-definitions.js b/test/resources/attachment-constraints-doc-definitions.js index f108358..6841e9a 100644 --- a/test/resources/attachment-constraints-doc-definitions.js +++ b/test/resources/attachment-constraints-doc-definitions.js @@ -6,14 +6,16 @@ attachmentConstraints: { maximumAttachmentCount: 3, supportedExtensions: [ 'html', 'jpg', 'pdf', 'txt', 'xml' ], - supportedContentTypes: [ 'text/html', 'image/jpeg', 'application/pdf', 'text/plain', 'application/xml' ] + supportedContentTypes: [ 'text/html', 'image/jpeg', 'application/pdf', 'text/plain', 'application/xml' ], + filenameRegexPattern: /^(foo|ba[rz]|qux)\.[a-z]+$/ }, propertyValidators: { attachmentRefProp: { type: 'attachmentReference', maximumSize: 40, supportedExtensions: [ 'foo', 'html', 'jpg', 'pdf', 'txt', 'xml' ], - supportedContentTypes: [ 'text/bar', 'text/html', 'image/jpeg', 'application/pdf', 'text/plain', 'application/xml' ] + supportedContentTypes: [ 'text/bar', 'text/html', 'image/jpeg', 'application/pdf', 'text/plain', 'application/xml' ], + regexPattern: /^[a-z]+\.[a-z]+$/ } } }, diff --git a/test/resources/attachment-reference-doc-definitions.js b/test/resources/attachment-reference-doc-definitions.js index 5a13fbf..75cb202 100644 --- a/test/resources/attachment-reference-doc-definitions.js +++ b/test/resources/attachment-reference-doc-definitions.js @@ -1,5 +1,5 @@ { - attachmentsDoc: { + attachmentReferencesDoc: { typeFilter: simpleTypeFilter, authorizedRoles: { write: 'write' }, allowAttachments: true, @@ -42,6 +42,19 @@ maximumSize: function(doc, oldDoc, value, oldValue) { return doc.dynamicMaxSize; } + }, + staticRegexPatternValidationProp: { + type: 'attachmentReference', + regexPattern: /^[a-z][a-z0-9]*\.[a-z]+$/ + }, + dynamicRegexPattern: { + type: 'string' + }, + dynamicRegexPatternValidationProp: { + type: 'attachmentReference', + regexPattern: function(doc, oldDoc, value, oldValue) { + return doc.dynamicRegexPattern ? new RegExp(doc.dynamicRegexPattern) : null; + } } } }