From 3d10a01577ca871cbf3fb1c4ea5f39904a27ca33 Mon Sep 17 00:00:00 2001 From: Konstantin Shuplenkov Date: Tue, 3 Mar 2020 19:01:19 +0300 Subject: [PATCH] feat: store document ID as a part of the document * Store Document ID as a part of the document --- lib/document/Document.js | 14 ++- lib/document/DocumentFactory.js | 18 +++- lib/document/RawDocumentInterface.js | 1 + lib/document/generateDocumentId.js | 25 +++++ lib/document/validateDocumentFactory.js | 21 +++++ lib/errors/InvalidDocumentIdError.js | 23 +++++ lib/test/fixtures/getDpnsDocumentFixture.js | 9 +- schema/base/document.json | 7 ++ .../document/DocumentFacade.spec.js | 2 +- .../data/executeDataTriggersFactory.spec.js | 2 +- .../document/validateDocumentFactory.spec.js | 91 +++++++++++++++++++ test/unit/document/Document.spec.js | 38 ++++---- test/unit/document/DocumentFactory.spec.js | 7 +- 13 files changed, 218 insertions(+), 40 deletions(-) create mode 100644 lib/document/generateDocumentId.js create mode 100644 lib/errors/InvalidDocumentIdError.js diff --git a/lib/document/Document.js b/lib/document/Document.js index 4cc4d98fa..0fc9ca575 100644 --- a/lib/document/Document.js +++ b/lib/document/Document.js @@ -1,4 +1,3 @@ -const bs58 = require('bs58'); const lodashGet = require('lodash.get'); const lodashSet = require('lodash.set'); @@ -14,9 +13,13 @@ class Document { constructor(rawDocument) { const data = { ...rawDocument }; - this.id = undefined; this.action = undefined; + if (Object.prototype.hasOwnProperty.call(rawDocument, '$id')) { + this.id = rawDocument.$id; + delete data.$id; + } + if (Object.prototype.hasOwnProperty.call(rawDocument, '$type')) { this.type = rawDocument.$type; delete data.$type; @@ -51,12 +54,6 @@ class Document { * @return {string} */ getId() { - if (!this.id) { - this.id = bs58.encode( - hash(this.contractId + this.ownerId + this.type + this.entropy), - ); - } - return this.id; } @@ -191,6 +188,7 @@ class Document { */ toJSON() { return { + $id: this.getId(), $type: this.getType(), $contractId: this.getDataContractId(), $ownerId: this.getOwnerId(), diff --git a/lib/document/DocumentFactory.js b/lib/document/DocumentFactory.js index 81ef95979..d2b6c3e41 100644 --- a/lib/document/DocumentFactory.js +++ b/lib/document/DocumentFactory.js @@ -9,6 +9,8 @@ const InvalidDocumentError = require('./errors/InvalidDocumentError'); const InvalidDocumentTypeError = require('../errors/InvalidDocumentTypeError'); const SerializedObjectParsingError = require('../errors/SerializedObjectParsingError'); +const generateDocumentId = require('./generateDocumentId'); + class DocumentFactory { /** * @param {validateDocument} validateDocument @@ -33,11 +35,22 @@ class DocumentFactory { throw new InvalidDocumentTypeError(type, dataContract); } + const documentEntropy = entropy.generate(); + const contractId = dataContract.getId(); + + const id = generateDocumentId( + contractId, + ownerId, + type, + documentEntropy, + ); + const rawDocument = { + $id: id, $type: type, - $contractId: dataContract.getId(), + $contractId: contractId, $ownerId: ownerId, - $entropy: entropy.generate(), + $entropy: documentEntropy, $rev: Document.DEFAULTS.REVISION, ...data, }; @@ -49,7 +62,6 @@ class DocumentFactory { return document; } - /** * Create Document from plain object * diff --git a/lib/document/RawDocumentInterface.js b/lib/document/RawDocumentInterface.js index 74ec030ca..10562618e 100644 --- a/lib/document/RawDocumentInterface.js +++ b/lib/document/RawDocumentInterface.js @@ -1,5 +1,6 @@ /** * @typedef {Object} RawDocument + * @property {string} $id * @property {string} $type * @property {string} $contractId * @property {string} $ownerId diff --git a/lib/document/generateDocumentId.js b/lib/document/generateDocumentId.js new file mode 100644 index 000000000..7f8fc3adc --- /dev/null +++ b/lib/document/generateDocumentId.js @@ -0,0 +1,25 @@ +const bs58 = require('bs58'); + +const hash = require('../util/hash'); + +/** + * Generates document ID + * + * @param {string} contractId + * @param {string} ownerId + * @param {string} type + * @param {string} entropy + * @returns {string} + */ +function generateDocumentId(contractId, ownerId, type, entropy) { + return bs58.encode( + hash(Buffer.concat([ + bs58.decode(contractId), + bs58.decode(ownerId), + Buffer.from(type), + bs58.decode(entropy), + ])), + ); +} + +module.exports = generateDocumentId; diff --git a/lib/document/validateDocumentFactory.js b/lib/document/validateDocumentFactory.js index 40a71a360..6cf4e2607 100644 --- a/lib/document/validateDocumentFactory.js +++ b/lib/document/validateDocumentFactory.js @@ -4,6 +4,7 @@ const documentBaseSchema = require('../../schema/base/document'); const ValidationResult = require('../validation/ValidationResult'); +const InvalidDocumentIdError = require('../errors/InvalidDocumentIdError'); const InvalidDocumentTypeError = require('../errors/InvalidDocumentTypeError'); const MissingDocumentTypeError = require('../errors/MissingDocumentTypeError'); const InvalidDocumentEntropyError = require('../errors/InvalidDocumentEntropyError'); @@ -11,6 +12,9 @@ const MismatchDocumentContractIdAndDataContractError = require('../errors/Mismat const entropy = require('../util/entropy'); +const generateDocumentId = require('./generateDocumentId'); + + /** * @param {JsonSchemaValidator} validator * @param {enrichDataContractWithBaseDocument} enrichDataContractWithBaseDocument @@ -106,6 +110,23 @@ module.exports = function validateDocumentFactory( ); } + if (!result.isValid()) { + return result; + } + + const documentId = generateDocumentId( + rawDocument.$contractId, + rawDocument.$ownerId, + rawDocument.$type, + rawDocument.$entropy, + ); + + if (rawDocument.$id !== documentId) { + result.addError( + new InvalidDocumentIdError(rawDocument), + ); + } + return result; } diff --git a/lib/errors/InvalidDocumentIdError.js b/lib/errors/InvalidDocumentIdError.js new file mode 100644 index 000000000..8c0fd91de --- /dev/null +++ b/lib/errors/InvalidDocumentIdError.js @@ -0,0 +1,23 @@ +const ConsensusError = require('./ConsensusError'); + +class InvalidDocumentIdError extends ConsensusError { + /** + * @param {RawDocument} rawDocument + */ + constructor(rawDocument) { + super('Invalid Document ID'); + + this.rawDocument = rawDocument; + } + + /** + * Get raw Document + * + * @return {RawDocument} + */ + getRawDocument() { + return this.rawDocument; + } +} + +module.exports = InvalidDocumentIdError; diff --git a/lib/test/fixtures/getDpnsDocumentFixture.js b/lib/test/fixtures/getDpnsDocumentFixture.js index df449dba1..ff1f5e75d 100644 --- a/lib/test/fixtures/getDpnsDocumentFixture.js +++ b/lib/test/fixtures/getDpnsDocumentFixture.js @@ -1,13 +1,10 @@ -const { Transaction, PrivateKey } = require('@dashevo/dashcore-lib'); const entropy = require('../../../lib/util/entropy'); const multihash = require('../../../lib/util/multihashDoubleSHA256'); const getDpnsContractFixture = require('./getDpnsContractFixture'); const DocumentFactory = require('../../document/DocumentFactory'); +const generateRandomId = require('../utils/generateRandomId'); -const transaction = new Transaction().setType(Transaction.TYPES.TRANSACTION_SUBTX_REGISTER); -transaction.extraPayload.setUserName('MyUser').setPubKeyIdFromPrivateKey(new PrivateKey()); - -const ownerId = transaction.hash; +const ownerId = generateRandomId(); /** * @return {Document} @@ -30,7 +27,7 @@ function getParentDocumentFixture(options = {}) { normalizedParentDomainName: 'grandparent', preorderSalt: entropy.generate(), records: { - dashIdentity: transaction.hash, + dashIdentity: ownerId, }, ...options, }; diff --git a/schema/base/document.json b/schema/base/document.json index 931b3d41b..8c2089a60 100644 --- a/schema/base/document.json +++ b/schema/base/document.json @@ -3,6 +3,12 @@ "$id": "https://schema.dash.org/dpp-0-4-0/base/document", "type": "object", "properties": { + "$id": { + "type": "string", + "minLength": 42, + "maxLength": 44, + "pattern": "^[123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz]+$" + }, "$type": { "type": "string" }, @@ -30,6 +36,7 @@ } }, "required": [ + "$id", "$type", "$rev", "$contractId", diff --git a/test/integration/document/DocumentFacade.spec.js b/test/integration/document/DocumentFacade.spec.js index 9510671bc..50e7c4416 100644 --- a/test/integration/document/DocumentFacade.spec.js +++ b/test/integration/document/DocumentFacade.spec.js @@ -23,7 +23,7 @@ describe('DocumentFacade', () => { beforeEach(function beforeEach() { dataContract = getDataContractFixture(); - ownerId = '6b74011f5d2ad1a8d45b71b9702f54205ce75253593c3cfbba3fdadeca278288'; + ownerId = '5zcXZpTLWFwZjKjq3ME5KVavtZa9YUaZESVzrndehBhq'; dataProviderMock = createDataProviderMock(this.sinonSandbox); diff --git a/test/integration/document/stateTransition/validation/data/executeDataTriggersFactory.spec.js b/test/integration/document/stateTransition/validation/data/executeDataTriggersFactory.spec.js index aadc692d6..391f7dd03 100644 --- a/test/integration/document/stateTransition/validation/data/executeDataTriggersFactory.spec.js +++ b/test/integration/document/stateTransition/validation/data/executeDataTriggersFactory.spec.js @@ -56,7 +56,7 @@ describe('executeDataTriggersFactory', () => { dpnsDeleteDomainDataTriggerMock .execute.resolves(new DataTriggerExecutionResult()); - const ownerId = 'ownerId'; + const ownerId = '5zcXZpTLWFwZjKjq3ME5KVavtZa9YUaZESVzrndehBhq'; context = new DataTriggerExecutionContext( null, ownerId, contractMock, diff --git a/test/integration/document/validateDocumentFactory.spec.js b/test/integration/document/validateDocumentFactory.spec.js index 86698ba43..7538f6b6b 100644 --- a/test/integration/document/validateDocumentFactory.spec.js +++ b/test/integration/document/validateDocumentFactory.spec.js @@ -12,6 +12,7 @@ const getDocumentsFixture = require('../../../lib/test/fixtures/getDocumentsFixt const MissingDocumentTypeError = require('../../../lib/errors/MissingDocumentTypeError'); const InvalidDocumentTypeError = require('../../../lib/errors/InvalidDocumentTypeError'); +const InvalidDocumentIdError = require('../../../lib/errors/InvalidDocumentIdError'); const InvalidDocumentEntropyError = require('../../../lib/errors/InvalidDocumentEntropyError'); const ConsensusError = require('../../../lib/errors/ConsensusError'); const JsonSchemaError = require('../../../lib/errors/JsonSchemaError'); @@ -19,6 +20,8 @@ const MismatchDocumentContractIdAndDataContractError = require('../../../lib/err const originalDocumentBaseSchema = require('../../../schema/base/document'); +const generateDocumentId = require('../../../lib/document/generateDocumentId'); + const { expectValidationError, expectJsonSchemaError, @@ -56,6 +59,94 @@ describe('validateDocumentFactory', () => { }); describe('Base schema', () => { + describe('$id', () => { + it('should be present', () => { + delete rawDocument.$id; + + const result = validateDocument(rawDocument, dataContract); + + expectJsonSchemaError(result); + + const [error] = result.getErrors(); + + expect(error.dataPath).to.equal(''); + expect(error.keyword).to.equal('required'); + expect(error.params.missingProperty).to.equal('$id'); + }); + + it('should be a string', () => { + rawDocument.$id = 1; + + const result = validateDocument(rawDocument, dataContract); + + expectJsonSchemaError(result); + + const [error] = result.getErrors(); + + expect(error.dataPath).to.equal('.$id'); + expect(error.keyword).to.equal('type'); + }); + + it('should be no less than 42 chars', () => { + rawDocument.$id = '1'.repeat(41); + + const result = validateDocument(rawDocument, dataContract); + + expectJsonSchemaError(result); + + const [error] = result.getErrors(); + + expect(error.dataPath).to.equal('.$id'); + expect(error.keyword).to.equal('minLength'); + }); + + it('should be no longer than 44 chars', () => { + rawDocument.$id = '1'.repeat(45); + + const result = validateDocument(rawDocument, dataContract); + + expectJsonSchemaError(result); + + const [error] = result.getErrors(); + + expect(error.dataPath).to.equal('.$id'); + expect(error.keyword).to.equal('maxLength'); + }); + + it('should be base58 encoded', () => { + rawDocument.$id = '&'.repeat(44); + + const result = validateDocument(rawDocument, dataContract); + + expectJsonSchemaError(result); + + const [error] = result.getErrors(); + + expect(error.keyword).to.equal('pattern'); + expect(error.dataPath).to.equal('.$id'); + }); + + it('should be a concatenation of contractId, ownerId, type and entropy', async () => { + rawDocument.$id = generateDocumentId( + rawDocument.$contractId, + rawDocument.$ownerId, + rawDocument.$type, + '', + ); + + const result = validateDocument(rawDocument, dataContract); + + expectValidationError( + result, + InvalidDocumentIdError, + ); + + const [error] = result.getErrors(); + + expect(error.getRawDocument()).to.equal(rawDocument); + }); + }); + describe('$type', () => { it('should be present', () => { delete rawDocument.$type; diff --git a/test/unit/document/Document.spec.js b/test/unit/document/Document.spec.js index 2e6d38cad..30a058c5d 100644 --- a/test/unit/document/Document.spec.js +++ b/test/unit/document/Document.spec.js @@ -1,4 +1,3 @@ -const bs58 = require('bs58'); const rewiremock = require('rewiremock/node'); const generateRandomId = require('../../../lib/test/utils/generateRandomId'); @@ -29,6 +28,7 @@ describe('Document', () => { }); rawDocument = { + $id: 'D3AT6rBtyTqx3hXFckwtP81ncu49y5ndE7ot9JkuNSeB', $type: 'test', $contractId: generateRandomId(), $ownerId: generateRandomId(), @@ -46,6 +46,22 @@ describe('Document', () => { Document.prototype.setData = this.sinonSandbox.stub(); }); + it('should create Document with $id and data if present', () => { + const data = { + test: 1, + }; + + rawDocument = { + $id: 'id', + ...data, + }; + + document = new Document(rawDocument); + + expect(document.id).to.equal(rawDocument.$id); + expect(Document.prototype.setData).to.have.been.calledOnceWith(data); + }); + it('should create Document with $type and data if present', () => { const data = { test: 1, @@ -143,25 +159,7 @@ describe('Document', () => { }); describe('#getId', () => { - it('should calculate and return ID', () => { - const idBuffer = Buffer.from('123'); - const id = bs58.encode(idBuffer); - - hashMock.returns(idBuffer); - - const actualId = document.getId(); - - expect(hashMock).to.have.been.calledOnceWith( - rawDocument.$contractId - + rawDocument.$ownerId - + rawDocument.$type - + rawDocument.$entropy, - ); - - expect(id).to.equal(actualId); - }); - - it('should return already calculated ID', () => { + it('should return ID', () => { const id = '123'; document.id = id; diff --git a/test/unit/document/DocumentFactory.spec.js b/test/unit/document/DocumentFactory.spec.js index 0edf14501..b13a4d2f7 100644 --- a/test/unit/document/DocumentFactory.spec.js +++ b/test/unit/document/DocumentFactory.spec.js @@ -58,10 +58,13 @@ describe('DocumentFactory', () => { describe('create', () => { it('should return new Document with specified type and data', () => { - const contractId = dataContract.getId(); + const contractId = 'G8QqfBuLDjdTQSLQWsGSDPsP84hpHaYn7iTYcFmWou1E'; const entropy = '789'; const name = 'Cutie'; + dataContract.contractId = contractId; + ownerId = '5zcXZpTLWFwZjKjq3ME5KVavtZa9YUaZESVzrndehBhq'; + generateMock.returns(entropy); const newDocument = factory.create( @@ -86,6 +89,8 @@ describe('DocumentFactory', () => { expect(newDocument.getAction()).to.equal(Document.DEFAULTS.ACTION); expect(newDocument.getRevision()).to.equal(Document.DEFAULTS.REVISION); + + expect(newDocument.getId()).to.equal('2M7DcR6SXR8ZnpDWB1JhrVTmsx6oxwEpcrujJCGKnayh'); }); it('should throw an error if type is not defined', () => {