Skip to content

Commit

Permalink
Issue #25: Regular expression pattern constraint for attachment refer…
Browse files Browse the repository at this point in the history
…ences

When included in a document definition, the `attachmentReference` validation type's `regexPattern` constraint verifies that the value conforms to the specified regular expression pattern. It overrides the value of the document-wide `attachmentConstraints.filenameRegexPattern` constraint for that attachment reference.
  • Loading branch information
OldSneerJaw committed May 30, 2018
1 parent 09d8b4f commit 7e1d89c
Show file tree
Hide file tree
Showing 11 changed files with 132 additions and 27 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
10 changes: 10 additions & 0 deletions src/testing/validation-error-formatter.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
6 changes: 6 additions & 0 deletions src/testing/validation-error-formatter.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
Expand Down
7 changes: 6 additions & 1 deletion src/validation/document-definitions-validator.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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))?)?$/',
Expand Down
3 changes: 2 additions & 1 deletion src/validation/property-validator-schema.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()),
Expand Down
12 changes: 11 additions & 1 deletion templates/validation-function/attachments-validation-module.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
}
}
}

Expand Down
6 changes: 3 additions & 3 deletions test/attachment-constraints.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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: {
Expand Down
92 changes: 74 additions & 18 deletions test/attachment-reference.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
};

Expand All @@ -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' ]));
});
});
Expand All @@ -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' ]
};
Expand All @@ -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));
});
});
Expand All @@ -72,7 +72,7 @@ describe('Attachment reference validation type', () => {
_attachments: {
'foo.bar': { content_type: 'text/plain' }
},
type: 'attachmentsDoc',
type: 'attachmentReferencesDoc',
staticContentTypesValidationProp: 'foo.bar'
};

Expand All @@ -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' ]));
});
});
Expand All @@ -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' ]
};
Expand All @@ -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));
});
});
Expand All @@ -139,7 +139,7 @@ describe('Attachment reference validation type', () => {
_attachments: {
'foo.bar': { length: 200 }
},
type: 'attachmentsDoc',
type: 'attachmentReferencesDoc',
staticMaxSizeValidationProp: 'foo.bar'
};

Expand All @@ -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));
});
});
Expand All @@ -170,7 +170,7 @@ describe('Attachment reference validation type', () => {
_attachments: {
'foo.bar': { length: 150 }
},
type: 'attachmentsDoc',
type: 'attachmentReferencesDoc',
dynamicMaxSizeValidationProp: 'foo.bar',
dynamicMaxSize: 150
};
Expand All @@ -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)));
});
});
});
});
6 changes: 4 additions & 2 deletions test/resources/attachment-constraints-doc-definitions.js
Original file line number Diff line number Diff line change
Expand Up @@ -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]+$/
}
}
},
Expand Down
15 changes: 14 additions & 1 deletion test/resources/attachment-reference-doc-definitions.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
attachmentsDoc: {
attachmentReferencesDoc: {
typeFilter: simpleTypeFilter,
authorizedRoles: { write: 'write' },
allowAttachments: true,
Expand Down Expand Up @@ -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;
}
}
}
}
Expand Down

0 comments on commit 7e1d89c

Please sign in to comment.