From 7ff37fbf010c6a93645c058c13c90b82d0ba02bf Mon Sep 17 00:00:00 2001 From: Milena Czierlinski <146972016+Milena-Czierlinski@users.noreply.github.com> Date: Mon, 25 Nov 2024 10:01:53 +0100 Subject: [PATCH 01/21] Attributes can be succeeded without changing their content (#337) * feat: add validation that attribute succession changes content * refactor: add blank lines * fix: use correct succession in existing tests * test: change position of error * refactor: change more error positions --- .../src/consumption/ConsumptionCoreErrors.ts | 7 ++ .../attributes/AttributesController.ts | 16 +++-- .../attributes/AttributesController.test.ts | 66 ++++++++++++++++++- ...oposeAttributeRequestItemProcessor.test.ts | 3 +- .../ReadAttributeRequestItemProcessor.test.ts | 6 +- .../requests/testHelpers/TestObjectFactory.ts | 7 +- 6 files changed, 92 insertions(+), 13 deletions(-) diff --git a/packages/consumption/src/consumption/ConsumptionCoreErrors.ts b/packages/consumption/src/consumption/ConsumptionCoreErrors.ts index 68737cab4..f838e3bd0 100644 --- a/packages/consumption/src/consumption/ConsumptionCoreErrors.ts +++ b/packages/consumption/src/consumption/ConsumptionCoreErrors.ts @@ -143,6 +143,13 @@ class Attributes { return new CoreError("error.consumption.attributes.successorSourceAttributeDoesNotExist", "The successor sourceAttribute does not exist."); } + public successionMustChangeContent() { + return new CoreError( + "error.consumption.attributes.successionMustChangeContent", + "The content of the successor matches that of the predecessor. An Attribute succession must change the Attribute's content." + ); + } + public successionMustNotChangeOwner() { return new CoreError( "error.consumption.attributes.successionMustNotChangeOwner", diff --git a/packages/consumption/src/modules/attributes/AttributesController.ts b/packages/consumption/src/modules/attributes/AttributesController.ts index 356f24d48..9d8bb5966 100644 --- a/packages/consumption/src/modules/attributes/AttributesController.ts +++ b/packages/consumption/src/modules/attributes/AttributesController.ts @@ -958,10 +958,6 @@ export class AttributesController extends ConsumptionBaseController { if (successor) return ValidationResult.error(ConsumptionCoreErrors.attributes.successorMustNotYetExist()); } - if (successor.succeeds && !predecessorId.equals(successor.succeeds.toString())) { - return ValidationResult.error(ConsumptionCoreErrors.attributes.setPredecessorIdDoesNotMatchActualPredecessorId()); - } - if (successor.succeededBy) { return ValidationResult.error(ConsumptionCoreErrors.attributes.successorMustNotHaveASuccessor()); } @@ -983,6 +979,14 @@ export class AttributesController extends ConsumptionBaseController { return ValidationResult.error(ConsumptionCoreErrors.attributes.cannotSucceedChildOfComplexAttribute(predecessorId.toString())); } + if (predecessor.hasDeletionInfo() && predecessor.deletionInfo.deletionStatus !== LocalAttributeDeletionStatus.DeletionRequestRejected) { + return ValidationResult.error(ConsumptionCoreErrors.attributes.cannotSucceedAttributesWithDeletionInfo()); + } + + if (_.isEqual(successor.content, predecessor.content)) { + return ValidationResult.error(ConsumptionCoreErrors.attributes.successionMustChangeContent()); + } + if (!predecessor.content.owner.equals(CoreAddress.from(successor.content.owner))) { return ValidationResult.error(ConsumptionCoreErrors.attributes.successionMustNotChangeOwner()); } @@ -995,8 +999,8 @@ export class AttributesController extends ConsumptionBaseController { return ValidationResult.error(ConsumptionCoreErrors.attributes.successionMustNotChangeValueType()); } - if (predecessor.hasDeletionInfo() && predecessor.deletionInfo.deletionStatus !== LocalAttributeDeletionStatus.DeletionRequestRejected) { - return ValidationResult.error(ConsumptionCoreErrors.attributes.cannotSucceedAttributesWithDeletionInfo()); + if (successor.succeeds && !predecessorId.equals(successor.succeeds.toString())) { + return ValidationResult.error(ConsumptionCoreErrors.attributes.setPredecessorIdDoesNotMatchActualPredecessorId()); } return ValidationResult.success(); diff --git a/packages/consumption/test/modules/attributes/AttributesController.test.ts b/packages/consumption/test/modules/attributes/AttributesController.test.ts index 7c98705ba..77c5bf4b7 100644 --- a/packages/consumption/test/modules/attributes/AttributesController.test.ts +++ b/packages/consumption/test/modules/attributes/AttributesController.test.ts @@ -1166,6 +1166,35 @@ describe("AttributesController", function () { describe("succeed Attributes", function () { describe("Common validator", function () { + test("should catch if content doesn't change", async function () { + const predecessor = await consumptionController.attributes.createRepositoryAttribute({ + content: IdentityAttribute.from({ + value: { + "@type": "Nationality", + value: "DE" + }, + owner: consumptionController.accountController.identity.address, + tags: ["aTag"] + }) + }); + + const successorData: IAttributeSuccessorParams = { + content: IdentityAttribute.from({ + value: { + "@type": "Nationality", + value: "DE" + }, + owner: consumptionController.accountController.identity.address, + tags: ["aTag"] + }) + }; + + const validationResult = await consumptionController.attributes.validateAttributeSuccessionCommon(predecessor.id, successorData); + expect(validationResult).errorValidationResult({ + code: "error.consumption.attributes.successionMustChangeContent" + }); + }); + test("should catch if the successor attribute already exist, if an explicit id is provided", async function () { const predecessor = await consumptionController.attributes.createRepositoryAttribute({ content: IdentityAttribute.from({ @@ -1475,7 +1504,7 @@ describe("AttributesController", function () { content: IdentityAttribute.from({ value: { "@type": "Nationality", - value: "DE" + value: "US" }, owner: CoreAddress.from("address") }) @@ -1726,6 +1755,41 @@ describe("AttributesController", function () { expect((successor.content.value.toJSON() as any).value).toBe("US"); }); + test("should succeed a repository attribute updating tags but not the value", async function () { + const predecessor = await consumptionController.attributes.createRepositoryAttribute({ + content: IdentityAttribute.from({ + value: { + "@type": "Nationality", + value: "DE" + }, + owner: consumptionController.accountController.identity.address, + tags: ["aTag"] + }) + }); + + const successorParams: IAttributeSuccessorParams = { + content: IdentityAttribute.from({ + value: { + "@type": "Nationality", + value: "DE" + }, + owner: consumptionController.accountController.identity.address, + tags: ["aTag", "anotherTag"] + }) + }; + + const { predecessor: updatedPredecessor, successor } = await consumptionController.attributes.succeedRepositoryAttribute(predecessor.id, successorParams); + expect(successor).toBeDefined(); + expect(updatedPredecessor).toBeDefined(); + expect(predecessor.id.equals(updatedPredecessor.id)).toBe(true); + expect(updatedPredecessor.succeededBy!.equals(successor.id)).toBe(true); + expect(successor.succeeds!.equals(updatedPredecessor.id)).toBe(true); + expect((updatedPredecessor.content.value.toJSON() as any).value).toBe("DE"); + expect((successor.content.value.toJSON() as any).value).toBe("DE"); + expect((updatedPredecessor.content as IdentityAttribute).tags).toStrictEqual(["aTag"]); + expect((successor.content as IdentityAttribute).tags).toStrictEqual(["aTag", "anotherTag"]); + }); + test("should make successor default succeeding a default repository attribute", async function () { const predecessor = await appConsumptionController.attributes.createRepositoryAttribute({ content: IdentityAttribute.from({ diff --git a/packages/consumption/test/modules/requests/itemProcessors/proposeAttribute/ProposeAttributeRequestItemProcessor.test.ts b/packages/consumption/test/modules/requests/itemProcessors/proposeAttribute/ProposeAttributeRequestItemProcessor.test.ts index 882b6b78b..ac727275a 100644 --- a/packages/consumption/test/modules/requests/itemProcessors/proposeAttribute/ProposeAttributeRequestItemProcessor.test.ts +++ b/packages/consumption/test/modules/requests/itemProcessors/proposeAttribute/ProposeAttributeRequestItemProcessor.test.ts @@ -1222,7 +1222,8 @@ describe("ProposeAttributeRequestItemProcessor", function () { predecessorId: predecessorPeerSharedIdentityAttribute.id, successorId: successorId, successorContent: TestObjectFactory.createIdentityAttribute({ - owner: sender + owner: sender, + tags: ["aNewTag"] }) }); diff --git a/packages/consumption/test/modules/requests/itemProcessors/readAttribute/ReadAttributeRequestItemProcessor.test.ts b/packages/consumption/test/modules/requests/itemProcessors/readAttribute/ReadAttributeRequestItemProcessor.test.ts index dfafc174f..9fd2b0d44 100644 --- a/packages/consumption/test/modules/requests/itemProcessors/readAttribute/ReadAttributeRequestItemProcessor.test.ts +++ b/packages/consumption/test/modules/requests/itemProcessors/readAttribute/ReadAttributeRequestItemProcessor.test.ts @@ -1898,7 +1898,8 @@ describe("ReadAttributeRequestItemProcessor", function () { predecessorId: predecessorPeerSharedIdentityAttribute.id, successorId: successorId, successorContent: TestObjectFactory.createIdentityAttribute({ - owner: recipient + owner: recipient, + tags: ["aNewTag"] }) }); @@ -1958,7 +1959,8 @@ describe("ReadAttributeRequestItemProcessor", function () { predecessorId: predecessorPeerSharedRelationshipAttribute.id, successorId: successorId, successorContent: TestObjectFactory.createRelationshipAttribute({ - owner: thirdPartyAddress + owner: thirdPartyAddress, + isTechnical: true }) }); diff --git a/packages/consumption/test/modules/requests/testHelpers/TestObjectFactory.ts b/packages/consumption/test/modules/requests/testHelpers/TestObjectFactory.ts index 92b613a67..afd099cb9 100644 --- a/packages/consumption/test/modules/requests/testHelpers/TestObjectFactory.ts +++ b/packages/consumption/test/modules/requests/testHelpers/TestObjectFactory.ts @@ -227,6 +227,7 @@ export class TestObjectFactory { public static createIdentityAttribute(properties?: Partial): IdentityAttribute { return IdentityAttribute.from({ value: properties?.value ?? GivenName.fromAny({ value: "aGivenName" }), + tags: properties?.tags, owner: properties?.owner ?? CoreAddress.from("did:e:a-domain:dids:anidentity") }); } @@ -234,9 +235,9 @@ export class TestObjectFactory { public static createRelationshipAttribute(properties?: Partial): RelationshipAttribute { return RelationshipAttribute.from({ value: properties?.value ?? ProprietaryString.from({ title: "aTitle", value: "aProprietaryStringValue" }), - confidentiality: RelationshipAttributeConfidentiality.Public, - key: "aKey", - isTechnical: false, + confidentiality: properties?.confidentiality ?? RelationshipAttributeConfidentiality.Public, + key: properties?.key ?? "aKey", + isTechnical: properties?.isTechnical ?? false, owner: properties?.owner ?? CoreAddress.from("did:e:a-domain:dids:anidentity") }); } From e4afd3b79d23d1c11405df948ef4d0ec423b914d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 26 Nov 2024 07:25:53 +0000 Subject: [PATCH 02/21] Chore(deps-dev): bump the update-npm-dependencies group with 2 updates (#340) --- package-lock.json | 17 +++++++++-------- package.json | 4 ++-- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3af811ba8..53c1dfa65 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,7 @@ "@js-soft/eslint-config-ts": "^1.6.13", "@js-soft/license-check": "^1.0.9", "@types/jest": "^29.5.14", - "@types/node": "^22.9.0", + "@types/node": "^22.9.3", "enhanced-publish": "^1.1.3", "eslint": "^8.57.1", "jest": "^29.7.0", @@ -29,7 +29,7 @@ "ts-jest": "^29.2.5", "ts-node": "^10.9.2", "tsconfig-paths": "^4.2.0", - "typescript": "^5.6.3" + "typescript": "^5.7.2" } }, "node_modules/@ampproject/remapping": { @@ -2167,10 +2167,11 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.9.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.9.0.tgz", - "integrity": "sha512-vuyHg81vvWA1Z1ELfvLko2c8f34gyA0zaic0+Rllc5lbCnbSyuvb2Oxpm6TAUAC/2xZN3QGqxBNggD1nNR2AfQ==", + "version": "22.9.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.9.3.tgz", + "integrity": "sha512-F3u1fs/fce3FFk+DAxbxc78DF8x0cY09RRL8GnXLmkJ1jvx3TtPdWoTT5/NiYfI5ASqXBmfqJi9dZ3gxMx4lzw==", "dev": true, + "license": "MIT", "dependencies": { "undici-types": "~6.19.8" } @@ -9142,9 +9143,9 @@ } }, "node_modules/typescript": { - "version": "5.6.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", - "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.2.tgz", + "integrity": "sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==", "dev": true, "license": "Apache-2.0", "bin": { diff --git a/package.json b/package.json index 942b0c864..9603959c0 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "@js-soft/eslint-config-ts": "^1.6.13", "@js-soft/license-check": "^1.0.9", "@types/jest": "^29.5.14", - "@types/node": "^22.9.0", + "@types/node": "^22.9.3", "enhanced-publish": "^1.1.3", "eslint": "^8.57.1", "jest": "^29.7.0", @@ -41,6 +41,6 @@ "ts-jest": "^29.2.5", "ts-node": "^10.9.2", "tsconfig-paths": "^4.2.0", - "typescript": "^5.6.3" + "typescript": "^5.7.2" } } From af8c3c019d3ef6512c9c68a8c968409e41936466 Mon Sep 17 00:00:00 2001 From: Sebastian Mahr Date: Tue, 26 Nov 2024 08:52:54 +0100 Subject: [PATCH 03/21] Fix flaky test (#339) * chore: subtract an hour to make it realy expire * chore: change to one second --------- Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com> --- .../test/modules/requests/IncomingRequestsController.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/consumption/test/modules/requests/IncomingRequestsController.test.ts b/packages/consumption/test/modules/requests/IncomingRequestsController.test.ts index c2af7c4ca..70e33a06d 100644 --- a/packages/consumption/test/modules/requests/IncomingRequestsController.test.ts +++ b/packages/consumption/test/modules/requests/IncomingRequestsController.test.ts @@ -66,7 +66,7 @@ describe("IncomingRequestsController", function () { }); test("takes the expiration date from the Template if the Request has no expiration date", async function () { - const timestamp = CoreDate.utc(); + const timestamp = CoreDate.utc().subtract({ second: 1 }); const incomingTemplate = TestObjectFactory.createIncomingRelationshipTemplate(timestamp); await When.iCreateAnIncomingRequestWith({ requestSourceObject: incomingTemplate }); await Then.theRequestHasExpirationDate(timestamp); From c89f9445b4ef375f32d2958ded2442a368ea6a28 Mon Sep 17 00:00:00 2001 From: Magnus Kuhn <127854942+Magnus-Kuhn@users.noreply.github.com> Date: Tue, 26 Nov 2024 13:16:13 +0100 Subject: [PATCH 04/21] Password-protected RelationshipTemplates (#295) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add password protection to templates * test: add tests * fix/test: add tests, make fixes * fix: backbone API * feat: change error message * refactor: nameof, toString * feat/test: error message, add validation test * fix: remove only * chore: bump backbone Co-authored-by: Julian König <33655937+jkoenig134@users.noreply.github.com> * fix: missing password pass * refactor: wrong variable name * refactor: review comments * chore: upgrade backbone and adapt client * feat: hash passwords * feat: add separate pin * feat: enhance password type * refactor: align error messages * fix: more enhancing password type * test: adapt tests * test: reference adaptations * wip * refactor/feat: review comments * feat: add transport empty string validation * test: add tests * fix: schemas, error codes * fix: add PINs when loading * feat: add loading validation, tests * test: add validations * fix: test errors * fix/feat: add loading validation, fix tests * test: fix tests * refactor: no PIN validation in loading schema * test: fix copy-paste error * refactor: use schemas for empty string validation * feat: use salt * refactor/test: reference adaptations * feat: adapt automatic version setting * fix: version in reference * feat: remove salt from dto * test: refactor tests * feat: add/adapt salt validation * refactor/test: validations * test: fix ids * chore: bump backbone * feat: remove version * test: fix salt test * chore: transport PR comments * chore: runtime PR comments * fix/refactor: more stuff * test: correct check * test: fix used function * feat: add transport setting validation * refactor: import * chore: build schemas * refactor: passwordinfo * fix: cleanup * test: cleanup * refactor/fix: use password info derivatives * refactor: remove unused error * test: fix error names in tests * feat: password error message * refactor: test names and content, class usage * feat: runtime interface with flag * test: adapt tests * chore: schemaas * fix: mapping, tests * refactor: simplify object access * refactor: rename passwordProtection * refactor: naming * chore: move business logic to object * refactor: use min * fix: tests * feat: passwordIsPin true or undefined * chore: build schemas * chore: remove unused method * test: fix tests * fix: ability to truncate * refactor: add fromTruncted * feat: error message mentions wrong password * test: add passwordType check * test: fix error code, naming * test: reorder, naming * test: fix error * fix: check for password * refactor: no error altering --------- Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com> Co-authored-by: Julian König <33655937+jkoenig134@users.noreply.github.com> Co-authored-by: Julian König --- .dev/compose.backbone.env | 2 +- packages/consumption/test/core/TestUtil.ts | 7 +- .../modules/requests/RequestEnd2End.test.ts | 6 +- .../PeerRelationshipTemplateDVO.ts | 4 + .../transport/RelationshipTemplateDVO.ts | 4 + .../transport/RelationshipTemplateDTO.ts | 4 + .../src/useCases/common/RuntimeErrors.ts | 4 + .../runtime/src/useCases/common/Schemas.ts | 20 ++ .../account/LoadItemFromTruncatedReference.ts | 2 +- .../CreateOwnRelationshipTemplate.ts | 18 +- .../CreateTokenForOwnRelationshipTemplate.ts | 14 +- ...teTokenQRCodeForOwnRelationshipTemplate.ts | 7 +- .../LoadPeerRelationshipTemplate.ts | 17 +- .../RelationshipTemplateMapper.ts | 6 + .../dataViews/RelationshipTemplateDVO.test.ts | 26 ++- .../transport/relationshipTemplates.test.ts | 187 ++++++++++++++++++ packages/transport/src/core/Reference.ts | 28 ++- .../transport/src/core/TransportCoreErrors.ts | 4 + .../src/core/types/PasswordProtection.ts | 34 ++++ .../PasswordProtectionCreationParameters.ts | 29 +++ .../core/types/SharedPasswordProtection.ts | 43 ++++ packages/transport/src/core/types/index.ts | 3 + .../RelationshipTemplateController.ts | 117 +++++++++-- .../BackboneGetRelationshipTemplates.ts | 2 +- .../BackbonePostRelationshipTemplates.ts | 1 + .../backbone/RelationshipTemplateClient.ts | 5 +- .../local/RelationshipTemplate.ts | 16 +- .../SendRelationshipTemplateParameters.ts | 6 + .../TokenContentRelationshipTemplate.ts | 6 + .../transport/test/end2end/End2End.test.ts | 13 +- .../test/modules/files/FileReference.test.ts | 95 +++++++-- .../RelationshipTemplateController.test.ts | 90 ++++++++- .../RelationshipTemplateReference.test.ts | 95 +++++++-- .../modules/sync/SyncController.error.test.ts | 6 +- .../sync/SyncController.relationships.test.ts | 18 +- .../test/modules/tokens/TokenContent.test.ts | 96 ++++++++- .../modules/tokens/TokenController.test.ts | 159 +++++++++++---- .../modules/tokens/TokenReference.test.ts | 95 +++++++-- .../transport/test/testHelpers/TestUtil.ts | 8 +- 39 files changed, 1090 insertions(+), 207 deletions(-) create mode 100644 packages/transport/src/core/types/PasswordProtection.ts create mode 100644 packages/transport/src/core/types/PasswordProtectionCreationParameters.ts create mode 100644 packages/transport/src/core/types/SharedPasswordProtection.ts diff --git a/.dev/compose.backbone.env b/.dev/compose.backbone.env index 903a8dc09..8115afe54 100644 --- a/.dev/compose.backbone.env +++ b/.dev/compose.backbone.env @@ -1 +1 @@ -BACKBONE_VERSION=6.13.2 +BACKBONE_VERSION=6.15.2 diff --git a/packages/consumption/test/core/TestUtil.ts b/packages/consumption/test/core/TestUtil.ts index d1b30f8dc..d5ef334f8 100644 --- a/packages/consumption/test/core/TestUtil.ts +++ b/packages/consumption/test/core/TestUtil.ts @@ -187,7 +187,8 @@ export class TestUtil { maxNumberOfAllocations: 1 }); - const templateTo = await to.relationshipTemplates.loadPeerRelationshipTemplate(templateFrom.id, templateFrom.secretKey); + const reference = templateFrom.toRelationshipTemplateReference().truncate(); + const templateTo = await to.relationshipTemplates.loadPeerRelationshipTemplateByTruncated(reference); await to.relationships.sendRelationship({ template: templateTo, @@ -241,7 +242,7 @@ export class TestUtil { throw new Error("token content not instanceof TokenContentRelationshipTemplate"); } - const templateTo = await to.relationshipTemplates.loadPeerRelationshipTemplate(receivedToken.cache!.content.templateId, receivedToken.cache!.content.secretKey); + const templateTo = await to.relationshipTemplates.loadPeerRelationshipTemplateByTokenContent(receivedToken.cache!.content); const relRequest = await to.relationships.sendRelationship({ template: templateTo, @@ -432,7 +433,7 @@ export class TestUtil { throw new Error("token content not instanceof TokenContentRelationshipTemplate"); } - const template = await account.relationshipTemplates.loadPeerRelationshipTemplate(receivedToken.cache!.content.templateId, receivedToken.cache!.content.secretKey); + const template = await account.relationshipTemplates.loadPeerRelationshipTemplateByTokenContent(receivedToken.cache!.content); return template; } diff --git a/packages/consumption/test/modules/requests/RequestEnd2End.test.ts b/packages/consumption/test/modules/requests/RequestEnd2End.test.ts index 9f7036446..9297f5105 100644 --- a/packages/consumption/test/modules/requests/RequestEnd2End.test.ts +++ b/packages/consumption/test/modules/requests/RequestEnd2End.test.ts @@ -56,7 +56,8 @@ describe("End2End Request/Response via Relationship Template", function () { }); test("recipient: load Relationship Template", async function () { - rTemplate = await rAccountController.relationshipTemplates.loadPeerRelationshipTemplate(sTemplate.id, sTemplate.secretKey); + const reference = sTemplate.toRelationshipTemplateReference().truncate(); + rTemplate = await rAccountController.relationshipTemplates.loadPeerRelationshipTemplateByTruncated(reference); }); test("recipient: create Local Request", async function () { @@ -343,7 +344,8 @@ describe("End2End Request via Template and Response via Message", function () { }); test("recipient: load Relationship Template", async function () { - rTemplate = await rAccountController.relationshipTemplates.loadPeerRelationshipTemplate(sTemplate.id, sTemplate.secretKey); + const reference = sTemplate.toRelationshipTemplateReference().truncate(); + rTemplate = await rAccountController.relationshipTemplates.loadPeerRelationshipTemplateByTruncated(reference); }); test("recipient: create Local Request", async function () { diff --git a/packages/runtime/src/dataViews/consumption/PeerRelationshipTemplateDVO.ts b/packages/runtime/src/dataViews/consumption/PeerRelationshipTemplateDVO.ts index 39c9ef89c..bfc1e888f 100644 --- a/packages/runtime/src/dataViews/consumption/PeerRelationshipTemplateDVO.ts +++ b/packages/runtime/src/dataViews/consumption/PeerRelationshipTemplateDVO.ts @@ -12,6 +12,10 @@ export interface PeerRelationshipTemplateDVO extends DataViewObject { expiresAt?: string; maxNumberOfAllocations?: number; forIdentity?: string; + passwordProtection?: { + password: string; + passwordIsPin?: true; + }; /** * Is optional, as there can be RelationshipTemplates without actual requests in it diff --git a/packages/runtime/src/dataViews/transport/RelationshipTemplateDVO.ts b/packages/runtime/src/dataViews/transport/RelationshipTemplateDVO.ts index 1b77c3021..18bc493dc 100644 --- a/packages/runtime/src/dataViews/transport/RelationshipTemplateDVO.ts +++ b/packages/runtime/src/dataViews/transport/RelationshipTemplateDVO.ts @@ -13,6 +13,10 @@ export interface RelationshipTemplateDVO extends DataViewObject { expiresAt?: string; maxNumberOfAllocations?: number; forIdentity?: string; + passwordProtection?: { + password: string; + passwordIsPin?: true; + }; /** * Is optional, as there can be RelationshipTemplates without actual requests in it diff --git a/packages/runtime/src/types/transport/RelationshipTemplateDTO.ts b/packages/runtime/src/types/transport/RelationshipTemplateDTO.ts index be249527e..4952186a8 100644 --- a/packages/runtime/src/types/transport/RelationshipTemplateDTO.ts +++ b/packages/runtime/src/types/transport/RelationshipTemplateDTO.ts @@ -9,6 +9,10 @@ export interface RelationshipTemplateDTO { createdByDevice: string; createdAt: string; forIdentity?: string; + passwordProtection?: { + password: string; + passwordIsPin?: true; + }; content: RelationshipTemplateContentDerivation; expiresAt?: string; maxNumberOfAllocations?: number; diff --git a/packages/runtime/src/useCases/common/RuntimeErrors.ts b/packages/runtime/src/useCases/common/RuntimeErrors.ts index ba6d19e27..7fc75add3 100644 --- a/packages/runtime/src/useCases/common/RuntimeErrors.ts +++ b/packages/runtime/src/useCases/common/RuntimeErrors.ts @@ -51,6 +51,10 @@ class General { public cacheEmpty(entityName: string | Function, id: string) { return new ApplicationError("error.runtime.cacheEmpty", `The cache of ${entityName instanceof Function ? entityName.name : entityName} with id '${id}' is empty.`); } + + public invalidPin(): ApplicationError { + return new ApplicationError("error.runtime.validation.invalidPin", "The PIN is invalid. It must consist of 4 to 16 digits from 0 to 9."); + } } class Serval { diff --git a/packages/runtime/src/useCases/common/Schemas.ts b/packages/runtime/src/useCases/common/Schemas.ts index 9951b600a..87e5c1967 100644 --- a/packages/runtime/src/useCases/common/Schemas.ts +++ b/packages/runtime/src/useCases/common/Schemas.ts @@ -22017,6 +22017,23 @@ export const CreateOwnRelationshipTemplateRequest: any = { }, "forIdentity": { "$ref": "#/definitions/AddressString" + }, + "passwordProtection": { + "type": "object", + "properties": { + "password": { + "type": "string", + "minLength": 1 + }, + "passwordIsPin": { + "type": "boolean", + "const": true + } + }, + "required": [ + "password" + ], + "additionalProperties": false } }, "required": [ @@ -22301,6 +22318,9 @@ export const LoadPeerRelationshipTemplateRequest: any = { "$ref": "#/definitions/RelationshipTemplateReferenceString" } ] + }, + "password": { + "type": "string" } }, "required": [ diff --git a/packages/runtime/src/useCases/transport/account/LoadItemFromTruncatedReference.ts b/packages/runtime/src/useCases/transport/account/LoadItemFromTruncatedReference.ts index 5ec7fac11..87b9ef427 100644 --- a/packages/runtime/src/useCases/transport/account/LoadItemFromTruncatedReference.ts +++ b/packages/runtime/src/useCases/transport/account/LoadItemFromTruncatedReference.ts @@ -93,7 +93,7 @@ export class LoadItemFromTruncatedReferenceUseCase extends UseCase { @@ -39,6 +46,12 @@ class Validator extends SchemaValidator { ); } + if (input.passwordProtection?.passwordIsPin) { + if (!/^[0-9]{4,16}$/.test(input.passwordProtection.password)) { + validationResult.addFailure(new ValidationFailure(RuntimeErrors.general.invalidPin())); + } + } + return validationResult; } } @@ -67,7 +80,8 @@ export class CreateOwnRelationshipTemplateUseCase extends UseCase { @@ -29,31 +30,31 @@ export class LoadPeerRelationshipTemplateUseCase extends UseCase> { - const result = await this.loadRelationshipTemplateFromReference(request.reference); + const result = await this.loadRelationshipTemplateFromReference(request.reference, request.password); await this.accountController.syncDatawallet(); return result; } - private async loadRelationshipTemplateFromReference(reference: string): Promise> { + private async loadRelationshipTemplateFromReference(reference: string, password?: string): Promise> { if (reference.startsWith(Base64ForIdPrefix.RelationshipTemplate)) { - return await this.loadRelationshipTemplateFromRelationshipTemplateReference(reference); + return await this.loadRelationshipTemplateFromRelationshipTemplateReference(reference, password); } if (reference.startsWith(Base64ForIdPrefix.Token)) { - return await this.loadRelationshipTemplateFromTokenReference(reference); + return await this.loadRelationshipTemplateFromTokenReference(reference, password); } throw RuntimeErrors.relationshipTemplates.invalidReference(reference); } - private async loadRelationshipTemplateFromRelationshipTemplateReference(relationshipTemplateReference: string) { - const template = await this.templateController.loadPeerRelationshipTemplateByTruncated(relationshipTemplateReference); + private async loadRelationshipTemplateFromRelationshipTemplateReference(relationshipTemplateReference: string, password?: string): Promise> { + const template = await this.templateController.loadPeerRelationshipTemplateByTruncated(relationshipTemplateReference, password); return Result.ok(RelationshipTemplateMapper.toRelationshipTemplateDTO(template)); } - private async loadRelationshipTemplateFromTokenReference(tokenReference: string): Promise> { + private async loadRelationshipTemplateFromTokenReference(tokenReference: string, password?: string): Promise> { const token = await this.tokenController.loadPeerTokenByTruncated(tokenReference, true); if (!token.cache) { @@ -65,7 +66,7 @@ export class LoadPeerRelationshipTemplateUseCase extends UseCase { maxNumberOfAllocations: 1, expiresAt: DateTime.utc().plus({ minutes: 10 }).toString(), content: templateContent, - forIdentity: requestor.address + forIdentity: requestor.address, + passwordProtection: { + password: "password" + } }) ).value as RelationshipTemplateDTO & { content: RelationshipTemplateContentJSON }; templateId = templatorTemplate.id; @@ -168,6 +171,8 @@ describe("RelationshipTemplateDVO", () => { expect(dvo.isOwn).toBe(true); expect(dvo.maxNumberOfAllocations).toBe(1); expect(dvo.forIdentity).toBe(requestor.address); + expect(dvo.passwordProtection!.password).toBe("password"); + expect(dvo.passwordProtection!.passwordIsPin).toBeUndefined(); expect(dvo.onNewRelationship!.type).toBe("RequestDVO"); expect(dvo.onNewRelationship!.items).toHaveLength(2); @@ -186,8 +191,13 @@ describe("RelationshipTemplateDVO", () => { }); test("TemplateDVO for requestor", async () => { - const requestorTemplate = (await requestor.transport.relationshipTemplates.loadPeerRelationshipTemplate({ reference: templatorTemplate.truncatedReference })) - .value as RelationshipTemplateDTO & { content: RelationshipTemplateContentJSON }; + const requestorTemplate = ( + await requestor.transport.relationshipTemplates.loadPeerRelationshipTemplate({ + reference: templatorTemplate.truncatedReference, + password: "password" + }) + ).value as RelationshipTemplateDTO & { content: RelationshipTemplateContentJSON }; + await requestor.eventBus.waitForEvent(IncomingRequestStatusChangedEvent, (e) => e.data.newStatus === LocalRequestStatus.DecisionRequired); const dto = requestorTemplate; @@ -203,6 +213,8 @@ describe("RelationshipTemplateDVO", () => { expect(dvo.isOwn).toBe(false); expect(dvo.maxNumberOfAllocations).toBe(1); expect(dvo.forIdentity).toBe(requestor.address); + expect(dvo.passwordProtection!.password).toBe("password"); + expect(dvo.passwordProtection!.passwordIsPin).toBeUndefined(); expect(dvo.onNewRelationship!.type).toBe("RequestDVO"); expect(dvo.onNewRelationship!.items).toHaveLength(2); @@ -260,8 +272,12 @@ describe("RelationshipTemplateDVO", () => { "source.reference": templateId } }); - const requestorTemplate = (await requestor.transport.relationshipTemplates.loadPeerRelationshipTemplate({ reference: templatorTemplate.truncatedReference })) - .value as RelationshipTemplateDTO & { content: RelationshipTemplateContentJSON }; + const requestorTemplate = ( + await requestor.transport.relationshipTemplates.loadPeerRelationshipTemplate({ + reference: templatorTemplate.truncatedReference, + password: "password" + }) + ).value as RelationshipTemplateDTO & { content: RelationshipTemplateContentJSON }; if (requestResult.value.length === 0) { await requestor.eventBus.waitForEvent(IncomingRequestStatusChangedEvent, (e) => e.data.newStatus === LocalRequestStatus.DecisionRequired); requestResult = await requestor.consumption.incomingRequests.getRequests({ diff --git a/packages/runtime/test/transport/relationshipTemplates.test.ts b/packages/runtime/test/transport/relationshipTemplates.test.ts index d46560234..6d5ee8fb2 100644 --- a/packages/runtime/test/transport/relationshipTemplates.test.ts +++ b/packages/runtime/test/transport/relationshipTemplates.test.ts @@ -1,4 +1,5 @@ import { RelationshipTemplateContent, RelationshipTemplateContentJSON } from "@nmshd/content"; +import { RelationshipTemplateReference } from "@nmshd/transport"; import { DateTime } from "luxon"; import { GetRelationshipTemplatesQuery, OwnerRestriction } from "../../src"; import { emptyRelationshipTemplateContent, QueryParamConditions, RuntimeServiceProvider, TestRuntimeServices } from "../lib"; @@ -247,6 +248,192 @@ describe("RelationshipTemplate Tests", () => { expect(createQRCodeWithoutPersonalizationResult).toBeAnError(/.*/, "error.runtime.relationshipTemplates.personalizationMustBeInherited"); }); }); + + describe("Password-protected templates", () => { + test("send and receive a password-protected template", async () => { + const createResult = await runtimeServices1.transport.relationshipTemplates.createOwnRelationshipTemplate({ + content: emptyRelationshipTemplateContent, + expiresAt: DateTime.utc().plus({ minutes: 1 }).toString(), + passwordProtection: { + password: "password" + } + }); + expect(createResult).toBeSuccessful(); + expect(createResult.value.passwordProtection!.password).toBe("password"); + expect(createResult.value.passwordProtection!.passwordIsPin).toBeUndefined(); + const reference = RelationshipTemplateReference.from(createResult.value.truncatedReference); + expect(reference.passwordProtection!.passwordType).toBe("pw"); + + const loadResult = await runtimeServices2.transport.relationshipTemplates.loadPeerRelationshipTemplate({ + reference: createResult.value.truncatedReference, + password: "password" + }); + expect(loadResult).toBeSuccessful(); + expect(loadResult.value.passwordProtection!.password).toBe("password"); + expect(loadResult.value.passwordProtection!.passwordIsPin).toBeUndefined(); + }); + + test("send and receive a PIN-protected template", async () => { + const createResult = await runtimeServices1.transport.relationshipTemplates.createOwnRelationshipTemplate({ + content: emptyRelationshipTemplateContent, + expiresAt: DateTime.utc().plus({ minutes: 1 }).toString(), + passwordProtection: { + password: "1234", + passwordIsPin: true + } + }); + expect(createResult).toBeSuccessful(); + expect(createResult.value.passwordProtection!.password).toBe("1234"); + expect(createResult.value.passwordProtection!.passwordIsPin).toBe(true); + const reference = RelationshipTemplateReference.from(createResult.value.truncatedReference); + expect(reference.passwordProtection!.passwordType).toBe("pin4"); + + const loadResult = await runtimeServices2.transport.relationshipTemplates.loadPeerRelationshipTemplate({ + reference: createResult.value.truncatedReference, + password: "1234" + }); + expect(loadResult).toBeSuccessful(); + expect(loadResult.value.passwordProtection!.password).toBe("1234"); + expect(loadResult.value.passwordProtection!.passwordIsPin).toBe(true); + }); + + test("send and receive a password-protected template via a token", async () => { + const templateId = ( + await runtimeServices1.transport.relationshipTemplates.createOwnRelationshipTemplate({ + content: emptyRelationshipTemplateContent, + expiresAt: DateTime.utc().plus({ minutes: 1 }).toString(), + passwordProtection: { + password: "password" + } + }) + ).value.id; + const createResult = await runtimeServices1.transport.relationshipTemplates.createTokenForOwnTemplate({ templateId }); + + const loadResult = await runtimeServices2.transport.relationshipTemplates.loadPeerRelationshipTemplate({ + reference: createResult.value.truncatedReference, + password: "password" + }); + expect(loadResult).toBeSuccessful(); + expect(loadResult.value.passwordProtection!.password).toBe("password"); + expect(loadResult.value.passwordProtection!.passwordIsPin).toBeUndefined(); + }); + + test("send and receive a PIN-protected template via a token", async () => { + const templateId = ( + await runtimeServices1.transport.relationshipTemplates.createOwnRelationshipTemplate({ + content: emptyRelationshipTemplateContent, + expiresAt: DateTime.utc().plus({ minutes: 1 }).toString(), + passwordProtection: { + password: "1234", + passwordIsPin: true + } + }) + ).value.id; + + const createResult = await runtimeServices1.transport.relationshipTemplates.createTokenForOwnTemplate({ templateId }); + + const loadResult = await runtimeServices2.transport.relationshipTemplates.loadPeerRelationshipTemplate({ + reference: createResult.value.truncatedReference, + password: "1234" + }); + expect(loadResult).toBeSuccessful(); + expect(loadResult.value.passwordProtection!.password).toBe("1234"); + expect(loadResult.value.passwordProtection!.passwordIsPin).toBe(true); + }); + + test("error when loading a password-protected template with a wrong password", async () => { + const createResult = await runtimeServices1.transport.relationshipTemplates.createOwnRelationshipTemplate({ + content: emptyRelationshipTemplateContent, + expiresAt: DateTime.utc().plus({ minutes: 1 }).toString(), + passwordProtection: { + password: "password" + } + }); + expect(createResult).toBeSuccessful(); + + const loadResult = await runtimeServices2.transport.relationshipTemplates.loadPeerRelationshipTemplate({ + reference: createResult.value.truncatedReference, + password: "wrong-password" + }); + expect(loadResult).toBeAnError("RelationshipTemplate not found. Make sure the ID exists and the record is not expired.", "error.runtime.recordNotFound"); + }); + + test("error when loading a password-protected template via token with wrong password", async () => { + const templateId = ( + await runtimeServices1.transport.relationshipTemplates.createOwnRelationshipTemplate({ + content: emptyRelationshipTemplateContent, + expiresAt: DateTime.utc().plus({ minutes: 1 }).toString(), + passwordProtection: { + password: "password" + } + }) + ).value.id; + const createResult = await runtimeServices1.transport.relationshipTemplates.createTokenForOwnTemplate({ templateId }); + + const loadResult = await runtimeServices2.transport.relationshipTemplates.loadPeerRelationshipTemplate({ + reference: createResult.value.truncatedReference, + password: "wrong-password" + }); + expect(loadResult).toBeAnError("RelationshipTemplate not found. Make sure the ID exists and the record is not expired.", "error.runtime.recordNotFound"); + }); + + test("error when loading a password-protected template with no password", async () => { + const createResult = await runtimeServices1.transport.relationshipTemplates.createOwnRelationshipTemplate({ + content: emptyRelationshipTemplateContent, + expiresAt: DateTime.utc().plus({ minutes: 1 }).toString(), + passwordProtection: { + password: "password" + } + }); + expect(createResult).toBeSuccessful(); + + const loadResult = await runtimeServices2.transport.relationshipTemplates.loadPeerRelationshipTemplate({ + reference: createResult.value.truncatedReference + }); + expect(loadResult).toBeAnError(/.*/, "error.transport.noPasswordProvided"); + }); + + test("error when loading a password-protected template via token with no password", async () => { + const templateId = ( + await runtimeServices1.transport.relationshipTemplates.createOwnRelationshipTemplate({ + content: emptyRelationshipTemplateContent, + expiresAt: DateTime.utc().plus({ minutes: 1 }).toString(), + passwordProtection: { + password: "password" + } + }) + ).value.id; + const createResult = await runtimeServices1.transport.relationshipTemplates.createTokenForOwnTemplate({ templateId }); + + const loadResult = await runtimeServices2.transport.relationshipTemplates.loadPeerRelationshipTemplate({ + reference: createResult.value.truncatedReference + }); + expect(loadResult).toBeAnError(/.*/, "error.transport.noPasswordProvided"); + }); + + test("validation error when creating a template with empty string as the password", async () => { + const createResult = await runtimeServices1.transport.relationshipTemplates.createOwnRelationshipTemplate({ + content: emptyRelationshipTemplateContent, + expiresAt: DateTime.utc().plus({ minutes: 1 }).toString(), + passwordProtection: { + password: "" + } + }); + expect(createResult).toBeAnError("password must NOT have fewer than 1 characters", "error.runtime.validation.invalidPropertyValue"); + }); + + test("validation error when creating a template with an invalid PIN", async () => { + const createResult = await runtimeServices1.transport.relationshipTemplates.createOwnRelationshipTemplate({ + content: emptyRelationshipTemplateContent, + expiresAt: DateTime.utc().plus({ minutes: 1 }).toString(), + passwordProtection: { + password: "invalid-pin", + passwordIsPin: true + } + }); + expect(createResult).toBeAnError(/.*/, "error.runtime.validation.invalidPin"); + }); + }); }); describe("Serialization Errors", () => { diff --git a/packages/transport/src/core/Reference.ts b/packages/transport/src/core/Reference.ts index 8b3a38bef..807f89f7d 100644 --- a/packages/transport/src/core/Reference.ts +++ b/packages/transport/src/core/Reference.ts @@ -1,17 +1,19 @@ -import { ISerializable, Serializable, serialize, validate, ValidationError } from "@js-soft/ts-serval"; +import { ISerializable, Serializable, serialize, type, validate, ValidationError } from "@js-soft/ts-serval"; import { CoreId, ICoreId } from "@nmshd/core-types"; import { CoreBuffer, CryptoSecretKey, ICryptoSecretKey } from "@nmshd/crypto"; import { CoreIdHelper } from "./CoreIdHelper"; import { TransportCoreErrors } from "./TransportCoreErrors"; +import { ISharedPasswordProtection, SharedPasswordProtection } from "./types"; export interface IReference extends ISerializable { id: ICoreId; backboneBaseUrl?: string; key: ICryptoSecretKey; forIdentityTruncated?: string; - passwordType?: number; + passwordProtection?: ISharedPasswordProtection; } +@type("Reference") export class Reference extends Serializable implements IReference { @validate({ regExp: new RegExp("^[A-Za-z0-9]{20}$") }) @serialize() @@ -29,14 +31,15 @@ export class Reference extends Serializable implements IReference { @serialize() public forIdentityTruncated?: string; - @validate({ nullable: true, min: 1, max: 12, customValidator: (v) => (!Number.isInteger(v) ? "must be an integer" : undefined) }) + @validate({ nullable: true }) @serialize() - public passwordType?: number; + public passwordProtection?: SharedPasswordProtection; public truncate(): string { const idPart = this.backboneBaseUrl ? `${this.id.toString()}@${this.backboneBaseUrl}` : this.id.toString(); + const truncatedReference = CoreBuffer.fromUtf8( - `${idPart}|${this.key.algorithm}|${this.key.secretKey.toBase64URL()}|${this.forIdentityTruncated ? this.forIdentityTruncated : ""}|${this.passwordType ? this.passwordType.toString() : ""}` + `${idPart}|${this.key.algorithm}|${this.key.secretKey.toBase64URL()}|${this.forIdentityTruncated ?? ""}|${this.passwordProtection?.truncate() ?? ""}` ); return truncatedReference.toBase64URL(); } @@ -54,27 +57,18 @@ export class Reference extends Serializable implements IReference { const secretKey = this.parseSecretKey(splitted[1], splitted[2]); const forIdentityTruncated = splitted[3] ? splitted[3] : undefined; - const passwordType = splitted[4] ? this.parsePasswordType(splitted[4]) : undefined; + + const passwordProtection = SharedPasswordProtection.fromTruncated(splitted[4]); return this.from({ id: CoreId.from(id), backboneBaseUrl, key: secretKey, forIdentityTruncated, - passwordType + passwordProtection }); } - private static parsePasswordType(value: string): number | undefined { - try { - if (value === "") return undefined; - - return parseInt(value); - } catch (_) { - throw TransportCoreErrors.general.invalidTruncatedReference("The password type must be indicated by an integer in the TruncatedReference."); - } - } - private static parseSecretKey(alg: string, secretKey: string): CryptoSecretKey { let algorithm: number; diff --git a/packages/transport/src/core/TransportCoreErrors.ts b/packages/transport/src/core/TransportCoreErrors.ts index e866bb1e1..8daba3013 100644 --- a/packages/transport/src/core/TransportCoreErrors.ts +++ b/packages/transport/src/core/TransportCoreErrors.ts @@ -206,6 +206,10 @@ class General { public accountControllerInitialSyncFailed() { return new CoreError("error.transport.accountControllerInitialSyncFailed", "The initial sync of the AccountController failed."); } + + public noPasswordProvided() { + return new CoreError("error.transport.noPasswordProvided", "You need to provide a password to perform this operation."); + } } export class TransportCoreErrors { diff --git a/packages/transport/src/core/types/PasswordProtection.ts b/packages/transport/src/core/types/PasswordProtection.ts new file mode 100644 index 000000000..0f61087ff --- /dev/null +++ b/packages/transport/src/core/types/PasswordProtection.ts @@ -0,0 +1,34 @@ +import { ISerializable, Serializable, serialize, validate } from "@js-soft/ts-serval"; +import { CoreBuffer, ICoreBuffer } from "@nmshd/crypto"; +import { SharedPasswordProtection } from "./SharedPasswordProtection"; + +export interface IPasswordProtection extends ISerializable { + passwordType: string; + salt: ICoreBuffer; + password: string; +} + +export class PasswordProtection extends Serializable implements IPasswordProtection { + @validate({ regExp: /^(pw|pin(4|5|6|7|8|9|10|11|12|13|14|15|16))$/ }) + @serialize() + public passwordType: string; + + @validate({ customValidator: (v: ICoreBuffer) => (v.buffer.byteLength === 16 ? undefined : "must be 16 bytes long") }) + @serialize() + public salt: CoreBuffer; + + @validate({ min: 1 }) + @serialize() + public password: string; + + public static from(value: IPasswordProtection): PasswordProtection { + return this.fromAny(value); + } + + public toSharedPasswordProtection(): SharedPasswordProtection { + return SharedPasswordProtection.from({ + passwordType: this.passwordType, + salt: this.salt + }); + } +} diff --git a/packages/transport/src/core/types/PasswordProtectionCreationParameters.ts b/packages/transport/src/core/types/PasswordProtectionCreationParameters.ts new file mode 100644 index 000000000..5af7adc42 --- /dev/null +++ b/packages/transport/src/core/types/PasswordProtectionCreationParameters.ts @@ -0,0 +1,29 @@ +import { ISerializable, Serializable, serialize, validate } from "@js-soft/ts-serval"; + +export interface IPasswordProtectionCreationParameters extends ISerializable { + passwordType: string; + password: string; +} + +export class PasswordProtectionCreationParameters extends Serializable implements IPasswordProtectionCreationParameters { + @validate({ regExp: /^(pw|pin(4|5|6|7|8|9|10|11|12|13|14|15|16))$/ }) + @serialize() + public passwordType: string; + + @validate({ min: 1 }) + @serialize() + public password: string; + + public static from(value: IPasswordProtectionCreationParameters): PasswordProtectionCreationParameters { + return this.fromAny(value); + } + + public static create(params: { password: string; passwordIsPin?: true } | undefined): PasswordProtectionCreationParameters | undefined { + if (!params) return; + + return PasswordProtectionCreationParameters.from({ + password: params.password, + passwordType: params.passwordIsPin ? `pin${params.password.length}` : "pw" + }); + } +} diff --git a/packages/transport/src/core/types/SharedPasswordProtection.ts b/packages/transport/src/core/types/SharedPasswordProtection.ts new file mode 100644 index 000000000..629a3eecd --- /dev/null +++ b/packages/transport/src/core/types/SharedPasswordProtection.ts @@ -0,0 +1,43 @@ +import { ISerializable, Serializable, serialize, validate } from "@js-soft/ts-serval"; +import { CoreBuffer, ICoreBuffer } from "@nmshd/crypto"; +import { TransportCoreErrors } from "../TransportCoreErrors"; + +export interface ISharedPasswordProtection extends ISerializable { + passwordType: string; + salt: ICoreBuffer; +} + +export class SharedPasswordProtection extends Serializable implements ISharedPasswordProtection { + @validate({ regExp: /^(pw|pin(4|5|6|7|8|9|10|11|12|13|14|15|16))$/ }) + @serialize() + public passwordType: string; + + @validate({ customValidator: (v: ICoreBuffer) => (v.buffer.byteLength === 16 ? undefined : "must be 16 bytes long") }) + @serialize() + public salt: CoreBuffer; + + public static from(value: ISharedPasswordProtection): SharedPasswordProtection { + return this.fromAny(value); + } + + public static fromTruncated(value?: string): SharedPasswordProtection | undefined { + if (value === undefined || value === "") return undefined; + + const splittedPasswordParts = value.split("&"); + if (splittedPasswordParts.length !== 2) { + throw TransportCoreErrors.general.invalidTruncatedReference("The password part of a TruncatedReference must consist of exactly 2 components."); + } + + const passwordType = splittedPasswordParts[0]; + try { + const salt = CoreBuffer.fromBase64(splittedPasswordParts[1]); + return SharedPasswordProtection.from({ passwordType, salt }); + } catch (_) { + throw TransportCoreErrors.general.invalidTruncatedReference("The salt needs to be a Base64 value."); + } + } + + public truncate(): string { + return `${this.passwordType}&${this.salt.toBase64()}`; + } +} diff --git a/packages/transport/src/core/types/index.ts b/packages/transport/src/core/types/index.ts index 34d5d158c..0f1973daf 100644 --- a/packages/transport/src/core/types/index.ts +++ b/packages/transport/src/core/types/index.ts @@ -1,2 +1,5 @@ export * from "./CoreHash"; +export * from "./PasswordProtection"; +export * from "./PasswordProtectionCreationParameters"; +export * from "./SharedPasswordProtection"; export * from "./TransportVersion"; diff --git a/packages/transport/src/modules/relationshipTemplates/RelationshipTemplateController.ts b/packages/transport/src/modules/relationshipTemplates/RelationshipTemplateController.ts index d4476e086..b9c52f1bf 100644 --- a/packages/transport/src/modules/relationshipTemplates/RelationshipTemplateController.ts +++ b/packages/transport/src/modules/relationshipTemplates/RelationshipTemplateController.ts @@ -2,7 +2,7 @@ import { ISerializable } from "@js-soft/ts-serval"; import { log } from "@js-soft/ts-utils"; import { CoreAddress, CoreDate, CoreId } from "@nmshd/core-types"; import { CoreBuffer, CryptoCipher, CryptoSecretKey } from "@nmshd/crypto"; -import { CoreCrypto, TransportCoreErrors } from "../../core"; +import { CoreCrypto, PasswordProtection, TransportCoreErrors } from "../../core"; import { DbCollectionName } from "../../core/DbCollectionName"; import { ControllerName, TransportController } from "../../core/TransportController"; import { PeerRelationshipTemplateLoadedEvent } from "../../events"; @@ -10,6 +10,7 @@ import { AccountController } from "../accounts/AccountController"; import { Relationship } from "../relationships/local/Relationship"; import { RelationshipSecretController } from "../relationships/RelationshipSecretController"; import { SynchronizedCollection } from "../sync/SynchronizedCollection"; +import { TokenContentRelationshipTemplate } from "../tokens/transmission/TokenContentRelationshipTemplate"; import { BackboneGetRelationshipTemplatesResponse } from "./backbone/BackboneGetRelationshipTemplates"; import { RelationshipTemplateClient } from "./backbone/RelationshipTemplateClient"; import { CachedRelationshipTemplate } from "./local/CachedRelationshipTemplate"; @@ -40,7 +41,6 @@ export class RelationshipTemplateController extends TransportController { public async sendRelationshipTemplate(parameters: ISendRelationshipTemplateParameters): Promise { parameters = SendRelationshipTemplateParameters.from(parameters); - const templateKey = await this.secrets.createTemplateKey(); const templateContent = RelationshipTemplateContentWrapper.from({ @@ -62,11 +62,16 @@ export class RelationshipTemplateController extends TransportController { const cipher = await CoreCrypto.encrypt(signedTemplateBuffer, secretKey); + const password = parameters.passwordProtection?.password; + const salt = password ? await CoreCrypto.random(16) : undefined; + const hashedPassword = password ? (await CoreCrypto.deriveHashOutOfPassword(password, salt!)).toBase64() : undefined; + const backboneResponse = ( await this.client.createRelationshipTemplate({ expiresAt: parameters.expiresAt.toString(), maxNumberOfAllocations: parameters.maxNumberOfAllocations, forIdentity: parameters.forIdentity?.address.toString(), + password: hashedPassword, content: cipher.toBase64() }) ).value; @@ -83,10 +88,20 @@ export class RelationshipTemplateController extends TransportController { templateKey: templateKey }); + const passwordProtection = parameters.passwordProtection + ? PasswordProtection.from({ + password: parameters.passwordProtection.password, + passwordType: parameters.passwordProtection.passwordType, + salt: salt! + }) + : undefined; + const template = RelationshipTemplate.from({ id: CoreId.from(backboneResponse.id), secretKey: secretKey, isOwn: true, + passwordProtection, + cache: templateCache, cachedAt: CoreDate.utc() }); @@ -113,7 +128,21 @@ export class RelationshipTemplateController extends TransportController { return []; } - const resultItems = (await this.client.getRelationshipTemplates({ ids })).value; + const templates = await this.readRelationshipTemplates(ids); + + const resultItems = ( + await this.client.getRelationshipTemplates({ + templates: await Promise.all( + templates.map(async (t) => { + const hashedPassword = t.passwordProtection + ? (await CoreCrypto.deriveHashOutOfPassword(t.passwordProtection.password, t.passwordProtection.salt)).toBase64() + : undefined; + return { id: t.id.toString(), password: hashedPassword }; + }) + ) + }) + ).value; + const promises = []; for await (const resultItem of resultItems) { promises.push(this.updateCacheOfExistingTemplateInDb(resultItem.id, resultItem)); @@ -124,25 +153,43 @@ export class RelationshipTemplateController extends TransportController { public async fetchCaches(ids: CoreId[]): Promise<{ id: CoreId; cache: CachedRelationshipTemplate }[]> { if (ids.length === 0) return []; + const templates = await this.readRelationshipTemplates(ids.map((id) => id.toString())); + + const backboneRelationshipTemplates = await ( + await this.client.getRelationshipTemplates({ + templates: await Promise.all( + templates.map(async (t) => { + const hashedPassword = t.passwordProtection + ? (await CoreCrypto.deriveHashOutOfPassword(t.passwordProtection.password, t.passwordProtection.salt)).toBase64() + : undefined; + return { id: t.id.toString(), password: hashedPassword }; + }) + ) + }) + ).value.collect(); + + const decryptionPromises = backboneRelationshipTemplates.map(async (t) => { + const template = templates.find((template) => template.id.toString() === t.id); + if (!template) return; + return { id: CoreId.from(t.id), cache: await this.decryptRelationshipTemplate(t, template.secretKey) }; + }); - const backboneRelationships = await (await this.client.getRelationshipTemplates({ ids: ids.map((id) => id.id) })).value.collect(); + const caches = await Promise.all(decryptionPromises); + return caches.filter((c) => c !== undefined); + } - const decryptionPromises = backboneRelationships.map(async (t) => { - const templateDoc = await this.templates.read(t.id); + private async readRelationshipTemplates(ids: string[]): Promise { + const templatePromises = ids.map(async (id) => { + const templateDoc = await this.templates.read(id); if (!templateDoc) { - this._log.error( - `Template '${t.id}' not found in local database and the cache fetching was therefore skipped. This should not happen and might be a bug in the application logic.` - ); + this._log.error(`Template '${id}' not found in local database. This should not happen and might be a bug in the application logic.`); return; } - const template = RelationshipTemplate.from(templateDoc); - - return { id: CoreId.from(t.id), cache: await this.decryptRelationshipTemplate(t, template.secretKey) }; + return RelationshipTemplate.from(templateDoc); }); - const caches = await Promise.all(decryptionPromises); - return caches.filter((c) => c !== undefined); + return (await Promise.all(templatePromises)).filter((t) => t !== undefined); } @log() @@ -161,7 +208,10 @@ export class RelationshipTemplateController extends TransportController { private async updateCacheOfTemplate(template: RelationshipTemplate, response?: BackboneGetRelationshipTemplatesResponse) { if (!response) { - response = (await this.client.getRelationshipTemplate(template.id.toString())).value; + const hashedPassword = template.passwordProtection + ? (await CoreCrypto.deriveHashOutOfPassword(template.passwordProtection.password, template.passwordProtection.salt)).toBase64() + : undefined; + response = (await this.client.getRelationshipTemplate(template.id.toString(), hashedPassword)).value; } const cachedTemplate = await this.decryptRelationshipTemplate(response, template.secretKey); @@ -227,12 +277,40 @@ export class RelationshipTemplateController extends TransportController { return template; } - public async loadPeerRelationshipTemplateByTruncated(truncated: string): Promise { + public async loadPeerRelationshipTemplateByTruncated(truncated: string, password?: string): Promise { const reference = RelationshipTemplateReference.fromTruncated(truncated); - return await this.loadPeerRelationshipTemplate(reference.id, reference.key, reference.forIdentityTruncated); + + if (reference.passwordProtection && !password) throw TransportCoreErrors.general.noPasswordProvided(); + const passwordProtection = reference.passwordProtection + ? PasswordProtection.from({ + salt: reference.passwordProtection.salt, + passwordType: reference.passwordProtection.passwordType, + password: password! + }) + : undefined; + + return await this.loadPeerRelationshipTemplate(reference.id, reference.key, reference.forIdentityTruncated, passwordProtection); + } + + public async loadPeerRelationshipTemplateByTokenContent(tokenContent: TokenContentRelationshipTemplate, password?: string): Promise { + if (tokenContent.passwordProtection && !password) throw TransportCoreErrors.general.noPasswordProvided(); + const passwordProtection = tokenContent.passwordProtection + ? PasswordProtection.from({ + salt: tokenContent.passwordProtection.salt, + passwordType: tokenContent.passwordProtection.passwordType, + password: password! + }) + : undefined; + + return await this.loadPeerRelationshipTemplate(tokenContent.templateId, tokenContent.secretKey, tokenContent.forIdentity?.toString(), passwordProtection); } - public async loadPeerRelationshipTemplate(id: CoreId, secretKey: CryptoSecretKey, forIdentityTruncated?: string): Promise { + private async loadPeerRelationshipTemplate( + id: CoreId, + secretKey: CryptoSecretKey, + forIdentityTruncated?: string, + passwordProtection?: PasswordProtection + ): Promise { const templateDoc = await this.templates.read(id.toString()); if (!templateDoc && forIdentityTruncated && !this.parent.identity.address.toString().endsWith(forIdentityTruncated)) { throw TransportCoreErrors.general.notIntendedForYou(id.toString()); @@ -251,7 +329,8 @@ export class RelationshipTemplateController extends TransportController { const relationshipTemplate = RelationshipTemplate.from({ id: id, secretKey: secretKey, - isOwn: false + isOwn: false, + passwordProtection }); await this.updateCacheOfTemplate(relationshipTemplate); diff --git a/packages/transport/src/modules/relationshipTemplates/backbone/BackboneGetRelationshipTemplates.ts b/packages/transport/src/modules/relationshipTemplates/backbone/BackboneGetRelationshipTemplates.ts index a5d12ff98..784b67045 100644 --- a/packages/transport/src/modules/relationshipTemplates/backbone/BackboneGetRelationshipTemplates.ts +++ b/packages/transport/src/modules/relationshipTemplates/backbone/BackboneGetRelationshipTemplates.ts @@ -1,5 +1,5 @@ export interface BackboneGetRelationshipTemplatesRequest { - ids: string[]; + templates: { id: string; password?: string }[]; } export interface BackboneGetRelationshipTemplatesResponse { diff --git a/packages/transport/src/modules/relationshipTemplates/backbone/BackbonePostRelationshipTemplates.ts b/packages/transport/src/modules/relationshipTemplates/backbone/BackbonePostRelationshipTemplates.ts index 63d683597..6fb10785d 100644 --- a/packages/transport/src/modules/relationshipTemplates/backbone/BackbonePostRelationshipTemplates.ts +++ b/packages/transport/src/modules/relationshipTemplates/backbone/BackbonePostRelationshipTemplates.ts @@ -2,6 +2,7 @@ export interface BackbonePostRelationshipTemplatesRequest { expiresAt?: string; maxNumberOfAllocations?: number; forIdentity?: string; + password?: string; content: string; } diff --git a/packages/transport/src/modules/relationshipTemplates/backbone/RelationshipTemplateClient.ts b/packages/transport/src/modules/relationshipTemplates/backbone/RelationshipTemplateClient.ts index 77f1f867a..8129199c8 100644 --- a/packages/transport/src/modules/relationshipTemplates/backbone/RelationshipTemplateClient.ts +++ b/packages/transport/src/modules/relationshipTemplates/backbone/RelationshipTemplateClient.ts @@ -9,8 +9,9 @@ export class RelationshipTemplateClient extends RESTClientAuthenticate { return await this.getPaged("/api/v1/RelationshipTemplates", request); } - public async getRelationshipTemplate(id: string): Promise> { - return await this.get(`/api/v1/RelationshipTemplates/${id}`); + public async getRelationshipTemplate(id: string, password?: string): Promise> { + const request = password ? { password } : undefined; + return await this.get(`/api/v1/RelationshipTemplates/${id}`, request); } public async deleteRelationshipTemplate(id: string): Promise> { diff --git a/packages/transport/src/modules/relationshipTemplates/local/RelationshipTemplate.ts b/packages/transport/src/modules/relationshipTemplates/local/RelationshipTemplate.ts index b9b02e84f..1caa423ad 100644 --- a/packages/transport/src/modules/relationshipTemplates/local/RelationshipTemplate.ts +++ b/packages/transport/src/modules/relationshipTemplates/local/RelationshipTemplate.ts @@ -2,13 +2,14 @@ import { serialize, type, validate } from "@js-soft/ts-serval"; import { CoreDate, ICoreDate } from "@nmshd/core-types"; import { CryptoSecretKey, ICryptoSecretKey } from "@nmshd/crypto"; import { nameof } from "ts-simple-nameof"; -import { CoreSynchronizable, ICoreSynchronizable } from "../../../core"; +import { CoreSynchronizable, ICoreSynchronizable, IPasswordProtection, PasswordProtection } from "../../../core"; import { RelationshipTemplateReference } from "../transmission/RelationshipTemplateReference"; import { CachedRelationshipTemplate, ICachedRelationshipTemplate } from "./CachedRelationshipTemplate"; export interface IRelationshipTemplate extends ICoreSynchronizable { secretKey: ICryptoSecretKey; isOwn: boolean; + passwordProtection?: IPasswordProtection; cache?: ICachedRelationshipTemplate; cachedAt?: ICoreDate; metadata?: any; @@ -18,7 +19,7 @@ export interface IRelationshipTemplate extends ICoreSynchronizable { @type("RelationshipTemplate") export class RelationshipTemplate extends CoreSynchronizable implements IRelationshipTemplate { public override readonly technicalProperties = ["@type", "@context", nameof((r) => r.secretKey), nameof((r) => r.isOwn)]; - + public override readonly userdataProperties = [nameof((r) => r.passwordProtection)]; public override readonly metadataProperties = [nameof((r) => r.metadata), nameof((r) => r.metadataModifiedAt)]; @validate() @@ -29,6 +30,10 @@ export class RelationshipTemplate extends CoreSynchronizable implements IRelatio @serialize() public isOwn: boolean; + @validate({ nullable: true }) + @serialize() + public passwordProtection?: PasswordProtection; + @validate({ nullable: true }) @serialize() public cache?: CachedRelationshipTemplate; @@ -50,7 +55,12 @@ export class RelationshipTemplate extends CoreSynchronizable implements IRelatio } public toRelationshipTemplateReference(): RelationshipTemplateReference { - return RelationshipTemplateReference.from({ id: this.id, key: this.secretKey, forIdentityTruncated: this.cache!.forIdentity?.toString().slice(-4) }); + return RelationshipTemplateReference.from({ + id: this.id, + key: this.secretKey, + forIdentityTruncated: this.cache!.forIdentity?.toString().slice(-4), + passwordProtection: this.passwordProtection?.toSharedPasswordProtection() + }); } public truncate(): string { diff --git a/packages/transport/src/modules/relationshipTemplates/local/SendRelationshipTemplateParameters.ts b/packages/transport/src/modules/relationshipTemplates/local/SendRelationshipTemplateParameters.ts index 88ae0d7e5..66e9cce4a 100644 --- a/packages/transport/src/modules/relationshipTemplates/local/SendRelationshipTemplateParameters.ts +++ b/packages/transport/src/modules/relationshipTemplates/local/SendRelationshipTemplateParameters.ts @@ -1,5 +1,6 @@ import { ISerializable, Serializable, serialize, type, validate } from "@js-soft/ts-serval"; import { CoreAddress, CoreDate, ICoreAddress, ICoreDate } from "@nmshd/core-types"; +import { IPasswordProtectionCreationParameters, PasswordProtectionCreationParameters } from "../../../core/types/PasswordProtectionCreationParameters"; import { validateMaxNumberOfAllocations } from "./CachedRelationshipTemplate"; export interface ISendRelationshipTemplateParameters extends ISerializable { @@ -7,6 +8,7 @@ export interface ISendRelationshipTemplateParameters extends ISerializable { expiresAt: ICoreDate; maxNumberOfAllocations?: number; forIdentity?: ICoreAddress; + passwordProtection?: IPasswordProtectionCreationParameters; } @type("SendRelationshipTemplateParameters") @@ -27,6 +29,10 @@ export class SendRelationshipTemplateParameters extends Serializable implements @serialize() public forIdentity?: CoreAddress; + @validate({ nullable: true }) + @serialize() + public passwordProtection?: PasswordProtectionCreationParameters; + public static from(value: ISendRelationshipTemplateParameters): SendRelationshipTemplateParameters { return this.fromAny(value); } diff --git a/packages/transport/src/modules/tokens/transmission/TokenContentRelationshipTemplate.ts b/packages/transport/src/modules/tokens/transmission/TokenContentRelationshipTemplate.ts index c629e8c1c..695cdec50 100644 --- a/packages/transport/src/modules/tokens/transmission/TokenContentRelationshipTemplate.ts +++ b/packages/transport/src/modules/tokens/transmission/TokenContentRelationshipTemplate.ts @@ -1,11 +1,13 @@ import { ISerializable, Serializable, serialize, type, validate } from "@js-soft/ts-serval"; import { CoreAddress, CoreId, ICoreAddress, ICoreId } from "@nmshd/core-types"; import { CryptoSecretKey, ICryptoSecretKey } from "@nmshd/crypto"; +import { ISharedPasswordProtection, SharedPasswordProtection } from "../../../core/types/SharedPasswordProtection"; export interface ITokenContentRelationshipTemplate extends ISerializable { templateId: ICoreId; secretKey: ICryptoSecretKey; forIdentity?: ICoreAddress; + passwordProtection?: ISharedPasswordProtection; } @type("TokenContentRelationshipTemplate") @@ -22,6 +24,10 @@ export class TokenContentRelationshipTemplate extends Serializable implements IT @serialize() public forIdentity?: CoreAddress; + @validate({ nullable: true }) + @serialize() + public passwordProtection?: SharedPasswordProtection; + public static from(value: ITokenContentRelationshipTemplate): TokenContentRelationshipTemplate { return this.fromAny(value); } diff --git a/packages/transport/test/end2end/End2End.test.ts b/packages/transport/test/end2end/End2End.test.ts index e0f61efd1..56aa855a5 100644 --- a/packages/transport/test/end2end/End2End.test.ts +++ b/packages/transport/test/end2end/End2End.test.ts @@ -77,7 +77,7 @@ describe("RelationshipTest: Accept", function () { throw new Error("token content not instanceof TokenContentRelationshipTemplate"); } - const templateTo = await to.relationshipTemplates.loadPeerRelationshipTemplate(receivedToken.cache!.content.templateId, receivedToken.cache!.content.secretKey); + const templateTo = await to.relationshipTemplates.loadPeerRelationshipTemplateByTokenContent(receivedToken.cache!.content); expect(templateTo.cache!.content).toBeInstanceOf(JSONWrapper); const templateContent = templateTo.cache!.content as JSONWrapper; @@ -191,7 +191,7 @@ describe("RelationshipTest: Reject", function () { throw new Error("token content not instanceof TokenContentRelationshipTemplate"); } - const templateTo = await to.relationshipTemplates.loadPeerRelationshipTemplate(receivedToken.cache!.content.templateId, receivedToken.cache!.content.secretKey); + const templateTo = await to.relationshipTemplates.loadPeerRelationshipTemplateByTokenContent(receivedToken.cache!.content); expect(templateTo.cache!.content).toBeInstanceOf(JSONWrapper); const templateContent = templateTo.cache!.content as JSONWrapper; @@ -300,10 +300,7 @@ describe("RelationshipTest: Revoke", function () { throw new Error("token content not instanceof TokenContentRelationshipTemplate"); } - const templateRequestor = await requestor.relationshipTemplates.loadPeerRelationshipTemplate( - receivedToken.cache!.content.templateId, - receivedToken.cache!.content.secretKey - ); + const templateRequestor = await requestor.relationshipTemplates.loadPeerRelationshipTemplateByTokenContent(receivedToken.cache!.content); expect(templateRequestor.cache!.content).toBeInstanceOf(JSONWrapper); const templateContent = templateRequestor.cache!.content as JSONWrapper; @@ -387,7 +384,7 @@ describe("RelationshipTest: Revoke", function () { const receivedTemplateToken = TokenContentRelationshipTemplate.from(receivedToken.cache!.content as TokenContentRelationshipTemplate); - const templateRequestor = await requestor.relationshipTemplates.loadPeerRelationshipTemplate(receivedTemplateToken.templateId, receivedTemplateToken.secretKey); + const templateRequestor = await requestor.relationshipTemplates.loadPeerRelationshipTemplateByTokenContent(receivedTemplateToken); expect(templateRequestor.cache!.content).toBeInstanceOf(JSONWrapper); const templateContent = templateRequestor.cache!.content as JSONWrapper; @@ -870,7 +867,7 @@ describe("RelationshipTest: operation executioner validation (on pending relatio throw new Error("token content not instanceof TokenContentRelationshipTemplate"); } - const templateTo = await to.relationshipTemplates.loadPeerRelationshipTemplate(receivedToken.cache!.content.templateId, receivedToken.cache!.content.secretKey); + const templateTo = await to.relationshipTemplates.loadPeerRelationshipTemplateByTokenContent(receivedToken.cache!.content); const request = await to.relationships.sendRelationship({ template: templateTo, creationContent: { diff --git a/packages/transport/test/modules/files/FileReference.test.ts b/packages/transport/test/modules/files/FileReference.test.ts index 1d62a2fda..04e23d1ea 100644 --- a/packages/transport/test/modules/files/FileReference.test.ts +++ b/packages/transport/test/modules/files/FileReference.test.ts @@ -1,7 +1,7 @@ import { Serializable } from "@js-soft/ts-serval"; import { CoreId } from "@nmshd/core-types"; import { CoreBuffer, CryptoEncryption, CryptoSecretKey } from "@nmshd/crypto"; -import { BackboneIds, FileReference } from "../../../src"; +import { BackboneIds, CoreCrypto, FileReference } from "../../../src"; describe("FileReference", function () { test("should serialize and deserialize correctly (verbose)", async function () { @@ -46,13 +46,16 @@ describe("FileReference", function () { expect(deserialized.id.toString()).toStrictEqual(reference.id.toString()); }); - test("should serialize and deserialize correctly (verbose, with backbone, identity, password)", async function () { + test("should serialize and deserialize correctly (verbose, with backbone, identity, passwordProtection)", async function () { const reference = FileReference.from({ key: await CryptoEncryption.generateKey(), id: await BackboneIds.file.generateUnsafe(), backboneBaseUrl: "localhost", forIdentityTruncated: "1234", - passwordType: 10 + passwordProtection: { + passwordType: "pin10", + salt: await CoreCrypto.random(16) + } }); expect(reference).toBeInstanceOf(Serializable); expect(reference).toBeInstanceOf(FileReference); @@ -61,27 +64,32 @@ describe("FileReference", function () { const serialized = reference.serialize(); expect(typeof serialized).toBe("string"); expect(serialized).toBe( - `{"@type":"FileReference","backboneBaseUrl":"localhost","forIdentityTruncated":"1234","id":"${reference.id.toString()}","key":${reference.key.serialize(false)},"passwordType":10}` + `{"@type":"FileReference","backboneBaseUrl":"localhost","forIdentityTruncated":"1234","id":"${reference.id.toString()}","key":${reference.key.serialize(false)},"passwordProtection":{"passwordType":"pin10","salt":"${reference.passwordProtection!.salt.toBase64URL()}"}}` ); const deserialized = FileReference.deserialize(serialized); expect(deserialized).toBeInstanceOf(Serializable); expect(deserialized).toBeInstanceOf(FileReference); expect(deserialized.key).toBeInstanceOf(CryptoSecretKey); expect(deserialized.id).toBeInstanceOf(CoreId); + expect(deserialized.passwordProtection!.salt).toBeInstanceOf(CoreBuffer); expect(deserialized.key.toBase64()).toStrictEqual(reference.key.toBase64()); expect(deserialized.id.toString()).toStrictEqual(reference.id.toString()); expect(deserialized.backboneBaseUrl).toBe("localhost"); expect(deserialized.forIdentityTruncated).toBe("1234"); - expect(deserialized.passwordType).toBe(10); + expect(deserialized.passwordProtection!.passwordType).toBe("pin10"); + expect(deserialized.passwordProtection!.salt.toBase64URL()).toBe(reference.passwordProtection!.salt.toBase64URL()); }); - test("should serialize and deserialize correctly (from unknown type, with backbone, identity, password)", async function () { + test("should serialize and deserialize correctly (from unknown type, with backbone, identity, passwordProtection)", async function () { const reference = FileReference.from({ key: await CryptoEncryption.generateKey(), id: await BackboneIds.file.generateUnsafe(), backboneBaseUrl: "localhost", forIdentityTruncated: "1234", - passwordType: 10 + passwordProtection: { + passwordType: "pw", + salt: await CoreCrypto.random(16) + } }); expect(reference).toBeInstanceOf(Serializable); expect(reference).toBeInstanceOf(FileReference); @@ -90,18 +98,20 @@ describe("FileReference", function () { const serialized = reference.serialize(); expect(typeof serialized).toBe("string"); expect(serialized).toBe( - `{"@type":"FileReference","backboneBaseUrl":"localhost","forIdentityTruncated":"1234","id":"${reference.id.toString()}","key":${reference.key.serialize(false)},"passwordType":10}` + `{"@type":"FileReference","backboneBaseUrl":"localhost","forIdentityTruncated":"1234","id":"${reference.id.toString()}","key":${reference.key.serialize(false)},"passwordProtection":{"passwordType":"pw","salt":"${reference.passwordProtection!.salt.toBase64URL()}"}}` ); const deserialized = Serializable.deserializeUnknown(serialized) as FileReference; expect(deserialized).toBeInstanceOf(Serializable); expect(deserialized).toBeInstanceOf(FileReference); expect(deserialized.key).toBeInstanceOf(CryptoSecretKey); expect(deserialized.id).toBeInstanceOf(CoreId); + expect(deserialized.passwordProtection!.salt).toBeInstanceOf(CoreBuffer); expect(deserialized.key.toBase64()).toStrictEqual(reference.key.toBase64()); expect(deserialized.id.toString()).toStrictEqual(reference.id.toString()); expect(deserialized.backboneBaseUrl).toBe("localhost"); expect(deserialized.forIdentityTruncated).toBe("1234"); - expect(deserialized.passwordType).toBe(10); + expect(deserialized.passwordProtection!.passwordType).toBe("pw"); + expect(deserialized.passwordProtection!.salt.toBase64URL()).toBe(reference.passwordProtection!.salt.toBase64URL()); }); test("should truncate and read in correctly", async function () { @@ -110,7 +120,7 @@ describe("FileReference", function () { id: await BackboneIds.file.generateUnsafe() }); const truncated = reference.truncate(); - expect(truncated.length).toBeLessThan(115); + expect(truncated.length).toBeLessThan(155); expect(truncated.length).toBeGreaterThan(80); const deserialized = FileReference.fromTruncated(truncated); expect(deserialized).toBeInstanceOf(Serializable); @@ -121,27 +131,32 @@ describe("FileReference", function () { expect(deserialized.id.toString()).toStrictEqual(reference.id.toString()); }); - test("should truncate and read in correctly with backbone, identity, password", async function () { + test("should truncate and read in correctly with backbone, identity, passwordProtection", async function () { const reference = FileReference.from({ key: await CryptoEncryption.generateKey(), id: await BackboneIds.file.generateUnsafe(), backboneBaseUrl: "localhost", forIdentityTruncated: "1234", - passwordType: 10 + passwordProtection: { + passwordType: "pin10", + salt: await CoreCrypto.random(16) + } }); const truncated = reference.truncate(); - expect(truncated.length).toBeLessThan(115); + expect(truncated.length).toBeLessThan(155); expect(truncated.length).toBeGreaterThan(80); const deserialized = FileReference.fromTruncated(truncated); expect(deserialized).toBeInstanceOf(Serializable); expect(deserialized).toBeInstanceOf(FileReference); expect(deserialized.key).toBeInstanceOf(CryptoSecretKey); expect(deserialized.id).toBeInstanceOf(CoreId); + expect(deserialized.passwordProtection!.salt).toBeInstanceOf(CoreBuffer); expect(deserialized.key.toBase64()).toStrictEqual(reference.key.toBase64()); expect(deserialized.id.toString()).toStrictEqual(reference.id.toString()); expect(deserialized.backboneBaseUrl).toBe("localhost"); expect(deserialized.forIdentityTruncated).toBe("1234"); - expect(deserialized.passwordType).toBe(10); + expect(deserialized.passwordProtection!.passwordType).toBe("pin10"); + expect(deserialized.passwordProtection!.salt.toBase64URL()).toBe(reference.passwordProtection!.salt.toBase64URL()); }); test("should read a reference in the old format", async function () { @@ -150,7 +165,7 @@ describe("FileReference", function () { id: await BackboneIds.file.generateUnsafe() }); const truncated = CoreBuffer.fromUtf8(`${reference.id.toString()}|${reference.key.algorithm}|${reference.key.secretKey.toBase64URL()}`).toBase64URL(); - expect(truncated.length).toBeLessThan(115); + expect(truncated.length).toBeLessThan(155); expect(truncated.length).toBeGreaterThan(80); const deserialized = FileReference.fromTruncated(truncated); expect(deserialized).toBeInstanceOf(Serializable); @@ -166,9 +181,12 @@ describe("FileReference", function () { FileReference.from({ key: await CryptoEncryption.generateKey(), id: await BackboneIds.file.generateUnsafe(), - passwordType: 20 + passwordProtection: { + passwordType: "pin20", + salt: await CoreCrypto.random(16) + } }); - }).rejects.toThrow("FileReference.passwordType"); + }).rejects.toThrow("SharedPasswordProtection.passwordType"); }); test("should not create a reference with non-integer passwordType", async function () { @@ -176,9 +194,48 @@ describe("FileReference", function () { FileReference.from({ key: await CryptoEncryption.generateKey(), id: await BackboneIds.file.generateUnsafe(), - passwordType: 2.4 + passwordProtection: { + passwordType: "pin2.4", + salt: await CoreCrypto.random(16) + } }); - }).rejects.toThrow("FileReference.passwordType"); + }).rejects.toThrow("SharedPasswordProtection.passwordType"); + }); + + test("should not create a reference with passwordType starting with neither pw nor pin", async function () { + await expect(async () => { + FileReference.from({ + key: await CryptoEncryption.generateKey(), + id: await BackboneIds.file.generateUnsafe(), + passwordProtection: { + passwordType: "pc", + salt: await CoreCrypto.random(16) + } + }); + }).rejects.toThrow("SharedPasswordProtection.passwordType"); + }); + + test("should not load a reference with a non-base64 salt", async function () { + const reference = FileReference.from({ + key: await CryptoEncryption.generateKey(), + id: await BackboneIds.file.generateUnsafe() + }); + + const truncated = CoreBuffer.fromUtf8(`${reference.id.toString()}|${reference.key.algorithm}|${reference.key.secretKey.toBase64URL()}||wrong-salt&pw`).toBase64URL(); + expect(() => FileReference.fromTruncated(truncated)).toThrow("The salt needs to be a Base64 value."); + }); + + test("should not create a reference with a salt of wrong length", async function () { + await expect(async () => { + FileReference.from({ + key: await CryptoEncryption.generateKey(), + id: await BackboneIds.file.generateUnsafe(), + passwordProtection: { + passwordType: "pw", + salt: await CoreCrypto.random(8) + } + }); + }).rejects.toThrow("must be 16 bytes long"); }); test("should not create a reference with too long personalization", async function () { diff --git a/packages/transport/test/modules/relationshipTemplates/RelationshipTemplateController.test.ts b/packages/transport/test/modules/relationshipTemplates/RelationshipTemplateController.test.ts index 76833487e..9053c4b9d 100644 --- a/packages/transport/test/modules/relationshipTemplates/RelationshipTemplateController.test.ts +++ b/packages/transport/test/modules/relationshipTemplates/RelationshipTemplateController.test.ts @@ -1,6 +1,6 @@ import { IDatabaseConnection } from "@js-soft/docdb-access-abstractions"; import { CoreDate, CoreId } from "@nmshd/core-types"; -import { AccountController, RelationshipTemplate, Transport } from "../../../src"; +import { AccountController, RelationshipTemplate, TokenContentRelationshipTemplate, Transport } from "../../../src"; import { TestUtil } from "../../testHelpers/TestUtil"; describe("RelationshipTemplateController", function () { @@ -49,7 +49,8 @@ describe("RelationshipTemplateController", function () { tempDate = CoreDate.utc().subtract(TestUtil.tempDateThreshold); const sentRelationshipTemplate = await TestUtil.sendRelationshipTemplate(sender); - const receivedRelationshipTemplate = await recipient.relationshipTemplates.loadPeerRelationshipTemplate(sentRelationshipTemplate.id, sentRelationshipTemplate.secretKey); + const templateReference = sentRelationshipTemplate.toRelationshipTemplateReference().truncate(); + const receivedRelationshipTemplate = await recipient.relationshipTemplates.loadPeerRelationshipTemplateByTruncated(templateReference); tempId1 = sentRelationshipTemplate.id; expectValidRelationshipTemplates(sentRelationshipTemplate, receivedRelationshipTemplate, tempDate); @@ -67,7 +68,8 @@ describe("RelationshipTemplateController", function () { tempDate = CoreDate.utc().subtract(TestUtil.tempDateThreshold); const sentRelationshipTemplate = await TestUtil.sendRelationshipTemplate(sender); - const receivedRelationshipTemplate = await recipient.relationshipTemplates.loadPeerRelationshipTemplate(sentRelationshipTemplate.id, sentRelationshipTemplate.secretKey); + const templateReference = sentRelationshipTemplate.toRelationshipTemplateReference().truncate(); + const receivedRelationshipTemplate = await recipient.relationshipTemplates.loadPeerRelationshipTemplateByTruncated(templateReference); tempId2 = sentRelationshipTemplate.id; expectValidRelationshipTemplates(sentRelationshipTemplate, receivedRelationshipTemplate, tempDate); @@ -77,8 +79,8 @@ describe("RelationshipTemplateController", function () { tempDate = CoreDate.utc().subtract(TestUtil.tempDateThreshold); const sentRelationshipTemplate = await TestUtil.sendRelationshipTemplate(sender); - const receivedRelationshipTemplate = await recipient.relationshipTemplates.loadPeerRelationshipTemplate(sentRelationshipTemplate.id, sentRelationshipTemplate.secretKey); - + const templateReference = sentRelationshipTemplate.toRelationshipTemplateReference().truncate(); + const receivedRelationshipTemplate = await recipient.relationshipTemplates.loadPeerRelationshipTemplateByTruncated(templateReference); expectValidRelationshipTemplates(sentRelationshipTemplate, receivedRelationshipTemplate, tempDate); }); @@ -101,7 +103,8 @@ describe("RelationshipTemplateController", function () { }); expect(ownTemplate).toBeDefined(); - const peerTemplate = await recipient.relationshipTemplates.loadPeerRelationshipTemplate(ownTemplate.id, ownTemplate.secretKey); + const templateReference = ownTemplate.toRelationshipTemplateReference().truncate(); + const peerTemplate = await recipient.relationshipTemplates.loadPeerRelationshipTemplateByTruncated(templateReference); expect(peerTemplate).toBeDefined(); }); @@ -145,7 +148,80 @@ describe("RelationshipTemplateController", function () { forIdentity: sender.identity.address }); - await expect(recipient.relationshipTemplates.loadPeerRelationshipTemplate(ownTemplate.id, ownTemplate.secretKey)).rejects.toThrow("error.platform.recordNotFound"); + const tokenContent = TokenContentRelationshipTemplate.from({ + templateId: ownTemplate.id, + secretKey: ownTemplate.secretKey + }); + await expect(recipient.relationshipTemplates.loadPeerRelationshipTemplateByTokenContent(tokenContent)).rejects.toThrow("error.platform.recordNotFound"); + }); + + test("should create and load a password-protected template", async function () { + const ownTemplate = await sender.relationshipTemplates.sendRelationshipTemplate({ + content: { a: "A" }, + expiresAt: CoreDate.utc().add({ minutes: 1 }), + passwordProtection: { + password: "password", + passwordType: "pw" + } + }); + expect(ownTemplate).toBeDefined(); + expect(ownTemplate.passwordProtection!.password).toBe("password"); + expect(ownTemplate.passwordProtection!.salt).toBeDefined(); + expect(ownTemplate.passwordProtection!.salt).toHaveLength(16); + expect(ownTemplate.passwordProtection!.passwordType).toBe("pw"); + + const reference = ownTemplate.toRelationshipTemplateReference(); + expect(reference.passwordProtection!.passwordType).toBe("pw"); + expect(reference.passwordProtection!.salt).toStrictEqual(ownTemplate.passwordProtection!.salt); + + const peerTemplate = await recipient.relationshipTemplates.loadPeerRelationshipTemplateByTruncated(reference.truncate(), "password"); + expect(peerTemplate).toBeDefined(); + expect(peerTemplate.passwordProtection!.password).toBe("password"); + expect(peerTemplate.passwordProtection!.salt).toStrictEqual(ownTemplate.passwordProtection!.salt); + expect(peerTemplate.passwordProtection!.passwordType).toBe("pw"); + }); + + test("should throw an error if loaded with a wrong or missing password", async function () { + const ownTemplate = await sender.relationshipTemplates.sendRelationshipTemplate({ + content: { a: "A" }, + expiresAt: CoreDate.utc().add({ minutes: 1 }), + passwordProtection: { + password: "1234", + passwordType: "pin4" + } + }); + expect(ownTemplate).toBeDefined(); + + await expect( + recipient.relationshipTemplates.loadPeerRelationshipTemplateByTruncated(ownTemplate.toRelationshipTemplateReference().truncate(), "wrongPassword") + ).rejects.toThrow("error.platform.recordNotFound (404): 'RelationshipTemplate not found. Make sure the ID exists and the record is not expired.'"); + await expect(recipient.relationshipTemplates.loadPeerRelationshipTemplateByTruncated(ownTemplate.toRelationshipTemplateReference().truncate())).rejects.toThrow( + "error.transport.noPasswordProvided" + ); + }); + + test("should fetch multiple password-protected templates", async function () { + const ownTemplate1 = await sender.relationshipTemplates.sendRelationshipTemplate({ + content: { a: "A" }, + expiresAt: CoreDate.utc().add({ minutes: 1 }), + passwordProtection: { + password: "password", + passwordType: "pw" + } + }); + const ownTemplate2 = await sender.relationshipTemplates.sendRelationshipTemplate({ + content: { a: "A" }, + expiresAt: CoreDate.utc().add({ minutes: 1 }), + passwordProtection: { + password: "1234", + passwordType: "pin4" + } + }); + + await recipient.relationshipTemplates.loadPeerRelationshipTemplateByTruncated(ownTemplate1.toRelationshipTemplateReference().truncate(), "password"); + await recipient.relationshipTemplates.loadPeerRelationshipTemplateByTruncated(ownTemplate2.toRelationshipTemplateReference().truncate(), "1234"); + const fetchCachesResult = await recipient.relationshipTemplates.fetchCaches([ownTemplate1.id, ownTemplate2.id]); + expect(fetchCachesResult).toHaveLength(2); }); test("should send and receive a RelationshipTemplate using a truncated RelationshipTemplateReference", async function () { diff --git a/packages/transport/test/modules/relationshipTemplates/RelationshipTemplateReference.test.ts b/packages/transport/test/modules/relationshipTemplates/RelationshipTemplateReference.test.ts index 412cd8658..416e5d64f 100644 --- a/packages/transport/test/modules/relationshipTemplates/RelationshipTemplateReference.test.ts +++ b/packages/transport/test/modules/relationshipTemplates/RelationshipTemplateReference.test.ts @@ -1,7 +1,7 @@ import { Serializable } from "@js-soft/ts-serval"; import { CoreId } from "@nmshd/core-types"; import { CoreBuffer, CryptoEncryption, CryptoSecretKey } from "@nmshd/crypto"; -import { BackboneIds, RelationshipTemplateReference } from "../../../src"; +import { BackboneIds, CoreCrypto, RelationshipTemplateReference } from "../../../src"; describe("RelationshipTemplateReference", function () { test("should serialize and deserialize correctly (verbose)", async function () { @@ -46,13 +46,16 @@ describe("RelationshipTemplateReference", function () { expect(deserialized.id.toString()).toStrictEqual(reference.id.toString()); }); - test("should serialize and deserialize correctly (verbose, with backbone, identity, password)", async function () { + test("should serialize and deserialize correctly (verbose, with backbone, identity, passwordProtection)", async function () { const reference = RelationshipTemplateReference.from({ key: await CryptoEncryption.generateKey(), id: await BackboneIds.relationshipTemplate.generateUnsafe(), backboneBaseUrl: "localhost", forIdentityTruncated: "1234", - passwordType: 10 + passwordProtection: { + passwordType: "pin10", + salt: await CoreCrypto.random(16) + } }); expect(reference).toBeInstanceOf(Serializable); expect(reference).toBeInstanceOf(RelationshipTemplateReference); @@ -61,27 +64,32 @@ describe("RelationshipTemplateReference", function () { const serialized = reference.serialize(); expect(typeof serialized).toBe("string"); expect(serialized).toBe( - `{"@type":"RelationshipTemplateReference","backboneBaseUrl":"localhost","forIdentityTruncated":"1234","id":"${reference.id.toString()}","key":${reference.key.serialize(false)},"passwordType":10}` + `{"@type":"RelationshipTemplateReference","backboneBaseUrl":"localhost","forIdentityTruncated":"1234","id":"${reference.id.toString()}","key":${reference.key.serialize(false)},"passwordProtection":{"passwordType":"pin10","salt":"${reference.passwordProtection!.salt.toBase64URL()}"}}` ); const deserialized = RelationshipTemplateReference.deserialize(serialized); expect(deserialized).toBeInstanceOf(Serializable); expect(deserialized).toBeInstanceOf(RelationshipTemplateReference); expect(deserialized.key).toBeInstanceOf(CryptoSecretKey); expect(deserialized.id).toBeInstanceOf(CoreId); + expect(deserialized.passwordProtection!.salt).toBeInstanceOf(CoreBuffer); expect(deserialized.key.toBase64()).toStrictEqual(reference.key.toBase64()); expect(deserialized.id.toString()).toStrictEqual(reference.id.toString()); expect(deserialized.backboneBaseUrl).toBe("localhost"); expect(deserialized.forIdentityTruncated).toBe("1234"); - expect(deserialized.passwordType).toBe(10); + expect(deserialized.passwordProtection!.passwordType).toBe("pin10"); + expect(deserialized.passwordProtection!.salt.toBase64URL()).toBe(reference.passwordProtection!.salt.toBase64URL()); }); - test("should serialize and deserialize correctly (from unknown type, with backbone, identity, password)", async function () { + test("should serialize and deserialize correctly (from unknown type, with backbone, identity, passwordProtection)", async function () { const reference = RelationshipTemplateReference.from({ key: await CryptoEncryption.generateKey(), id: await BackboneIds.relationshipTemplate.generateUnsafe(), backboneBaseUrl: "localhost", forIdentityTruncated: "1234", - passwordType: 10 + passwordProtection: { + passwordType: "pw", + salt: await CoreCrypto.random(16) + } }); expect(reference).toBeInstanceOf(Serializable); expect(reference).toBeInstanceOf(RelationshipTemplateReference); @@ -90,18 +98,20 @@ describe("RelationshipTemplateReference", function () { const serialized = reference.serialize(); expect(typeof serialized).toBe("string"); expect(serialized).toBe( - `{"@type":"RelationshipTemplateReference","backboneBaseUrl":"localhost","forIdentityTruncated":"1234","id":"${reference.id.toString()}","key":${reference.key.serialize(false)},"passwordType":10}` + `{"@type":"RelationshipTemplateReference","backboneBaseUrl":"localhost","forIdentityTruncated":"1234","id":"${reference.id.toString()}","key":${reference.key.serialize(false)},"passwordProtection":{"passwordType":"pw","salt":"${reference.passwordProtection!.salt.toBase64URL()}"}}` ); const deserialized = Serializable.deserializeUnknown(serialized) as RelationshipTemplateReference; expect(deserialized).toBeInstanceOf(Serializable); expect(deserialized).toBeInstanceOf(RelationshipTemplateReference); expect(deserialized.key).toBeInstanceOf(CryptoSecretKey); expect(deserialized.id).toBeInstanceOf(CoreId); + expect(deserialized.passwordProtection!.salt).toBeInstanceOf(CoreBuffer); expect(deserialized.key.toBase64()).toStrictEqual(reference.key.toBase64()); expect(deserialized.id.toString()).toStrictEqual(reference.id.toString()); expect(deserialized.backboneBaseUrl).toBe("localhost"); expect(deserialized.forIdentityTruncated).toBe("1234"); - expect(deserialized.passwordType).toBe(10); + expect(deserialized.passwordProtection!.passwordType).toBe("pw"); + expect(deserialized.passwordProtection!.salt.toBase64URL()).toBe(reference.passwordProtection!.salt.toBase64URL()); }); test("should truncate and read in correctly", async function () { @@ -110,7 +120,7 @@ describe("RelationshipTemplateReference", function () { id: await BackboneIds.relationshipTemplate.generateUnsafe() }); const truncated = reference.truncate(); - expect(truncated.length).toBeLessThan(115); + expect(truncated.length).toBeLessThan(155); expect(truncated.length).toBeGreaterThan(80); const deserialized = RelationshipTemplateReference.fromTruncated(truncated); expect(deserialized).toBeInstanceOf(Serializable); @@ -121,27 +131,32 @@ describe("RelationshipTemplateReference", function () { expect(deserialized.id.toString()).toStrictEqual(reference.id.toString()); }); - test("should truncate and read in correctly with backbone, identity, password", async function () { + test("should truncate and read in correctly with backbone, identity, passwordProtection", async function () { const reference = RelationshipTemplateReference.from({ key: await CryptoEncryption.generateKey(), id: await BackboneIds.relationshipTemplate.generateUnsafe(), backboneBaseUrl: "localhost", forIdentityTruncated: "1234", - passwordType: 10 + passwordProtection: { + passwordType: "pin10", + salt: await CoreCrypto.random(16) + } }); const truncated = reference.truncate(); - expect(truncated.length).toBeLessThan(115); + expect(truncated.length).toBeLessThan(155); expect(truncated.length).toBeGreaterThan(80); const deserialized = RelationshipTemplateReference.fromTruncated(truncated); expect(deserialized).toBeInstanceOf(Serializable); expect(deserialized).toBeInstanceOf(RelationshipTemplateReference); expect(deserialized.key).toBeInstanceOf(CryptoSecretKey); expect(deserialized.id).toBeInstanceOf(CoreId); + expect(deserialized.passwordProtection!.salt).toBeInstanceOf(CoreBuffer); expect(deserialized.key.toBase64()).toStrictEqual(reference.key.toBase64()); expect(deserialized.id.toString()).toStrictEqual(reference.id.toString()); expect(deserialized.backboneBaseUrl).toBe("localhost"); expect(deserialized.forIdentityTruncated).toBe("1234"); - expect(deserialized.passwordType).toBe(10); + expect(deserialized.passwordProtection!.passwordType).toBe("pin10"); + expect(deserialized.passwordProtection!.salt.toBase64URL()).toBe(reference.passwordProtection!.salt.toBase64URL()); }); test("should read a reference in the old format", async function () { @@ -150,7 +165,7 @@ describe("RelationshipTemplateReference", function () { id: await BackboneIds.relationshipTemplate.generateUnsafe() }); const truncated = CoreBuffer.fromUtf8(`${reference.id.toString()}|${reference.key.algorithm}|${reference.key.secretKey.toBase64URL()}`).toBase64URL(); - expect(truncated.length).toBeLessThan(115); + expect(truncated.length).toBeLessThan(155); expect(truncated.length).toBeGreaterThan(80); const deserialized = RelationshipTemplateReference.fromTruncated(truncated); expect(deserialized).toBeInstanceOf(Serializable); @@ -166,9 +181,12 @@ describe("RelationshipTemplateReference", function () { RelationshipTemplateReference.from({ key: await CryptoEncryption.generateKey(), id: await BackboneIds.relationshipTemplate.generateUnsafe(), - passwordType: 20 + passwordProtection: { + passwordType: "pin20", + salt: await CoreCrypto.random(16) + } }); - }).rejects.toThrow("RelationshipTemplateReference.passwordType"); + }).rejects.toThrow("SharedPasswordProtection.passwordType"); }); test("should not create a reference with non-integer passwordType", async function () { @@ -176,9 +194,48 @@ describe("RelationshipTemplateReference", function () { RelationshipTemplateReference.from({ key: await CryptoEncryption.generateKey(), id: await BackboneIds.relationshipTemplate.generateUnsafe(), - passwordType: 2.4 + passwordProtection: { + passwordType: "pin2.4", + salt: await CoreCrypto.random(16) + } }); - }).rejects.toThrow("RelationshipTemplateReference.passwordType"); + }).rejects.toThrow("SharedPasswordProtection.passwordType"); + }); + + test("should not create a reference with passwordType starting with neither pw nor pin", async function () { + await expect(async () => { + RelationshipTemplateReference.from({ + key: await CryptoEncryption.generateKey(), + id: await BackboneIds.relationshipTemplate.generateUnsafe(), + passwordProtection: { + passwordType: "pc", + salt: await CoreCrypto.random(16) + } + }); + }).rejects.toThrow("SharedPasswordProtection.passwordType"); + }); + + test("should not load a reference with a non-base64 salt", async function () { + const reference = RelationshipTemplateReference.from({ + key: await CryptoEncryption.generateKey(), + id: await BackboneIds.relationshipTemplate.generateUnsafe() + }); + + const truncated = CoreBuffer.fromUtf8(`${reference.id.toString()}|${reference.key.algorithm}|${reference.key.secretKey.toBase64URL()}||wrong-salt&pw`).toBase64URL(); + expect(() => RelationshipTemplateReference.fromTruncated(truncated)).toThrow("The salt needs to be a Base64 value."); + }); + + test("should not create a reference with a salt of wrong length", async function () { + await expect(async () => { + RelationshipTemplateReference.from({ + key: await CryptoEncryption.generateKey(), + id: await BackboneIds.relationshipTemplate.generateUnsafe(), + passwordProtection: { + passwordType: "pw", + salt: await CoreCrypto.random(8) + } + }); + }).rejects.toThrow("must be 16 bytes long"); }); test("should not create a reference with too long personalization", async function () { diff --git a/packages/transport/test/modules/sync/SyncController.error.test.ts b/packages/transport/test/modules/sync/SyncController.error.test.ts index de9fea63f..022800df1 100644 --- a/packages/transport/test/modules/sync/SyncController.error.test.ts +++ b/packages/transport/test/modules/sync/SyncController.error.test.ts @@ -28,10 +28,8 @@ describe("SyncController.error", function () { // in the ExternalEventsProcessor of templatorDevice2, because the template // doesn't exist on templatorDevice2 - const templateOnRequestorDevice = await requestorDevice.relationshipTemplates.loadPeerRelationshipTemplate( - templateOnTemplatorDevice.id, - templateOnTemplatorDevice.secretKey - ); + const reference = templateOnTemplatorDevice.toRelationshipTemplateReference().truncate(); + const templateOnRequestorDevice = await requestorDevice.relationshipTemplates.loadPeerRelationshipTemplateByTruncated(reference); await requestorDevice.relationships.sendRelationship({ template: templateOnRequestorDevice, diff --git a/packages/transport/test/modules/sync/SyncController.relationships.test.ts b/packages/transport/test/modules/sync/SyncController.relationships.test.ts index bda4e5d40..5294ec4f4 100644 --- a/packages/transport/test/modules/sync/SyncController.relationships.test.ts +++ b/packages/transport/test/modules/sync/SyncController.relationships.test.ts @@ -25,10 +25,8 @@ describe("RelationshipSync", function () { maxNumberOfAllocations: 1 }); - const templateOnRequestorDevice1 = await requestorDevice1.relationshipTemplates.loadPeerRelationshipTemplate( - templateOnTemplatorDevice.id, - templateOnTemplatorDevice.secretKey - ); + const reference = templateOnTemplatorDevice.toRelationshipTemplateReference().truncate(); + const templateOnRequestorDevice1 = await requestorDevice1.relationshipTemplates.loadPeerRelationshipTemplateByTruncated(reference); const createdRelationship = await requestorDevice1.relationships.sendRelationship({ template: templateOnRequestorDevice1, @@ -80,10 +78,8 @@ describe("RelationshipSync", function () { maxNumberOfAllocations: 1 }); - const templateOnRequestorDevice1 = await requestorDevice1.relationshipTemplates.loadPeerRelationshipTemplate( - templateOnTemplatorDevice.id, - templateOnTemplatorDevice.secretKey - ); + const reference = templateOnTemplatorDevice.toRelationshipTemplateReference().truncate(); + const templateOnRequestorDevice1 = await requestorDevice1.relationshipTemplates.loadPeerRelationshipTemplateByTruncated(reference); const createdRelationship = await requestorDevice1.relationships.sendRelationship({ template: templateOnRequestorDevice1, @@ -129,10 +125,8 @@ describe("RelationshipSync", function () { }); await templatorDevice1.syncDatawallet(); - const templateOnRequestorDevice1 = await requestorDevice.relationshipTemplates.loadPeerRelationshipTemplate( - templateOnTemplatorDevice.id, - templateOnTemplatorDevice.secretKey - ); + const reference = templateOnTemplatorDevice.toRelationshipTemplateReference().truncate(); + const templateOnRequestorDevice1 = await requestorDevice.relationshipTemplates.loadPeerRelationshipTemplateByTruncated(reference); const createdRelationship = await requestorDevice.relationships.sendRelationship({ template: templateOnRequestorDevice1, diff --git a/packages/transport/test/modules/tokens/TokenContent.test.ts b/packages/transport/test/modules/tokens/TokenContent.test.ts index 8143c9d8d..c4c56f6c1 100644 --- a/packages/transport/test/modules/tokens/TokenContent.test.ts +++ b/packages/transport/test/modules/tokens/TokenContent.test.ts @@ -1,8 +1,17 @@ import { IDatabaseConnection } from "@js-soft/docdb-access-abstractions"; import { JSONWrapper, Serializable } from "@js-soft/ts-serval"; import { CoreAddress, CoreDate, CoreId } from "@nmshd/core-types"; -import { CryptoEncryption, CryptoSecretKey } from "@nmshd/crypto"; -import { AccountController, CoreIdHelper, DeviceSharedSecret, TokenContentDeviceSharedSecret, TokenContentFile, TokenContentRelationshipTemplate, Transport } from "../../../src"; +import { CoreBuffer, CryptoEncryption, CryptoSecretKey } from "@nmshd/crypto"; +import { + AccountController, + CoreCrypto, + CoreIdHelper, + DeviceSharedSecret, + TokenContentDeviceSharedSecret, + TokenContentFile, + TokenContentRelationshipTemplate, + Transport +} from "../../../src"; import { TestUtil } from "../../testHelpers/TestUtil"; describe("TokenContent", function () { @@ -65,7 +74,11 @@ describe("TokenContent", function () { const token = TokenContentRelationshipTemplate.from({ secretKey: await CryptoEncryption.generateKey(), templateId: await CoreIdHelper.notPrefixed.generate(), - forIdentity: CoreAddress.from("did:e:a-domain:dids:anidentity") + forIdentity: CoreAddress.from("did:e:a-domain:dids:anidentity"), + passwordProtection: { + passwordType: "pw", + salt: await CoreCrypto.random(16) + } }); expect(token).toBeInstanceOf(Serializable); expect(token).toBeInstanceOf(TokenContentRelationshipTemplate); @@ -74,7 +87,7 @@ describe("TokenContent", function () { const serialized = token.serialize(); expect(typeof serialized).toBe("string"); expect(serialized).toBe( - `{"@type":"TokenContentRelationshipTemplate","forIdentity":"${token.forIdentity!.serialize()}","secretKey":${token.secretKey.serialize(false)},"templateId":"${token.templateId.toString()}"}` + `{"@type":"TokenContentRelationshipTemplate","forIdentity":"${token.forIdentity!.serialize()}","passwordProtection":{"passwordType":"${token.passwordProtection!.passwordType}","salt":"${token.passwordProtection!.salt.toBase64URL()}"},"secretKey":${token.secretKey.serialize(false)},"templateId":"${token.templateId.toString()}"}` ); const deserialized = TokenContentRelationshipTemplate.deserialize(serialized); expect(deserialized).toBeInstanceOf(Serializable); @@ -82,16 +95,23 @@ describe("TokenContent", function () { expect(deserialized.secretKey).toBeInstanceOf(CryptoSecretKey); expect(deserialized.templateId).toBeInstanceOf(CoreId); expect(deserialized.forIdentity).toBeInstanceOf(CoreAddress); + expect(deserialized.passwordProtection!.salt).toBeInstanceOf(CoreBuffer); expect(deserialized.secretKey.toBase64()).toStrictEqual(token.secretKey.toBase64()); expect(deserialized.templateId.toString()).toStrictEqual(token.templateId.toString()); expect(deserialized.forIdentity!.toString()).toStrictEqual(token.forIdentity!.toString()); + expect(deserialized.passwordProtection!.passwordType).toBe("pw"); + expect(deserialized.passwordProtection!.salt).toStrictEqual(token.passwordProtection!.salt); }); test("should serialize and deserialize correctly (no type information)", async function () { const token = TokenContentRelationshipTemplate.from({ secretKey: await CryptoEncryption.generateKey(), templateId: await CoreIdHelper.notPrefixed.generate(), - forIdentity: CoreAddress.from("did:e:a-domain:dids:anidentity") + forIdentity: CoreAddress.from("did:e:a-domain:dids:anidentity"), + passwordProtection: { + passwordType: "pin10", + salt: await CoreCrypto.random(16) + } }); expect(token).toBeInstanceOf(Serializable); expect(token).toBeInstanceOf(TokenContentRelationshipTemplate); @@ -105,16 +125,23 @@ describe("TokenContent", function () { expect(deserialized.secretKey).toBeInstanceOf(CryptoSecretKey); expect(deserialized.templateId).toBeInstanceOf(CoreId); expect(deserialized.forIdentity).toBeInstanceOf(CoreAddress); + expect(deserialized.passwordProtection!.salt).toBeInstanceOf(CoreBuffer); expect(deserialized.secretKey.toBase64()).toStrictEqual(token.secretKey.toBase64()); expect(deserialized.templateId.toString()).toStrictEqual(token.templateId.toString()); expect(deserialized.forIdentity!.toString()).toStrictEqual(token.forIdentity!.toString()); + expect(deserialized.passwordProtection!.passwordType).toBe("pin10"); + expect(deserialized.passwordProtection!.salt).toStrictEqual(token.passwordProtection!.salt); }); test("should serialize and deserialize correctly (from unknown type)", async function () { const token = TokenContentRelationshipTemplate.from({ secretKey: await CryptoEncryption.generateKey(), templateId: await CoreIdHelper.notPrefixed.generate(), - forIdentity: CoreAddress.from("did:e:a-domain:dids:anidentity") + forIdentity: CoreAddress.from("did:e:a-domain:dids:anidentity"), + passwordProtection: { + passwordType: "pw", + salt: await CoreCrypto.random(16) + } }); expect(token).toBeInstanceOf(Serializable); expect(token).toBeInstanceOf(TokenContentRelationshipTemplate); @@ -123,7 +150,7 @@ describe("TokenContent", function () { const serialized = token.serialize(); expect(typeof serialized).toBe("string"); expect(serialized).toBe( - `{"@type":"TokenContentRelationshipTemplate","forIdentity":"${token.forIdentity!.serialize()}","secretKey":${token.secretKey.serialize(false)},"templateId":"${token.templateId.toString()}"}` + `{"@type":"TokenContentRelationshipTemplate","forIdentity":"${token.forIdentity!.serialize()}","passwordProtection":{"passwordType":"${token.passwordProtection!.passwordType}","salt":"${token.passwordProtection!.salt.toBase64URL()}"},"secretKey":${token.secretKey.serialize(false)},"templateId":"${token.templateId.toString()}"}` ); const deserialized = Serializable.deserializeUnknown(serialized) as TokenContentRelationshipTemplate; expect(deserialized).toBeInstanceOf(Serializable); @@ -131,9 +158,64 @@ describe("TokenContent", function () { expect(deserialized.secretKey).toBeInstanceOf(CryptoSecretKey); expect(deserialized.templateId).toBeInstanceOf(CoreId); expect(deserialized.forIdentity).toBeInstanceOf(CoreAddress); + expect(deserialized.passwordProtection!.salt).toBeInstanceOf(CoreBuffer); expect(deserialized.secretKey.toBase64()).toStrictEqual(token.secretKey.toBase64()); expect(deserialized.templateId.toString()).toStrictEqual(token.templateId.toString()); expect(deserialized.forIdentity!.toString()).toStrictEqual(token.forIdentity!.toString()); + expect(deserialized.passwordProtection!.passwordType).toBe("pw"); + expect(deserialized.passwordProtection!.salt).toStrictEqual(token.passwordProtection!.salt); + }); + + test("should not create a tokenContent with too large passwordType", async function () { + await expect(async () => { + TokenContentRelationshipTemplate.from({ + secretKey: await CryptoEncryption.generateKey(), + templateId: await CoreIdHelper.notPrefixed.generate(), + passwordProtection: { + passwordType: "pin20", + salt: await CoreCrypto.random(16) + } + }); + }).rejects.toThrow("SharedPasswordProtection.passwordType"); + }); + + test("should not create a tokenContent with non-integer passwordType", async function () { + await expect(async () => { + TokenContentRelationshipTemplate.from({ + secretKey: await CryptoEncryption.generateKey(), + templateId: await CoreIdHelper.notPrefixed.generate(), + passwordProtection: { + passwordType: "pin2.4", + salt: await CoreCrypto.random(16) + } + }); + }).rejects.toThrow("SharedPasswordProtection.passwordType"); + }); + + test("should not create a tokenContent with passwordType starting with neither pw nor pin", async function () { + await expect(async () => { + TokenContentRelationshipTemplate.from({ + secretKey: await CryptoEncryption.generateKey(), + templateId: await CoreIdHelper.notPrefixed.generate(), + passwordProtection: { + passwordType: "pc", + salt: await CoreCrypto.random(16) + } + }); + }).rejects.toThrow("SharedPasswordProtection.passwordType"); + }); + + test("should not create a tokenContent with a salt of wrong length", async function () { + await expect(async () => { + TokenContentRelationshipTemplate.from({ + secretKey: await CryptoEncryption.generateKey(), + templateId: await CoreIdHelper.notPrefixed.generate(), + passwordProtection: { + passwordType: "pw", + salt: await CoreCrypto.random(8) + } + }); + }).rejects.toThrow("must be 16 bytes long"); }); }); diff --git a/packages/transport/test/modules/tokens/TokenController.test.ts b/packages/transport/test/modules/tokens/TokenController.test.ts index eacefd8fa..2c4821598 100644 --- a/packages/transport/test/modules/tokens/TokenController.test.ts +++ b/packages/transport/test/modules/tokens/TokenController.test.ts @@ -1,8 +1,8 @@ import { IDatabaseConnection } from "@js-soft/docdb-access-abstractions"; import { JSONWrapper, Serializable } from "@js-soft/ts-serval"; import { CoreDate, CoreId } from "@nmshd/core-types"; -import { CryptoEncryption, CryptoSecretKey } from "@nmshd/crypto"; -import { AccountController, CoreIdHelper, Token, TokenContentFile, TokenContentRelationshipTemplate, Transport } from "../../../src"; +import { CoreBuffer, CryptoEncryption, CryptoSecretKey } from "@nmshd/crypto"; +import { AccountController, CoreCrypto, CoreIdHelper, Token, TokenContentFile, TokenContentRelationshipTemplate, Transport } from "../../../src"; import { TestUtil } from "../../testHelpers/TestUtil"; describe("TokenController", function () { @@ -59,8 +59,8 @@ describe("TokenController", function () { expiresAt, ephemeral: false }); - const reference = sentToken.toTokenReference().truncate(); - const receivedToken = await recipient.tokens.loadPeerTokenByTruncated(reference, false); + const reference = sentToken.toTokenReference(); + const receivedToken = await recipient.tokens.loadPeerTokenByTruncated(reference.truncate(), false); tempId1 = sentToken.id; testTokens(sentToken, receivedToken, tempDate); @@ -97,15 +97,17 @@ describe("TokenController", function () { testTokens(sentToken, receivedToken, tempDate); expect(sentToken.cache?.expiresAt.toISOString()).toBe(expiresAt.toISOString()); expect(sentToken.cache?.content).toBeInstanceOf(TokenContentFile); - expect((sentToken.cache?.content as TokenContentFile).fileId).toBeInstanceOf(CoreId); - expect((sentToken.cache?.content as TokenContentFile).secretKey).toBeInstanceOf(CryptoSecretKey); + const sentTokenContent = sentToken.cache?.content as TokenContentFile; + expect(sentTokenContent.fileId).toBeInstanceOf(CoreId); + expect(sentTokenContent.secretKey).toBeInstanceOf(CryptoSecretKey); expect(receivedToken.cache?.content).toBeInstanceOf(TokenContentFile); - expect((receivedToken.cache?.content as TokenContentFile).fileId).toBeInstanceOf(CoreId); - expect((receivedToken.cache?.content as TokenContentFile).secretKey).toBeInstanceOf(CryptoSecretKey); - expect((sentToken.cache?.content as TokenContentFile).fileId.toString()).toBe(content.fileId.toString()); - expect((sentToken.cache?.content as TokenContentFile).secretKey.toBase64()).toBe(content.secretKey.toBase64()); - expect((receivedToken.cache?.content as TokenContentFile).fileId.toString()).toBe((sentToken.cache?.content as TokenContentFile).fileId.toString()); - expect((receivedToken.cache?.content as TokenContentFile).secretKey.toBase64()).toBe((sentToken.cache?.content as TokenContentFile).secretKey.toBase64()); + const receivedTokenContent = receivedToken.cache?.content as TokenContentFile; + expect(receivedTokenContent.fileId).toBeInstanceOf(CoreId); + expect(receivedTokenContent.secretKey).toBeInstanceOf(CryptoSecretKey); + expect(sentTokenContent.fileId.toString()).toBe(content.fileId.toString()); + expect(sentTokenContent.secretKey.toBase64()).toBe(content.secretKey.toBase64()); + expect(receivedTokenContent.fileId.toString()).toBe(sentTokenContent.fileId.toString()); + expect(receivedTokenContent.secretKey.toBase64()).toBe(sentTokenContent.secretKey.toBase64()); }); test("should send and receive a TokenContentRelationshipTemplate", async function () { @@ -125,19 +127,17 @@ describe("TokenController", function () { testTokens(sentToken, receivedToken, tempDate); expect(sentToken.cache?.expiresAt.toISOString()).toBe(expiresAt.toISOString()); expect(sentToken.cache?.content).toBeInstanceOf(TokenContentRelationshipTemplate); - expect((sentToken.cache?.content as TokenContentRelationshipTemplate).templateId).toBeInstanceOf(CoreId); - expect((sentToken.cache?.content as TokenContentRelationshipTemplate).secretKey).toBeInstanceOf(CryptoSecretKey); + const sentTokenContent = sentToken.cache?.content as TokenContentRelationshipTemplate; + expect(sentTokenContent.templateId).toBeInstanceOf(CoreId); + expect(sentTokenContent.secretKey).toBeInstanceOf(CryptoSecretKey); expect(receivedToken.cache?.content).toBeInstanceOf(TokenContentRelationshipTemplate); - expect((receivedToken.cache?.content as TokenContentRelationshipTemplate).templateId).toBeInstanceOf(CoreId); - expect((receivedToken.cache?.content as TokenContentRelationshipTemplate).secretKey).toBeInstanceOf(CryptoSecretKey); - expect((sentToken.cache?.content as TokenContentRelationshipTemplate).templateId.toString()).toBe(content.templateId.toString()); - expect((sentToken.cache?.content as TokenContentRelationshipTemplate).secretKey.toBase64()).toBe(content.secretKey.toBase64()); - expect((receivedToken.cache?.content as TokenContentRelationshipTemplate).templateId.toString()).toBe( - (sentToken.cache?.content as TokenContentRelationshipTemplate).templateId.toString() - ); - expect((receivedToken.cache?.content as TokenContentRelationshipTemplate).secretKey.toBase64()).toBe( - (sentToken.cache?.content as TokenContentRelationshipTemplate).secretKey.toBase64() - ); + const receivedTokenContent = receivedToken.cache?.content as TokenContentRelationshipTemplate; + expect(receivedTokenContent.templateId).toBeInstanceOf(CoreId); + expect(receivedTokenContent.secretKey).toBeInstanceOf(CryptoSecretKey); + expect(sentTokenContent.templateId.toString()).toBe(content.templateId.toString()); + expect(sentTokenContent.secretKey.toBase64()).toBe(content.secretKey.toBase64()); + expect(receivedTokenContent.templateId.toString()).toBe(sentTokenContent.templateId.toString()); + expect(receivedTokenContent.secretKey.toBase64()).toBe(sentTokenContent.secretKey.toBase64()); }); test("should send and receive a personalized TokenContentRelationshipTemplate", async function () { @@ -158,29 +158,106 @@ describe("TokenController", function () { testTokens(sentToken, receivedToken, tempDate); expect(sentToken.cache?.expiresAt.toISOString()).toBe(expiresAt.toISOString()); expect(sentToken.cache?.content).toBeInstanceOf(TokenContentRelationshipTemplate); - expect((sentToken.cache?.content as TokenContentRelationshipTemplate).templateId).toBeInstanceOf(CoreId); - expect((sentToken.cache?.content as TokenContentRelationshipTemplate).secretKey).toBeInstanceOf(CryptoSecretKey); + const sentTokenContent = sentToken.cache?.content as TokenContentRelationshipTemplate; + expect(sentTokenContent.templateId).toBeInstanceOf(CoreId); + expect(sentTokenContent.secretKey).toBeInstanceOf(CryptoSecretKey); + expect(receivedToken.cache?.content).toBeInstanceOf(TokenContentRelationshipTemplate); + const receivedTokenContent = receivedToken.cache?.content as TokenContentRelationshipTemplate; + expect(receivedTokenContent.templateId).toBeInstanceOf(CoreId); + expect(receivedTokenContent.secretKey).toBeInstanceOf(CryptoSecretKey); + expect(sentTokenContent.templateId.toString()).toBe(content.templateId.toString()); + expect(sentTokenContent.secretKey.toBase64()).toBe(content.secretKey.toBase64()); + expect(receivedTokenContent.templateId.toString()).toBe(sentTokenContent.templateId.toString()); + expect(receivedTokenContent.secretKey.toBase64()).toBe(sentTokenContent.secretKey.toBase64()); + expect(receivedTokenContent.forIdentity!.toString()).toBe(sentTokenContent.forIdentity!.toString()); + }); + + test("should send and receive a password-protected TokenContentRelationshipTemplate", async function () { + const expiresAt = CoreDate.utc().add({ minutes: 5 }); + const content = TokenContentRelationshipTemplate.from({ + templateId: await CoreIdHelper.notPrefixed.generate(), + secretKey: await CryptoEncryption.generateKey(), + passwordProtection: { + passwordType: "pw", + salt: await CoreCrypto.random(16) + } + }); + const sentToken = await sender.tokens.sendToken({ + content, + expiresAt, + ephemeral: false + }); + const reference = sentToken.toTokenReference().truncate(); + const receivedToken = await recipient.tokens.loadPeerTokenByTruncated(reference, false); + + testTokens(sentToken, receivedToken, tempDate); + expect(sentToken.cache?.expiresAt.toISOString()).toBe(expiresAt.toISOString()); + expect(sentToken.cache?.content).toBeInstanceOf(TokenContentRelationshipTemplate); + const sentTokenContent = sentToken.cache?.content as TokenContentRelationshipTemplate; + expect(sentTokenContent.templateId).toBeInstanceOf(CoreId); + expect(sentTokenContent.secretKey).toBeInstanceOf(CryptoSecretKey); + expect(sentTokenContent.passwordProtection!.salt).toBeInstanceOf(CoreBuffer); + expect(sentTokenContent.passwordProtection!.passwordType).toBe("pw"); + expect(receivedToken.cache?.content).toBeInstanceOf(TokenContentRelationshipTemplate); + const receivedTokenContent = receivedToken.cache?.content as TokenContentRelationshipTemplate; + expect(receivedTokenContent.templateId).toBeInstanceOf(CoreId); + expect(receivedTokenContent.secretKey).toBeInstanceOf(CryptoSecretKey); + expect(receivedTokenContent.passwordProtection!.salt).toBeInstanceOf(CoreBuffer); + expect(sentTokenContent.templateId.toString()).toBe(content.templateId.toString()); + expect(sentTokenContent.secretKey.toBase64()).toBe(content.secretKey.toBase64()); + expect(receivedTokenContent.templateId.toString()).toBe(sentTokenContent.templateId.toString()); + expect(receivedTokenContent.secretKey.toBase64()).toBe(sentTokenContent.secretKey.toBase64()); + expect(receivedTokenContent.passwordProtection!.passwordType).toBe(sentTokenContent.passwordProtection!.passwordType); + expect(receivedTokenContent.passwordProtection!.salt.toBase64URL()).toBe(sentTokenContent.passwordProtection!.salt.toBase64URL()); + }); + + test("should send and receive a password-protected and personalized TokenContentRelationshipTemplate", async function () { + const expiresAt = CoreDate.utc().add({ minutes: 5 }); + const content = TokenContentRelationshipTemplate.from({ + templateId: await CoreIdHelper.notPrefixed.generate(), + secretKey: await CryptoEncryption.generateKey(), + forIdentity: recipient.identity.address, + passwordProtection: { + passwordType: "pw", + salt: await CoreCrypto.random(16) + } + }); + const sentToken = await sender.tokens.sendToken({ + content, + expiresAt, + ephemeral: false + }); + const reference = sentToken.toTokenReference().truncate(); + const receivedToken = await recipient.tokens.loadPeerTokenByTruncated(reference, false); + + testTokens(sentToken, receivedToken, tempDate); + expect(sentToken.cache?.expiresAt.toISOString()).toBe(expiresAt.toISOString()); + expect(sentToken.cache?.content).toBeInstanceOf(TokenContentRelationshipTemplate); + const sentTokenContent = sentToken.cache?.content as TokenContentRelationshipTemplate; + expect(sentTokenContent.templateId).toBeInstanceOf(CoreId); + expect(sentTokenContent.secretKey).toBeInstanceOf(CryptoSecretKey); + expect(sentTokenContent.passwordProtection!.salt).toBeInstanceOf(CoreBuffer); + expect(sentTokenContent.passwordProtection!.passwordType).toBe("pw"); expect(receivedToken.cache?.content).toBeInstanceOf(TokenContentRelationshipTemplate); - expect((receivedToken.cache?.content as TokenContentRelationshipTemplate).templateId).toBeInstanceOf(CoreId); - expect((receivedToken.cache?.content as TokenContentRelationshipTemplate).secretKey).toBeInstanceOf(CryptoSecretKey); - expect((sentToken.cache?.content as TokenContentRelationshipTemplate).templateId.toString()).toBe(content.templateId.toString()); - expect((sentToken.cache?.content as TokenContentRelationshipTemplate).secretKey.toBase64()).toBe(content.secretKey.toBase64()); - expect((receivedToken.cache?.content as TokenContentRelationshipTemplate).templateId.toString()).toBe( - (sentToken.cache?.content as TokenContentRelationshipTemplate).templateId.toString() - ); - expect((receivedToken.cache?.content as TokenContentRelationshipTemplate).secretKey.toBase64()).toBe( - (sentToken.cache?.content as TokenContentRelationshipTemplate).secretKey.toBase64() - ); - expect((receivedToken.cache?.content as TokenContentRelationshipTemplate).forIdentity!.toString()).toBe( - (sentToken.cache?.content as TokenContentRelationshipTemplate).forIdentity!.toString() - ); + const receivedTokenContent = receivedToken.cache?.content as TokenContentRelationshipTemplate; + expect(receivedTokenContent.templateId).toBeInstanceOf(CoreId); + expect(receivedTokenContent.secretKey).toBeInstanceOf(CryptoSecretKey); + expect(receivedTokenContent.passwordProtection!.salt).toBeInstanceOf(CoreBuffer); + expect(sentTokenContent.templateId.toString()).toBe(content.templateId.toString()); + expect(sentTokenContent.secretKey.toBase64()).toBe(content.secretKey.toBase64()); + expect(receivedTokenContent.templateId.toString()).toBe(sentTokenContent.templateId.toString()); + expect(receivedTokenContent.secretKey.toBase64()).toBe(sentTokenContent.secretKey.toBase64()); + expect(sentTokenContent.forIdentity!.toString()).toBe(sentTokenContent.forIdentity!.toString()); + expect(receivedTokenContent.forIdentity!.toString()).toBe(sentTokenContent.forIdentity!.toString()); + expect(receivedTokenContent.passwordProtection!.passwordType).toBe(sentTokenContent.passwordProtection!.passwordType); + expect(receivedTokenContent.passwordProtection!.salt.toBase64URL()).toBe(sentTokenContent.passwordProtection!.salt.toBase64URL()); }); test("should get the cached tokens", async function () { const sentTokens = await sender.tokens.getTokens(); const receivedTokens = await recipient.tokens.getTokens(); - expect(sentTokens).toHaveLength(4); - expect(receivedTokens).toHaveLength(4); + expect(sentTokens).toHaveLength(6); + expect(receivedTokens).toHaveLength(6); expect(sentTokens[0].id.toString()).toBe(tempId1.toString()); expect(sentTokens[1].id.toString()).toBe(tempId2.toString()); testTokens(sentTokens[0], receivedTokens[0], tempDate); diff --git a/packages/transport/test/modules/tokens/TokenReference.test.ts b/packages/transport/test/modules/tokens/TokenReference.test.ts index 13d41972e..1396f98c3 100644 --- a/packages/transport/test/modules/tokens/TokenReference.test.ts +++ b/packages/transport/test/modules/tokens/TokenReference.test.ts @@ -1,7 +1,7 @@ import { Serializable } from "@js-soft/ts-serval"; import { CoreId } from "@nmshd/core-types"; import { CoreBuffer, CryptoEncryption, CryptoSecretKey } from "@nmshd/crypto"; -import { BackboneIds, TokenReference } from "../../../src"; +import { BackboneIds, CoreCrypto, TokenReference } from "../../../src"; describe("TokenReference", function () { test("should serialize and deserialize correctly (verbose)", async function () { @@ -46,13 +46,16 @@ describe("TokenReference", function () { expect(deserialized.id.toString()).toStrictEqual(reference.id.toString()); }); - test("should serialize and deserialize correctly (verbose, with backbone, identity, password)", async function () { + test("should serialize and deserialize correctly (verbose, with backbone, identity, passwordProtection)", async function () { const reference = TokenReference.from({ key: await CryptoEncryption.generateKey(), id: await BackboneIds.token.generateUnsafe(), backboneBaseUrl: "localhost", forIdentityTruncated: "1234", - passwordType: 10 + passwordProtection: { + passwordType: "pin10", + salt: await CoreCrypto.random(16) + } }); expect(reference).toBeInstanceOf(Serializable); expect(reference).toBeInstanceOf(TokenReference); @@ -61,27 +64,32 @@ describe("TokenReference", function () { const serialized = reference.serialize(); expect(typeof serialized).toBe("string"); expect(serialized).toBe( - `{"@type":"TokenReference","backboneBaseUrl":"localhost","forIdentityTruncated":"1234","id":"${reference.id.toString()}","key":${reference.key.serialize(false)},"passwordType":10}` + `{"@type":"TokenReference","backboneBaseUrl":"localhost","forIdentityTruncated":"1234","id":"${reference.id.toString()}","key":${reference.key.serialize(false)},"passwordProtection":{"passwordType":"pin10","salt":"${reference.passwordProtection!.salt.toBase64URL()}"}}` ); const deserialized = TokenReference.deserialize(serialized); expect(deserialized).toBeInstanceOf(Serializable); expect(deserialized).toBeInstanceOf(TokenReference); expect(deserialized.key).toBeInstanceOf(CryptoSecretKey); expect(deserialized.id).toBeInstanceOf(CoreId); + expect(deserialized.passwordProtection!.salt).toBeInstanceOf(CoreBuffer); expect(deserialized.key.toBase64()).toStrictEqual(reference.key.toBase64()); expect(deserialized.id.toString()).toStrictEqual(reference.id.toString()); expect(deserialized.backboneBaseUrl).toBe("localhost"); expect(deserialized.forIdentityTruncated).toBe("1234"); - expect(deserialized.passwordType).toBe(10); + expect(deserialized.passwordProtection!.passwordType).toBe("pin10"); + expect(deserialized.passwordProtection!.salt.toBase64URL()).toBe(reference.passwordProtection!.salt.toBase64URL()); }); - test("should serialize and deserialize correctly (from unknown type, with backbone, identity, password)", async function () { + test("should serialize and deserialize correctly (from unknown type, with backbone, identity, passwordProtection)", async function () { const reference = TokenReference.from({ key: await CryptoEncryption.generateKey(), id: await BackboneIds.token.generateUnsafe(), backboneBaseUrl: "localhost", forIdentityTruncated: "1234", - passwordType: 10 + passwordProtection: { + passwordType: "pw", + salt: await CoreCrypto.random(16) + } }); expect(reference).toBeInstanceOf(Serializable); expect(reference).toBeInstanceOf(TokenReference); @@ -90,18 +98,20 @@ describe("TokenReference", function () { const serialized = reference.serialize(); expect(typeof serialized).toBe("string"); expect(serialized).toBe( - `{"@type":"TokenReference","backboneBaseUrl":"localhost","forIdentityTruncated":"1234","id":"${reference.id.toString()}","key":${reference.key.serialize(false)},"passwordType":10}` + `{"@type":"TokenReference","backboneBaseUrl":"localhost","forIdentityTruncated":"1234","id":"${reference.id.toString()}","key":${reference.key.serialize(false)},"passwordProtection":{"passwordType":"pw","salt":"${reference.passwordProtection!.salt.toBase64URL()}"}}` ); const deserialized = Serializable.deserializeUnknown(serialized) as TokenReference; expect(deserialized).toBeInstanceOf(Serializable); expect(deserialized).toBeInstanceOf(TokenReference); expect(deserialized.key).toBeInstanceOf(CryptoSecretKey); expect(deserialized.id).toBeInstanceOf(CoreId); + expect(deserialized.passwordProtection!.salt).toBeInstanceOf(CoreBuffer); expect(deserialized.key.toBase64()).toStrictEqual(reference.key.toBase64()); expect(deserialized.id.toString()).toStrictEqual(reference.id.toString()); expect(deserialized.backboneBaseUrl).toBe("localhost"); expect(deserialized.forIdentityTruncated).toBe("1234"); - expect(deserialized.passwordType).toBe(10); + expect(deserialized.passwordProtection!.passwordType).toBe("pw"); + expect(deserialized.passwordProtection!.salt.toBase64URL()).toBe(reference.passwordProtection!.salt.toBase64URL()); }); test("should truncate and read in correctly", async function () { @@ -110,7 +120,7 @@ describe("TokenReference", function () { id: await BackboneIds.token.generateUnsafe() }); const truncated = reference.truncate(); - expect(truncated.length).toBeLessThan(115); + expect(truncated.length).toBeLessThan(155); expect(truncated.length).toBeGreaterThan(80); const deserialized = TokenReference.fromTruncated(truncated); expect(deserialized).toBeInstanceOf(Serializable); @@ -121,27 +131,32 @@ describe("TokenReference", function () { expect(deserialized.id.toString()).toStrictEqual(reference.id.toString()); }); - test("should truncate and read in correctly with backbone, identity, password", async function () { + test("should truncate and read in correctly with backbone, identity, passwordProtection", async function () { const reference = TokenReference.from({ key: await CryptoEncryption.generateKey(), id: await BackboneIds.token.generateUnsafe(), backboneBaseUrl: "localhost", forIdentityTruncated: "1234", - passwordType: 10 + passwordProtection: { + passwordType: "pin10", + salt: await CoreCrypto.random(16) + } }); const truncated = reference.truncate(); - expect(truncated.length).toBeLessThan(115); + expect(truncated.length).toBeLessThan(155); expect(truncated.length).toBeGreaterThan(80); const deserialized = TokenReference.fromTruncated(truncated); expect(deserialized).toBeInstanceOf(Serializable); expect(deserialized).toBeInstanceOf(TokenReference); expect(deserialized.key).toBeInstanceOf(CryptoSecretKey); expect(deserialized.id).toBeInstanceOf(CoreId); + expect(deserialized.passwordProtection!.salt).toBeInstanceOf(CoreBuffer); expect(deserialized.key.toBase64()).toStrictEqual(reference.key.toBase64()); expect(deserialized.id.toString()).toStrictEqual(reference.id.toString()); expect(deserialized.backboneBaseUrl).toBe("localhost"); expect(deserialized.forIdentityTruncated).toBe("1234"); - expect(deserialized.passwordType).toBe(10); + expect(deserialized.passwordProtection!.passwordType).toBe("pin10"); + expect(deserialized.passwordProtection!.salt.toBase64URL()).toBe(reference.passwordProtection!.salt.toBase64URL()); }); test("should read a reference in the old format", async function () { @@ -150,7 +165,7 @@ describe("TokenReference", function () { id: await BackboneIds.token.generateUnsafe() }); const truncated = CoreBuffer.fromUtf8(`${reference.id.toString()}|${reference.key.algorithm}|${reference.key.secretKey.toBase64URL()}`).toBase64URL(); - expect(truncated.length).toBeLessThan(115); + expect(truncated.length).toBeLessThan(155); expect(truncated.length).toBeGreaterThan(80); const deserialized = TokenReference.fromTruncated(truncated); expect(deserialized).toBeInstanceOf(Serializable); @@ -166,9 +181,12 @@ describe("TokenReference", function () { TokenReference.from({ key: await CryptoEncryption.generateKey(), id: await BackboneIds.token.generateUnsafe(), - passwordType: 20 + passwordProtection: { + passwordType: "pin20", + salt: await CoreCrypto.random(16) + } }); - }).rejects.toThrow("TokenReference.passwordType"); + }).rejects.toThrow("SharedPasswordProtection.passwordType"); }); test("should not create a reference with non-integer passwordType", async function () { @@ -176,9 +194,48 @@ describe("TokenReference", function () { TokenReference.from({ key: await CryptoEncryption.generateKey(), id: await BackboneIds.token.generateUnsafe(), - passwordType: 2.4 + passwordProtection: { + passwordType: "pin2.4", + salt: await CoreCrypto.random(16) + } }); - }).rejects.toThrow("TokenReference.passwordType"); + }).rejects.toThrow("SharedPasswordProtection.passwordType"); + }); + + test("should not create a reference with passwordType starting with neither pw nor pin", async function () { + await expect(async () => { + TokenReference.from({ + key: await CryptoEncryption.generateKey(), + id: await BackboneIds.token.generateUnsafe(), + passwordProtection: { + passwordType: "pc", + salt: await CoreCrypto.random(16) + } + }); + }).rejects.toThrow("SharedPasswordProtection.passwordType"); + }); + + test("should not load a reference with a non-base64 salt", async function () { + const reference = TokenReference.from({ + key: await CryptoEncryption.generateKey(), + id: await BackboneIds.token.generateUnsafe() + }); + + const truncated = CoreBuffer.fromUtf8(`${reference.id.toString()}|${reference.key.algorithm}|${reference.key.secretKey.toBase64URL()}||wrong-salt&pw`).toBase64URL(); + expect(() => TokenReference.fromTruncated(truncated)).toThrow("The salt needs to be a Base64 value."); + }); + + test("should not create a reference with a salt of wrong length", async function () { + await expect(async () => { + TokenReference.from({ + key: await CryptoEncryption.generateKey(), + id: await BackboneIds.token.generateUnsafe(), + passwordProtection: { + passwordType: "pw", + salt: await CoreCrypto.random(8) + } + }); + }).rejects.toThrow("must be 16 bytes long"); }); test("should not create a reference with too long personalization", async function () { diff --git a/packages/transport/test/testHelpers/TestUtil.ts b/packages/transport/test/testHelpers/TestUtil.ts index f7a6b2120..42f09ef77 100644 --- a/packages/transport/test/testHelpers/TestUtil.ts +++ b/packages/transport/test/testHelpers/TestUtil.ts @@ -296,7 +296,8 @@ export class TestUtil { maxNumberOfAllocations: 1 }); - const templateTo = await to.relationshipTemplates.loadPeerRelationshipTemplate(templateFrom.id, templateFrom.secretKey); + const reference = templateFrom.toRelationshipTemplateReference().truncate(); + const templateTo = await to.relationshipTemplates.loadPeerRelationshipTemplateByTruncated(reference); await to.relationships.sendRelationship({ template: templateTo, @@ -341,7 +342,8 @@ export class TestUtil { to: AccountController, template: RelationshipTemplate ): Promise<{ acceptedRelationshipFromSelf: Relationship; acceptedRelationshipPeer: Relationship }> { - const templateTo = await to.relationshipTemplates.loadPeerRelationshipTemplate(template.id, template.secretKey); + const reference = template.toRelationshipTemplateReference().truncate(); + const templateTo = await to.relationshipTemplates.loadPeerRelationshipTemplateByTruncated(reference); const relRequest = await to.relationships.sendRelationship({ template: templateTo, @@ -539,7 +541,7 @@ export class TestUtil { throw new Error("token content not instanceof TokenContentRelationshipTemplate"); } - const template = await account.relationshipTemplates.loadPeerRelationshipTemplate(receivedToken.cache!.content.templateId, receivedToken.cache!.content.secretKey); + const template = await account.relationshipTemplates.loadPeerRelationshipTemplateByTokenContent(receivedToken.cache!.content); return template; } From acb942bc01ddc34edf3460d13ec3c9ebceb7e9cd Mon Sep 17 00:00:00 2001 From: Magnus Kuhn <127854942+Magnus-Kuhn@users.noreply.github.com> Date: Tue, 26 Nov 2024 15:29:00 +0100 Subject: [PATCH 05/21] Don't compare classes with `_.isEqual` (#341) * refactor: convert to JSON before _.isEqual * refactor: remove any typing --- .../src/modules/attributes/AttributesController.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/consumption/src/modules/attributes/AttributesController.ts b/packages/consumption/src/modules/attributes/AttributesController.ts index 9d8bb5966..a97c7fb6e 100644 --- a/packages/consumption/src/modules/attributes/AttributesController.ts +++ b/packages/consumption/src/modules/attributes/AttributesController.ts @@ -734,13 +734,11 @@ export class AttributesController extends ConsumptionBaseController { return ValidationResult.error(ConsumptionCoreErrors.attributes.successorSourceAttributeIsNotRepositoryAttribute()); } - if (!_.isEqual(successorSource.content, successor.content)) { + if (!_.isEqual(successorSource.content.toJSON(), successor.content.toJSON())) { return ValidationResult.error(ConsumptionCoreErrors.attributes.successorSourceContentIsNotEqualToCopyContent()); } - let predecessorSource: any = undefined; - if (predecessor.shareInfo.sourceAttribute) predecessorSource = await this.getLocalAttribute(predecessor.shareInfo.sourceAttribute); - + const predecessorSource = predecessor.shareInfo.sourceAttribute ? await this.getLocalAttribute(predecessor.shareInfo.sourceAttribute) : undefined; if (predecessorSource) { if (!predecessorSource.isRepositoryAttribute(this.identity.address)) { return ValidationResult.error(ConsumptionCoreErrors.attributes.predecessorSourceAttributeIsNotRepositoryAttribute()); @@ -751,7 +749,7 @@ export class AttributesController extends ConsumptionBaseController { return ValidationResult.error(ConsumptionCoreErrors.attributes.successorSourceDoesNotSucceedPredecessorSource()); } - if (!_.isEqual(predecessorSource.content, predecessor.content)) { + if (!_.isEqual(predecessorSource.content.toJSON(), predecessor.content.toJSON())) { return ValidationResult.error(ConsumptionCoreErrors.attributes.predecessorSourceContentIsNotEqualToCopyContent()); } } @@ -983,7 +981,7 @@ export class AttributesController extends ConsumptionBaseController { return ValidationResult.error(ConsumptionCoreErrors.attributes.cannotSucceedAttributesWithDeletionInfo()); } - if (_.isEqual(successor.content, predecessor.content)) { + if (_.isEqual(successor.content.toJSON(), predecessor.content.toJSON())) { return ValidationResult.error(ConsumptionCoreErrors.attributes.successionMustChangeContent()); } From c74ebb579effd1404a769924af8b9b8a43c062c7 Mon Sep 17 00:00:00 2001 From: Milena Czierlinski <146972016+Milena-Czierlinski@users.noreply.github.com> Date: Wed, 27 Nov 2024 10:30:12 +0100 Subject: [PATCH 06/21] Remove unused injected controller (#343) --- .../src/useCases/transport/relationships/CreateRelationship.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/runtime/src/useCases/transport/relationships/CreateRelationship.ts b/packages/runtime/src/useCases/transport/relationships/CreateRelationship.ts index b5f83360d..cf0d8d76a 100644 --- a/packages/runtime/src/useCases/transport/relationships/CreateRelationship.ts +++ b/packages/runtime/src/useCases/transport/relationships/CreateRelationship.ts @@ -1,6 +1,5 @@ import { Serializable } from "@js-soft/ts-serval"; import { Result } from "@js-soft/ts-utils"; -import { IncomingRequestsController } from "@nmshd/consumption"; import { ArbitraryRelationshipCreationContent, RelationshipCreationContent } from "@nmshd/content"; import { CoreId } from "@nmshd/core-types"; import { AccountController, RelationshipTemplate, RelationshipTemplateController, RelationshipsController } from "@nmshd/transport"; @@ -24,7 +23,6 @@ export class CreateRelationshipUseCase extends UseCase Date: Wed, 27 Nov 2024 10:43:15 +0100 Subject: [PATCH 07/21] Allow passing no title and description when uploading a file (#342) * refactor: update SendFileParameters * refactor: make title optional * chore: update schemas --------- Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com> --- packages/runtime/src/useCases/common/Schemas.ts | 6 ++---- .../useCases/transport/files/UploadOwnFile.ts | 9 +++------ .../modules/files/local/SendFileParameters.ts | 16 ++++++++++------ 3 files changed, 15 insertions(+), 16 deletions(-) diff --git a/packages/runtime/src/useCases/common/Schemas.ts b/packages/runtime/src/useCases/common/Schemas.ts index 87e5c1967..178cd5b8f 100644 --- a/packages/runtime/src/useCases/common/Schemas.ts +++ b/packages/runtime/src/useCases/common/Schemas.ts @@ -21641,8 +21641,7 @@ export const UploadOwnFileRequest: any = { "required": [ "content", "filename", - "mimetype", - "title" + "mimetype" ], "additionalProperties": false }, @@ -21683,8 +21682,7 @@ export const UploadOwnFileValidatableRequest: any = { "required": [ "content", "filename", - "mimetype", - "title" + "mimetype" ], "additionalProperties": false }, diff --git a/packages/runtime/src/useCases/transport/files/UploadOwnFile.ts b/packages/runtime/src/useCases/transport/files/UploadOwnFile.ts index 5ac012de9..fc81f0c40 100644 --- a/packages/runtime/src/useCases/transport/files/UploadOwnFile.ts +++ b/packages/runtime/src/useCases/transport/files/UploadOwnFile.ts @@ -13,7 +13,7 @@ export interface UploadOwnFileRequest { filename: string; mimetype: string; expiresAt?: ISO8601DateTimeString; - title: string; + title?: string; description?: string; } @@ -77,16 +77,13 @@ export class UploadOwnFileUseCase extends UseCase } protected async executeInternal(request: UploadOwnFileRequest): Promise> { - const maxDate = "9999-12-31T00:00:00.000Z"; - const expiresAt = request.expiresAt ?? maxDate; - const file = await this.fileController.sendFile({ buffer: CoreBuffer.from(request.content), title: request.title, - description: request.description ?? "", + description: request.description, filename: request.filename, mimetype: request.mimetype, - expiresAt: CoreDate.from(expiresAt) + expiresAt: CoreDate.from(request.expiresAt ?? "9999-12-31T00:00:00.000Z") }); await this.accountController.syncDatawallet(); diff --git a/packages/transport/src/modules/files/local/SendFileParameters.ts b/packages/transport/src/modules/files/local/SendFileParameters.ts index 37e1a8f80..b4fbade67 100644 --- a/packages/transport/src/modules/files/local/SendFileParameters.ts +++ b/packages/transport/src/modules/files/local/SendFileParameters.ts @@ -3,8 +3,8 @@ import { CoreDate, ICoreDate } from "@nmshd/core-types"; import { CoreBuffer, ICoreBuffer } from "@nmshd/crypto"; export interface ISendFileParameters extends ISerializable { - title: string; - description: string; + title?: string; + description?: string; filename: string; mimetype: string; expiresAt: ICoreDate; @@ -14,18 +14,22 @@ export interface ISendFileParameters extends ISerializable { @type("SendFileParameters") export class SendFileParameters extends Serializable implements ISendFileParameters { - @validate() + @validate({ nullable: true }) @serialize() - public title: string; - @validate() + public title?: string; + + @validate({ nullable: true }) @serialize() - public description: string; + public description?: string; + @validate() @serialize() public filename: string; + @validate() @serialize() public mimetype: string; + @validate() @serialize() public expiresAt: CoreDate; From 4b41c4ffbbf623e1d02608c05062f9790e0061a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julian=20K=C3=B6nig?= <33655937+jkoenig134@users.noreply.github.com> Date: Wed, 27 Nov 2024 12:14:53 +0100 Subject: [PATCH 08/21] Upgrade Backbone Version (#344) * chore: bump backbone * fix: update error message --- .dev/appsettings.override.json | 5 +++++ .dev/compose.backbone.env | 2 +- .../RelationshipTemplateController.test.ts | 4 +++- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/.dev/appsettings.override.json b/.dev/appsettings.override.json index e655dfaec..167f4fd5f 100644 --- a/.dev/appsettings.override.json +++ b/.dev/appsettings.override.json @@ -103,6 +103,11 @@ "ConnectionString": "User ID=tokens;Password=Passw0rd;Server=postgres;Port=5432;Database=enmeshed;" } } + }, + "Tags": { + "Application": { + "SupportedLanguages": ["en"] + } } }, "Serilog": { diff --git a/.dev/compose.backbone.env b/.dev/compose.backbone.env index 8115afe54..a3a499d00 100644 --- a/.dev/compose.backbone.env +++ b/.dev/compose.backbone.env @@ -1 +1 @@ -BACKBONE_VERSION=6.15.2 +BACKBONE_VERSION=6.19.1 diff --git a/packages/transport/test/modules/relationshipTemplates/RelationshipTemplateController.test.ts b/packages/transport/test/modules/relationshipTemplates/RelationshipTemplateController.test.ts index 9053c4b9d..1bf019b38 100644 --- a/packages/transport/test/modules/relationshipTemplates/RelationshipTemplateController.test.ts +++ b/packages/transport/test/modules/relationshipTemplates/RelationshipTemplateController.test.ts @@ -194,7 +194,9 @@ describe("RelationshipTemplateController", function () { await expect( recipient.relationshipTemplates.loadPeerRelationshipTemplateByTruncated(ownTemplate.toRelationshipTemplateReference().truncate(), "wrongPassword") - ).rejects.toThrow("error.platform.recordNotFound (404): 'RelationshipTemplate not found. Make sure the ID exists and the record is not expired.'"); + ).rejects.toThrow( + "error.platform.recordNotFound (404): 'RelationshipTemplate not found. Make sure the ID exists and the record is not expired. If a password is required to fetch the record, make sure you passed the correct one.'" + ); await expect(recipient.relationshipTemplates.loadPeerRelationshipTemplateByTruncated(ownTemplate.toRelationshipTemplateReference().truncate())).rejects.toThrow( "error.transport.noPasswordProvided" ); From 226d09b4996a9c40403b91766b1a85bc57da47ff Mon Sep 17 00:00:00 2001 From: Milena Czierlinski <146972016+Milena-Czierlinski@users.noreply.github.com> Date: Thu, 28 Nov 2024 13:40:14 +0100 Subject: [PATCH 09/21] Add `deletionDate` to LocalAccount (#345) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * WIP: Enhance LocalAccountDTO with deletion Info * feat: try to untangle changes * feat: add getAccounts(Not)InDeletion functions * test: clean up IdentityDeletionProcessStatusChangedModule test * refactor: stuff * refactor: clean up changes * test: notes * feat: publish DatawalletSynchronizedEvent in AccountController * test: receive DatawalletSynchronizedEvent calling syncDatawallet use case * feat: use runtime event in app-runtime * feat: add DatawalletSynchronized module * chore: remove LocalAccountDeletionDateChangedModule * test: clean up IdentityDeletionProcessStatusChangedModule test * fix: DatawalletSynchronizedModule * fix: don't publish event updating LocalAccount deletionDate * fix and test: getAccounts(Not)InDeletion * test: don's skip tests * test: remove unrelated test * chore: remove dangerous getters * fix: write deletionDate to cached local account from MultiAccountController * fix: use correct apis * refactor: massively simplify tests * chore: naming * chore: more asserts * refactor: move event publish location * fix: change location of publishing * feat: make deletionDate CoreDate * test: refactor afterAll and afterEach function * test: wording * test: refactor functions * test: compare strings * chore: one-liners * feat: provide LocalAccountDTO in LocalAccountDeletionDateChangedEvent * refactor: move variable closer to its usage * refactor: use at-method to access last array element --------- Co-authored-by: Siolto Co-authored-by: Julian König --- packages/app-runtime/src/AppConfig.ts | 12 +++ packages/app-runtime/src/AppRuntime.ts | 28 ++----- .../src/events/DatawalletSynchronizedEvent.ts | 9 -- .../LocalAccountDeletionDateChangedEvent.ts | 10 +++ packages/app-runtime/src/events/index.ts | 2 +- .../PushNotificationModule.ts | 3 +- .../DatawalletSynchronizedModule.ts | 59 +++++++++++++ ...ntityDeletionProcessStatusChangedModule.ts | 44 ++++++++++ .../src/modules/runtimeEvents/index.ts | 2 + .../multiAccount/MultiAccountController.ts | 29 +++++++ .../src/multiAccount/data/LocalAccount.ts | 11 ++- .../src/multiAccount/data/LocalAccountDTO.ts | 1 + .../multiAccount/data/LocalAccountMapper.ts | 3 +- .../DatawalletSynchronizedModule.test.ts | 83 +++++++++++++++++++ ...DeletionProcessStatusChangedModule.test.ts | 51 ++++++++++++ .../test/modules/PushNotification.test.ts | 3 +- .../MultiAccountController.test.ts | 66 +++++++++++++++ packages/runtime/src/events/EventProxy.ts | 5 ++ .../transport/DatawalletSynchronizedEvent.ts | 9 ++ .../runtime/src/events/transport/index.ts | 1 + .../runtime/test/transport/account.test.ts | 17 +++- .../src/events/DatawalletSynchronizedEvent.ts | 9 ++ packages/transport/src/events/index.ts | 1 + .../src/modules/accounts/AccountController.ts | 6 +- .../src/modules/sync/SyncController.ts | 3 + 25 files changed, 421 insertions(+), 46 deletions(-) delete mode 100644 packages/app-runtime/src/events/DatawalletSynchronizedEvent.ts create mode 100644 packages/app-runtime/src/events/LocalAccountDeletionDateChangedEvent.ts create mode 100644 packages/app-runtime/src/modules/runtimeEvents/DatawalletSynchronizedModule.ts create mode 100644 packages/app-runtime/src/modules/runtimeEvents/IdentityDeletionProcessStatusChangedModule.ts create mode 100644 packages/app-runtime/test/modules/DatawalletSynchronizedModule.test.ts create mode 100644 packages/app-runtime/test/modules/IdentityDeletionProcessStatusChangedModule.test.ts create mode 100644 packages/app-runtime/test/multiAccount/MultiAccountController.test.ts create mode 100644 packages/runtime/src/events/transport/DatawalletSynchronizedEvent.ts create mode 100644 packages/transport/src/events/DatawalletSynchronizedEvent.ts diff --git a/packages/app-runtime/src/AppConfig.ts b/packages/app-runtime/src/AppConfig.ts index 8f5144bc1..b92b91798 100644 --- a/packages/app-runtime/src/AppConfig.ts +++ b/packages/app-runtime/src/AppConfig.ts @@ -52,6 +52,18 @@ export function createAppConfig(...configs: AppConfigOverwrite[]): AppConfig { location: "onboardingChangeReceived", enabled: true }, + datawalletSynchronized: { + name: "datawalletSynchronized", + displayName: "Datawallet Synchronized Module", + location: "datawalletSynchronized", + enabled: true + }, + identityDeletionProcessStatusChanged: { + name: "identityDeletionProcessStatusChanged", + displayName: "Identity Deletion Process Status Changed Module", + location: "identityDeletionProcessStatusChanged", + enabled: true + }, messageReceived: { name: "messageReceived", displayName: "Message Received Module", diff --git a/packages/app-runtime/src/AppRuntime.ts b/packages/app-runtime/src/AppRuntime.ts index 8538ecca9..6852d4327 100644 --- a/packages/app-runtime/src/AppRuntime.ts +++ b/packages/app-runtime/src/AppRuntime.ts @@ -9,13 +9,15 @@ import { AppConfig, AppConfigOverwrite, createAppConfig } from "./AppConfig"; import { AppRuntimeErrors } from "./AppRuntimeErrors"; import { AppRuntimeServices } from "./AppRuntimeServices"; import { AppStringProcessor } from "./AppStringProcessor"; -import { AccountSelectedEvent, RelationshipSelectedEvent } from "./events"; +import { AccountSelectedEvent } from "./events"; import { AppServices, IUIBridge } from "./extensibility"; import { AppLaunchModule, AppRuntimeModuleConfiguration, AppSyncModule, + DatawalletSynchronizedModule, IAppRuntimeModuleConstructor, + IdentityDeletionProcessStatusChangedModule, MailReceivedModule, MessageReceivedModule, OnboardingChangeReceivedModule, @@ -86,14 +88,6 @@ export class AppRuntime extends Runtime { private readonly sessionStorage = new SessionStorage(); - public get currentAccount(): LocalAccountDTO { - return this.sessionStorage.currentSession.account; - } - - public get currentSession(): LocalAccountSession { - return this.sessionStorage.currentSession; - } - public getSessions(): LocalAccountSession[] { return this.sessionStorage.getSessions(); } @@ -209,20 +203,6 @@ export class AppRuntime extends Runtime { return UserfriendlyResult.ok(accountSelectionResult.value); } - public async selectRelationship(id?: string): Promise { - if (!id) { - this.currentSession.selectedRelationship = undefined; - return; - } - - const result = await this.currentSession.appServices.relationships.renderRelationship(id); - if (result.isError) throw result.error; - - const relationship = result.value; - this.currentSession.selectedRelationship = relationship; - this.eventBus.publish(new RelationshipSelectedEvent(this.currentSession.address, relationship)); - } - public getHealth(): Promise { const health = { isHealthy: true, @@ -298,6 +278,8 @@ export class AppRuntime extends Runtime { pushNotification: PushNotificationModule, mailReceived: MailReceivedModule, onboardingChangeReceived: OnboardingChangeReceivedModule, + datawalletSynchronized: DatawalletSynchronizedModule, + identityDeletionProcessStatusChanged: IdentityDeletionProcessStatusChangedModule, messageReceived: MessageReceivedModule, relationshipChanged: RelationshipChangedModule, relationshipTemplateProcessed: RelationshipTemplateProcessedModule diff --git a/packages/app-runtime/src/events/DatawalletSynchronizedEvent.ts b/packages/app-runtime/src/events/DatawalletSynchronizedEvent.ts deleted file mode 100644 index a9d01fa22..000000000 --- a/packages/app-runtime/src/events/DatawalletSynchronizedEvent.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { DataEvent } from "@nmshd/runtime"; - -export class DatawalletSynchronizedEvent extends DataEvent { - public static readonly namespace: string = "app.datawalletSynchronized"; - - public constructor(address: string) { - super(DatawalletSynchronizedEvent.namespace, address, undefined); - } -} diff --git a/packages/app-runtime/src/events/LocalAccountDeletionDateChangedEvent.ts b/packages/app-runtime/src/events/LocalAccountDeletionDateChangedEvent.ts new file mode 100644 index 000000000..2f106af6e --- /dev/null +++ b/packages/app-runtime/src/events/LocalAccountDeletionDateChangedEvent.ts @@ -0,0 +1,10 @@ +import { DataEvent } from "@nmshd/runtime"; +import { LocalAccountDTO } from "../multiAccount"; + +export class LocalAccountDeletionDateChangedEvent extends DataEvent { + public static readonly namespace: string = "app.localAccountDeletionDateChanged"; + + public constructor(address: string, localAccount: LocalAccountDTO) { + super(LocalAccountDeletionDateChangedEvent.namespace, address, localAccount); + } +} diff --git a/packages/app-runtime/src/events/index.ts b/packages/app-runtime/src/events/index.ts index 5b512897c..be009dd2b 100644 --- a/packages/app-runtime/src/events/index.ts +++ b/packages/app-runtime/src/events/index.ts @@ -1,6 +1,6 @@ export * from "./AccountSelectedEvent"; -export * from "./DatawalletSynchronizedEvent"; export * from "./ExternalEventReceivedEvent"; +export * from "./LocalAccountDeletionDateChangedEvent"; export * from "./MailReceivedEvent"; export * from "./OnboardingChangeReceivedEvent"; export * from "./RelationshipSelectedEvent"; diff --git a/packages/app-runtime/src/modules/pushNotifications/PushNotificationModule.ts b/packages/app-runtime/src/modules/pushNotifications/PushNotificationModule.ts index 92df666a9..31f9141d2 100644 --- a/packages/app-runtime/src/modules/pushNotifications/PushNotificationModule.ts +++ b/packages/app-runtime/src/modules/pushNotifications/PushNotificationModule.ts @@ -1,6 +1,6 @@ import { Result } from "@js-soft/ts-utils"; import { AppRuntimeErrors } from "../../AppRuntimeErrors"; -import { AccountSelectedEvent, DatawalletSynchronizedEvent, ExternalEventReceivedEvent } from "../../events"; +import { AccountSelectedEvent, ExternalEventReceivedEvent } from "../../events"; import { RemoteNotificationEvent, RemoteNotificationRegistrationEvent } from "../../natives"; import { AppRuntimeModule, AppRuntimeModuleConfiguration } from "../AppRuntimeModule"; import { BackboneEventName, IBackboneEventContent } from "./IBackboneEventContent"; @@ -35,7 +35,6 @@ export class PushNotificationModule extends AppRuntimeModule { + public async init(): Promise { + // Nothing to do here + } + + public start(): Promise | void { + this.subscribeToEvent(DatawalletSynchronizedEvent, this.handleDatawalletSynchronized.bind(this)); + } + + private async handleDatawalletSynchronized(event: DatawalletSynchronizedEvent) { + const services = await this.runtime.getServices(event.eventTargetAddress); + const identityDeletionProcessResult = await services.transportServices.identityDeletionProcesses.getIdentityDeletionProcesses(); + + if (identityDeletionProcessResult.isError) { + this.logger.error(identityDeletionProcessResult); + return; + } + + if (identityDeletionProcessResult.value.length === 0) return; + + const mostRecentIdentityDeletionProcess = identityDeletionProcessResult.value.at(-1)!; + let newDeletionDate; + switch (mostRecentIdentityDeletionProcess.status) { + case IdentityDeletionProcessStatus.Approved: + newDeletionDate = CoreDate.from(mostRecentIdentityDeletionProcess.gracePeriodEndsAt!); + break; + case IdentityDeletionProcessStatus.Cancelled: + case IdentityDeletionProcessStatus.Rejected: + case IdentityDeletionProcessStatus.WaitingForApproval: + newDeletionDate = undefined; + break; + } + + const account = await this.runtime.multiAccountController.getAccountByAddress(event.eventTargetAddress); + const previousDeletionDate = account.deletionDate; + + if (previousDeletionDate === newDeletionDate) return; + + await this.runtime.multiAccountController.updateLocalAccountDeletionDate(event.eventTargetAddress, newDeletionDate); + + const updatedAccount = await this.runtime.multiAccountController.getAccountByAddress(event.eventTargetAddress); + this.runtime.eventBus.publish(new LocalAccountDeletionDateChangedEvent(event.eventTargetAddress, LocalAccountMapper.toLocalAccountDTO(updatedAccount))); + } + + public override stop(): Promise | void { + this.unsubscribeFromAllEvents(); + } +} diff --git a/packages/app-runtime/src/modules/runtimeEvents/IdentityDeletionProcessStatusChangedModule.ts b/packages/app-runtime/src/modules/runtimeEvents/IdentityDeletionProcessStatusChangedModule.ts new file mode 100644 index 000000000..a8306b593 --- /dev/null +++ b/packages/app-runtime/src/modules/runtimeEvents/IdentityDeletionProcessStatusChangedModule.ts @@ -0,0 +1,44 @@ +import { CoreDate } from "@nmshd/core-types"; +import { IdentityDeletionProcessStatus, IdentityDeletionProcessStatusChangedEvent } from "@nmshd/runtime"; +import { AppRuntimeError } from "../../AppRuntimeError"; +import { AppRuntimeModule, AppRuntimeModuleConfiguration } from "../AppRuntimeModule"; + +export interface IdentityDeletionProcessStatusChangedModuleConfig extends AppRuntimeModuleConfiguration {} + +export class IdentityDeletionProcessChangedModuleError extends AppRuntimeError {} + +export class IdentityDeletionProcessStatusChangedModule extends AppRuntimeModule { + public async init(): Promise { + // Nothing to do here + } + + public start(): Promise | void { + this.subscribeToEvent(IdentityDeletionProcessStatusChangedEvent, this.handleIdentityDeletionProcessStatusChanged.bind(this)); + } + + private async handleIdentityDeletionProcessStatusChanged(event: IdentityDeletionProcessStatusChangedEvent) { + const identityDeletionProcess = event.data; + + switch (identityDeletionProcess.status) { + case IdentityDeletionProcessStatus.Approved: + await this.runtime.multiAccountController.updateLocalAccountDeletionDate(event.eventTargetAddress, CoreDate.from(identityDeletionProcess.gracePeriodEndsAt!)); + break; + + case IdentityDeletionProcessStatus.Cancelled: + const account = await this.runtime.multiAccountController.getAccountByAddress(event.eventTargetAddress); + const previousDeletionDate = account.deletionDate; + + if (!previousDeletionDate) break; + + await this.runtime.multiAccountController.updateLocalAccountDeletionDate(event.eventTargetAddress, undefined); + break; + + default: + break; + } + } + + public override stop(): Promise | void { + this.unsubscribeFromAllEvents(); + } +} diff --git a/packages/app-runtime/src/modules/runtimeEvents/index.ts b/packages/app-runtime/src/modules/runtimeEvents/index.ts index 69825c2de..e37a1aa45 100644 --- a/packages/app-runtime/src/modules/runtimeEvents/index.ts +++ b/packages/app-runtime/src/modules/runtimeEvents/index.ts @@ -1,2 +1,4 @@ +export * from "./DatawalletSynchronizedModule"; +export * from "./IdentityDeletionProcessStatusChangedModule"; export * from "./MessageReceivedModule"; export * from "./RelationshipChangedModule"; diff --git a/packages/app-runtime/src/multiAccount/MultiAccountController.ts b/packages/app-runtime/src/multiAccount/MultiAccountController.ts index 55728c7ec..252590aa1 100644 --- a/packages/app-runtime/src/multiAccount/MultiAccountController.ts +++ b/packages/app-runtime/src/multiAccount/MultiAccountController.ts @@ -42,6 +42,7 @@ export class MultiAccountController { this._dbClosed = false; this._localAccounts = await this._db.getCollection("LocalAccounts"); + return this; } @@ -81,6 +82,18 @@ export class MultiAccountController { return dbAccounts.map((account) => LocalAccount.from(account)); } + public async getAccountsInDeletion(): Promise { + const allAccounts = await this.getAccounts(); + const accountsInDeletion = allAccounts.filter((item) => item.deletionDate !== undefined); + return accountsInDeletion; + } + + public async getAccountsNotInDeletion(): Promise { + const allAccounts = await this.getAccounts(); + const accountsNotInDeletion = allAccounts.filter((item) => item.deletionDate === undefined); + return accountsNotInDeletion; + } + public async selectAccount(id: CoreId): Promise<[LocalAccount, AccountController]> { this._log.trace(`Selecting LocalAccount with id ${id}...`); const account = await this._localAccounts.read(id.toString()); @@ -238,6 +251,22 @@ export class MultiAccountController { await this._localAccounts.update(oldAccount, renamedAccount); } + public async updateLocalAccountDeletionDate(address: string, deletionDate?: CoreDate): Promise { + const oldAccount = await this._localAccounts.findOne({ address }); + + if (!oldAccount) { + throw TransportCoreErrors.general.recordNotFound(LocalAccount, address).logWith(this._log); + } + + const account = LocalAccount.from(oldAccount); + + account.deletionDate = deletionDate ?? undefined; + await this._localAccounts.update(oldAccount, account); + + const cachedAccount = this.sessionStorage.findSession(address)?.account; + if (cachedAccount) cachedAccount.deletionDate = deletionDate?.toString(); + } + public async updateLastAccessedAt(accountId: string): Promise { const document = await this._localAccounts.read(accountId); if (!document) { diff --git a/packages/app-runtime/src/multiAccount/data/LocalAccount.ts b/packages/app-runtime/src/multiAccount/data/LocalAccount.ts index 4bcab0860..e2cb890cc 100644 --- a/packages/app-runtime/src/multiAccount/data/LocalAccount.ts +++ b/packages/app-runtime/src/multiAccount/data/LocalAccount.ts @@ -1,14 +1,15 @@ import { ISerializable, Serializable, serialize, type, validate } from "@js-soft/ts-serval"; -import { CoreAddress, CoreDate, CoreId, ICoreDate } from "@nmshd/core-types"; +import { CoreAddress, CoreDate, CoreId, ICoreAddress, ICoreDate, ICoreId } from "@nmshd/core-types"; export interface ILocalAccount extends ISerializable { - id: CoreId; - address?: CoreAddress; + id: ICoreId; + address?: ICoreAddress; name: string; directory: string; order: number; lastAccessedAt?: ICoreDate; devicePushIdentifier?: string; + deletionDate?: ICoreDate; } @type("LocalAccount") @@ -41,6 +42,10 @@ export class LocalAccount extends Serializable implements ILocalAccount { @serialize() public devicePushIdentifier?: string; + @validate({ nullable: true }) + @serialize() + public deletionDate?: CoreDate; + public static from(value: ILocalAccount): LocalAccount { return this.fromAny(value); } diff --git a/packages/app-runtime/src/multiAccount/data/LocalAccountDTO.ts b/packages/app-runtime/src/multiAccount/data/LocalAccountDTO.ts index 69bb38ede..43724520c 100644 --- a/packages/app-runtime/src/multiAccount/data/LocalAccountDTO.ts +++ b/packages/app-runtime/src/multiAccount/data/LocalAccountDTO.ts @@ -6,4 +6,5 @@ export interface LocalAccountDTO { order: number; lastAccessedAt?: string; devicePushIdentifier?: string; + deletionDate?: string; } diff --git a/packages/app-runtime/src/multiAccount/data/LocalAccountMapper.ts b/packages/app-runtime/src/multiAccount/data/LocalAccountMapper.ts index 4de37fd74..8e05f3a87 100644 --- a/packages/app-runtime/src/multiAccount/data/LocalAccountMapper.ts +++ b/packages/app-runtime/src/multiAccount/data/LocalAccountMapper.ts @@ -10,7 +10,8 @@ export class LocalAccountMapper { directory: localAccount.directory.toString(), order: localAccount.order, lastAccessedAt: localAccount.lastAccessedAt?.toString(), - devicePushIdentifier: localAccount.devicePushIdentifier + devicePushIdentifier: localAccount.devicePushIdentifier, + deletionDate: localAccount.deletionDate?.toString() }; } } diff --git a/packages/app-runtime/test/modules/DatawalletSynchronizedModule.test.ts b/packages/app-runtime/test/modules/DatawalletSynchronizedModule.test.ts new file mode 100644 index 000000000..961aa0f73 --- /dev/null +++ b/packages/app-runtime/test/modules/DatawalletSynchronizedModule.test.ts @@ -0,0 +1,83 @@ +import { CoreId } from "@nmshd/core-types"; +import { IdentityDeletionProcessStatus } from "@nmshd/runtime"; +import { AppRuntime, LocalAccountDeletionDateChangedEvent, LocalAccountMapper, LocalAccountSession } from "../../src"; +import { TestUtil } from "../lib"; + +describe("DatawalletSynchronized", function () { + let runtimeDevice1: AppRuntime; + let sessionDevice1: LocalAccountSession; + + let runtimeDevice2: AppRuntime; + let sessionDevice2: LocalAccountSession; + + beforeAll(async function () { + runtimeDevice1 = await TestUtil.createRuntime(); + await runtimeDevice1.start(); + + const [localAccountDevice1] = await TestUtil.provideAccounts(runtimeDevice1, 1); + sessionDevice1 = await runtimeDevice1.selectAccount(localAccountDevice1.id); + + runtimeDevice2 = await TestUtil.createRuntime(); + await runtimeDevice2.start(); + + const createDeviceResult = await sessionDevice1.transportServices.devices.createDevice({ name: "test", isAdmin: true }); + const onboardingInfoResult = await sessionDevice1.transportServices.devices.getDeviceOnboardingInfo({ id: createDeviceResult.value.id, profileName: "Test" }); + const localAccountDevice2 = await runtimeDevice2.accountServices.onboardAccount(onboardingInfoResult.value); + sessionDevice2 = await runtimeDevice2.selectAccount(localAccountDevice2.id.toString()); + + await sessionDevice1.transportServices.account.syncDatawallet(); + await sessionDevice2.transportServices.account.syncDatawallet(); + }); + + afterEach(async () => { + const activeIdentityDeletionProcess = await sessionDevice1.transportServices.identityDeletionProcesses.getActiveIdentityDeletionProcess(); + if (!activeIdentityDeletionProcess.isSuccess) return; + + if (activeIdentityDeletionProcess.value.status === IdentityDeletionProcessStatus.Approved) { + const abortResult = await sessionDevice1.transportServices.identityDeletionProcesses.cancelIdentityDeletionProcess(); + if (abortResult.isError) throw abortResult.error; + + await sessionDevice2.transportServices.account.syncDatawallet(); + await TestUtil.awaitEvent(runtimeDevice2, LocalAccountDeletionDateChangedEvent); + } + }); + + afterAll(async function () { + await runtimeDevice1.stop(); + await runtimeDevice2.stop(); + }); + + test("should set the deletionDate on the LocalAccount on a second device when an IdentityDeletionProcess is initiated", async function () { + const initiateDeletionResult = await sessionDevice1.transportServices.identityDeletionProcesses.initiateIdentityDeletionProcess(); + expect(sessionDevice2.account.deletionDate).toBeUndefined(); + + await sessionDevice2.transportServices.account.syncDatawallet(); + const event = await TestUtil.awaitEvent(runtimeDevice2, LocalAccountDeletionDateChangedEvent); + const updatedAccount = await runtimeDevice2.multiAccountController.getAccountByAddress(sessionDevice2.account.address!); + + expect(event.data).toStrictEqual(LocalAccountMapper.toLocalAccountDTO(updatedAccount)); + expect(event.data.deletionDate).toBe(initiateDeletionResult.value.gracePeriodEndsAt); + + const account = await runtimeDevice2.multiAccountController.getAccount(CoreId.from(sessionDevice2.account.id)); + expect(account.deletionDate!.toString()).toBe(initiateDeletionResult.value.gracePeriodEndsAt); + }); + + test("should unset the deletionDate on the LocalAccount on a second device when an IdentityDeletionProcess is cancelled", async function () { + await sessionDevice1.transportServices.identityDeletionProcesses.initiateIdentityDeletionProcess(); + await sessionDevice2.transportServices.account.syncDatawallet(); + await TestUtil.awaitEvent(runtimeDevice2, LocalAccountDeletionDateChangedEvent); + + await sessionDevice1.transportServices.identityDeletionProcesses.cancelIdentityDeletionProcess(); + expect(sessionDevice2.account.deletionDate).toBeDefined(); + + await sessionDevice2.transportServices.account.syncDatawallet(); + const event = await TestUtil.awaitEvent(runtimeDevice2, LocalAccountDeletionDateChangedEvent); + const updatedAccount = await runtimeDevice2.multiAccountController.getAccountByAddress(sessionDevice2.account.address!); + + expect(event.data).toStrictEqual(LocalAccountMapper.toLocalAccountDTO(updatedAccount)); + expect(event.data.deletionDate).toBeUndefined(); + + const account = await runtimeDevice2.multiAccountController.getAccount(CoreId.from(sessionDevice2.account.id)); + expect(account.deletionDate).toBeUndefined(); + }); +}); diff --git a/packages/app-runtime/test/modules/IdentityDeletionProcessStatusChangedModule.test.ts b/packages/app-runtime/test/modules/IdentityDeletionProcessStatusChangedModule.test.ts new file mode 100644 index 000000000..88a4648ec --- /dev/null +++ b/packages/app-runtime/test/modules/IdentityDeletionProcessStatusChangedModule.test.ts @@ -0,0 +1,51 @@ +import { CoreId } from "@nmshd/core-types"; +import { IdentityDeletionProcessStatus } from "@nmshd/runtime"; +import { AppRuntime, LocalAccountSession } from "../../src"; +import { TestUtil } from "../lib"; + +describe("IdentityDeletionProcessStatusChanged", function () { + let runtime: AppRuntime; + let session: LocalAccountSession; + + beforeAll(async function () { + runtime = await TestUtil.createRuntime(); + await runtime.start(); + + const [localAccount] = await TestUtil.provideAccounts(runtime, 1); + session = await runtime.selectAccount(localAccount.id); + }); + + afterEach(async () => { + const activeIdentityDeletionProcess = await session.transportServices.identityDeletionProcesses.getActiveIdentityDeletionProcess(); + if (!activeIdentityDeletionProcess.isSuccess) return; + + if (activeIdentityDeletionProcess.value.status === IdentityDeletionProcessStatus.Approved) { + const abortResult = await session.transportServices.identityDeletionProcesses.cancelIdentityDeletionProcess(); + if (abortResult.isError) throw abortResult.error; + } + }); + + afterAll(async () => await runtime.stop()); + + test("should set the deletionDate on the LocalAccount initiating an IdentityDeletionProcess", async function () { + expect(session.account.deletionDate).toBeUndefined(); + + const initiateDeletionResult = await session.transportServices.identityDeletionProcesses.initiateIdentityDeletionProcess(); + + expect(session.account.deletionDate).toBe(initiateDeletionResult.value.gracePeriodEndsAt); + + const account = await runtime.multiAccountController.getAccount(CoreId.from(session.account.id)); + expect(account.deletionDate!.toString()).toBe(initiateDeletionResult.value.gracePeriodEndsAt); + }); + + test("should unset the deletionDate on the LocalAccount cancelling an IdentityDeletionProcess", async function () { + await session.transportServices.identityDeletionProcesses.initiateIdentityDeletionProcess(); + expect(session.account.deletionDate).toBeDefined(); + + await session.transportServices.identityDeletionProcesses.cancelIdentityDeletionProcess(); + expect(session.account.deletionDate).toBeUndefined(); + + const account = await runtime.multiAccountController.getAccount(CoreId.from(session.account.id)); + expect(account.deletionDate).toBeUndefined(); + }); +}); diff --git a/packages/app-runtime/test/modules/PushNotification.test.ts b/packages/app-runtime/test/modules/PushNotification.test.ts index 87a7a8608..329759aca 100644 --- a/packages/app-runtime/test/modules/PushNotification.test.ts +++ b/packages/app-runtime/test/modules/PushNotification.test.ts @@ -1,5 +1,6 @@ import { sleep } from "@js-soft/ts-utils"; -import { AppRuntime, DatawalletSynchronizedEvent, ExternalEventReceivedEvent, LocalAccountSession, RemoteNotificationEvent, RemoteNotificationRegistrationEvent } from "../../src"; +import { DatawalletSynchronizedEvent } from "@nmshd/runtime"; +import { AppRuntime, ExternalEventReceivedEvent, LocalAccountSession, RemoteNotificationEvent, RemoteNotificationRegistrationEvent } from "../../src"; import { TestUtil } from "../lib"; describe("PushNotificationModuleTest", function () { diff --git a/packages/app-runtime/test/multiAccount/MultiAccountController.test.ts b/packages/app-runtime/test/multiAccount/MultiAccountController.test.ts new file mode 100644 index 000000000..22863881b --- /dev/null +++ b/packages/app-runtime/test/multiAccount/MultiAccountController.test.ts @@ -0,0 +1,66 @@ +import { IdentityDeletionProcessStatus } from "@nmshd/runtime"; +import { AppRuntime, LocalAccountDTO, LocalAccountSession } from "../../src"; +import { TestUtil } from "../lib"; + +describe("MultiAccountController", function () { + let runtime: AppRuntime; + + let account1: LocalAccountDTO; + let account2: LocalAccountDTO; + let account3: LocalAccountDTO; + + let session1: LocalAccountSession; + let session2: LocalAccountSession; + let session3: LocalAccountSession; + + beforeAll(async function () { + runtime = await TestUtil.createRuntime(); + await runtime.start(); + + [account1, account2, account3] = await TestUtil.provideAccounts(runtime, 3); + + session1 = await runtime.selectAccount(account1.id); + session2 = await runtime.selectAccount(account2.id); + session3 = await runtime.selectAccount(account3.id); + }); + + afterEach(async () => { + for (const session of [session1, session2, session3]) { + const activeIdentityDeletionProcess = await session.transportServices.identityDeletionProcesses.getActiveIdentityDeletionProcess(); + if (!activeIdentityDeletionProcess.isSuccess) { + return; + } + + let abortResult; + if (activeIdentityDeletionProcess.value.status === IdentityDeletionProcessStatus.Approved) { + abortResult = await session.transportServices.identityDeletionProcesses.cancelIdentityDeletionProcess(); + } + if (abortResult?.isError) throw abortResult.error; + } + }); + + afterAll(async () => await runtime.stop()); + + test("should get all accounts in deletion", async function () { + await session1.transportServices.identityDeletionProcesses.initiateIdentityDeletionProcess(); + await session2.transportServices.identityDeletionProcesses.initiateIdentityDeletionProcess(); + + const accountsInDeletion = await runtime.multiAccountController.getAccountsInDeletion(); + expect(accountsInDeletion).toHaveLength(2); + + const addressesInDeletion = accountsInDeletion.map((account) => account.address!.toString()); + expect(addressesInDeletion).toContain(account1.address); + expect(addressesInDeletion).toContain(account2.address); + }); + + test("should get all accounts not in deletion", async function () { + await session1.transportServices.identityDeletionProcesses.initiateIdentityDeletionProcess(); + await session2.transportServices.identityDeletionProcesses.initiateIdentityDeletionProcess(); + + const accountsNotInDeletion = await runtime.multiAccountController.getAccountsNotInDeletion(); + expect(accountsNotInDeletion).toHaveLength(1); + + const addressesNotInDeletion = accountsNotInDeletion.map((account) => account.address!.toString()); + expect(addressesNotInDeletion).toContain(account3.address); + }); +}); diff --git a/packages/runtime/src/events/EventProxy.ts b/packages/runtime/src/events/EventProxy.ts index fdbf0f1eb..020e8ad17 100644 --- a/packages/runtime/src/events/EventProxy.ts +++ b/packages/runtime/src/events/EventProxy.ts @@ -23,6 +23,7 @@ import { ThirdPartyRelationshipAttributeSucceededEvent } from "./consumption"; import { + DatawalletSynchronizedEvent, IdentityDeletionProcessStatusChangedEvent, MessageDeliveredEvent, MessageReceivedEvent, @@ -108,6 +109,10 @@ export class EventProxy { this.subscribeToSourceEvent(transport.PeerDeletionCancelledEvent, (event) => { this.targetEventBus.publish(new PeerDeletionCancelledEvent(event.eventTargetAddress, RelationshipMapper.toRelationshipDTO(event.data))); }); + + this.subscribeToSourceEvent(transport.DatawalletSynchronizedEvent, (event) => { + this.targetEventBus.publish(new DatawalletSynchronizedEvent(event.eventTargetAddress)); + }); } private proxyConsumptionEvents() { diff --git a/packages/runtime/src/events/transport/DatawalletSynchronizedEvent.ts b/packages/runtime/src/events/transport/DatawalletSynchronizedEvent.ts new file mode 100644 index 000000000..1e641701f --- /dev/null +++ b/packages/runtime/src/events/transport/DatawalletSynchronizedEvent.ts @@ -0,0 +1,9 @@ +import { DataEvent } from "../DataEvent"; + +export class DatawalletSynchronizedEvent extends DataEvent { + public static readonly namespace: string = "transport.datawalletSynchronized"; + + public constructor(eventTargetAddress: string) { + super(DatawalletSynchronizedEvent.namespace, eventTargetAddress, undefined); + } +} diff --git a/packages/runtime/src/events/transport/index.ts b/packages/runtime/src/events/transport/index.ts index ea53d15f8..fc004346d 100644 --- a/packages/runtime/src/events/transport/index.ts +++ b/packages/runtime/src/events/transport/index.ts @@ -1,3 +1,4 @@ +export * from "./DatawalletSynchronizedEvent"; export * from "./IdentityDeletionProcessStatusChangedEvent"; export * from "./MessageDeliveredEvent"; export * from "./MessageReceivedEvent"; diff --git a/packages/runtime/test/transport/account.test.ts b/packages/runtime/test/transport/account.test.ts index 9697b9a4d..f6de20dd8 100644 --- a/packages/runtime/test/transport/account.test.ts +++ b/packages/runtime/test/transport/account.test.ts @@ -1,17 +1,24 @@ import { CoreDate } from "@nmshd/core-types"; import { DateTime } from "luxon"; -import { DeviceDTO, DeviceOnboardingInfoDTO, TransportServices } from "../../src"; -import { emptyRelationshipTemplateContent, RuntimeServiceProvider, uploadFile } from "../lib"; +import { DatawalletSynchronizedEvent, DeviceDTO, DeviceOnboardingInfoDTO, TransportServices } from "../../src"; +import { emptyRelationshipTemplateContent, MockEventBus, RuntimeServiceProvider, uploadFile } from "../lib"; const serviceProvider = new RuntimeServiceProvider(); let sTransportServices: TransportServices; let rTransportServices: TransportServices; +let sEventBus: MockEventBus; + beforeAll(async () => { const runtimeServices = await serviceProvider.launch(2, { enableDatawallet: true }); sTransportServices = runtimeServices[0].transport; rTransportServices = runtimeServices[1].transport; + + sEventBus = runtimeServices[0].eventBus; }, 30000); + +beforeEach(() => sEventBus.reset()); + afterAll(async () => await serviceProvider.stop()); describe("Sync", () => { @@ -49,6 +56,12 @@ describe("Automatic Datawallet Sync", () => { expect(oldSyncTime).not.toStrictEqual(newSyncTime); }); + test("should receive a DatawalletSynchronizedEvent", async () => { + await sTransportServices.account.syncDatawallet(); + + await expect(sEventBus).toHavePublished(DatawalletSynchronizedEvent); + }); + test("should not run an automatic datawallet sync", async () => { const disableResult = await sTransportServices.account.disableAutoSync(); expect(disableResult).toBeSuccessful(); diff --git a/packages/transport/src/events/DatawalletSynchronizedEvent.ts b/packages/transport/src/events/DatawalletSynchronizedEvent.ts new file mode 100644 index 000000000..7ac5665bd --- /dev/null +++ b/packages/transport/src/events/DatawalletSynchronizedEvent.ts @@ -0,0 +1,9 @@ +import { TransportDataEvent } from "./TransportDataEvent"; + +export class DatawalletSynchronizedEvent extends TransportDataEvent { + public static readonly namespace: string = "transport.datawalletSynchronized"; + + public constructor(eventTargetAddress: string) { + super(DatawalletSynchronizedEvent.namespace, eventTargetAddress, undefined); + } +} diff --git a/packages/transport/src/events/index.ts b/packages/transport/src/events/index.ts index c3318f1a4..80fec200c 100644 --- a/packages/transport/src/events/index.ts +++ b/packages/transport/src/events/index.ts @@ -1,3 +1,4 @@ +export * from "./DatawalletSynchronizedEvent"; export * from "./IdentityDeletionProcessStatusChangedEvent"; export * from "./MessageDeliveredEvent"; export * from "./MessageReceivedEvent"; diff --git a/packages/transport/src/modules/accounts/AccountController.ts b/packages/transport/src/modules/accounts/AccountController.ts index 40b2592cd..6dee85801 100644 --- a/packages/transport/src/modules/accounts/AccountController.ts +++ b/packages/transport/src/modules/accounts/AccountController.ts @@ -230,11 +230,9 @@ export class AccountController { } public async syncDatawallet(force = false): Promise { - if (!force && !this.autoSync) { - return; - } + if (!force && !this.autoSync) return; - return await this.synchronization.sync("OnlyDatawallet"); + await this.synchronization.sync("OnlyDatawallet"); } public async syncEverything(): Promise { diff --git a/packages/transport/src/modules/sync/SyncController.ts b/packages/transport/src/modules/sync/SyncController.ts index 638bfce7d..7c2b01d74 100644 --- a/packages/transport/src/modules/sync/SyncController.ts +++ b/packages/transport/src/modules/sync/SyncController.ts @@ -3,6 +3,7 @@ import { log } from "@js-soft/ts-utils"; import { CoreDate, CoreError, CoreId } from "@nmshd/core-types"; import { ControllerName, RequestError, TransportController, TransportCoreErrors, TransportError, TransportLoggerFactory } from "../../core"; import { DependencyOverrides } from "../../core/DependencyOverrides"; +import { DatawalletSynchronizedEvent } from "../../events/DatawalletSynchronizedEvent"; import { AccountController } from "../accounts/AccountController"; import { ChangedItems } from "./ChangedItems"; import { DatawalletModificationMapper } from "./DatawalletModificationMapper"; @@ -99,6 +100,8 @@ export class SyncController extends TransportController { if (this.datawalletEnabled && (await this.unpushedDatawalletModifications.exists())) { await this.syncDatawallet().catch((e) => this.log.error(e)); } + + this.transport.eventBus.publish(new DatawalletSynchronizedEvent(this.parent.identity.address.toString())); } } From 4abbafe2dc5f342e5e7f2657c066b835d28b8e2d Mon Sep 17 00:00:00 2001 From: Sebastian Mahr Date: Thu, 28 Nov 2024 15:06:22 +0100 Subject: [PATCH 10/21] Provide backbone-defined public RelationshipTemplate references (#336) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add public relationship references POC * chore: nomenclature * chore: update index * chore: update schema * chore: typo * chore: pr comments * chore: pr comments * chore: use barrels * test: add tests for new backbone version * test: add rest client mock * chore: add missing file * chore: fix eslint * chore: fix eslint * chore: test naming * chore: use standard mock strings * Update packages/runtime/src/useCases/transport/publicRelationshipTemplateReferences/PublicRelationshipTemplateReferenceMapper.ts Co-authored-by: Julian König <33655937+jkoenig134@users.noreply.github.com> * chore: switch to mockito * chore: remove unused file * Update packages/transport/test/modules/publicRelationshipTemplateReferences/PublicRelationshipTemplateReferencesController.test.ts Co-authored-by: Milena Czierlinski <146972016+Milena-Czierlinski@users.noreply.github.com> * chore: simpler naming * chore: simplify / order tests --------- Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com> Co-authored-by: Julian König <33655937+jkoenig134@users.noreply.github.com> Co-authored-by: Milena Czierlinski <146972016+Milena-Czierlinski@users.noreply.github.com> Co-authored-by: Julian König --- package-lock.json | 5 +- packages/runtime/package.json | 7 +- packages/runtime/src/Runtime.ts | 5 + .../src/extensibility/TransportServices.ts | 4 +- ...licRelationshipTemplateReferencesFacade.ts | 12 + .../extensibility/facades/transport/index.ts | 3 +- .../PublicRelationshipTemplateReferenceDTO.ts | 5 + packages/runtime/src/types/transport/index.ts | 3 +- .../runtime/src/useCases/common/Schemas.ts | 732 +++++++++--------- .../runtime/src/useCases/transport/index.ts | 3 +- ...GetPublicRelationshipTemplateReferences.ts | 19 + ...blicRelationshipTemplateReferenceMapper.ts | 18 + .../index.ts | 1 + ...blicRelationshipTemplateReferences.test.ts | 38 + .../transport/src/core/TransportController.ts | 1 + .../src/modules/accounts/AccountController.ts | 3 + packages/transport/src/modules/index.ts | 3 + ...elationshipTemplateReferencesController.ts | 34 + ...blicRelationshipTemplateReferenceClient.ts | 14 + .../PublicRelationshipTemplateReference.ts | 15 + ...onshipTemplateReferencesController.test.ts | 61 ++ 21 files changed, 612 insertions(+), 374 deletions(-) create mode 100644 packages/runtime/src/extensibility/facades/transport/PublicRelationshipTemplateReferencesFacade.ts create mode 100644 packages/runtime/src/types/transport/PublicRelationshipTemplateReferenceDTO.ts create mode 100644 packages/runtime/src/useCases/transport/publicRelationshipTemplateReferences/GetPublicRelationshipTemplateReferences.ts create mode 100644 packages/runtime/src/useCases/transport/publicRelationshipTemplateReferences/PublicRelationshipTemplateReferenceMapper.ts create mode 100644 packages/runtime/src/useCases/transport/publicRelationshipTemplateReferences/index.ts create mode 100644 packages/runtime/test/transport/publicRelationshipTemplateReferences.test.ts create mode 100644 packages/transport/src/modules/publicRelationshipTemplateReferences/PublicRelationshipTemplateReferencesController.ts create mode 100644 packages/transport/src/modules/publicRelationshipTemplateReferences/backbone/PublicRelationshipTemplateReferenceClient.ts create mode 100644 packages/transport/src/modules/publicRelationshipTemplateReferences/data/PublicRelationshipTemplateReference.ts create mode 100644 packages/transport/test/modules/publicRelationshipTemplateReferences/PublicRelationshipTemplateReferencesController.test.ts diff --git a/package-lock.json b/package-lock.json index 53c1dfa65..1354fe12c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8934,6 +8934,8 @@ }, "node_modules/ts-mockito": { "version": "2.6.1", + "resolved": "https://registry.npmjs.org/ts-mockito/-/ts-mockito-2.6.1.tgz", + "integrity": "sha512-qU9m/oEBQrKq5hwfbJ7MgmVN5Gu6lFnIGWvpxSjrqq6YYEVv+RwVFWySbZMBgazsWqv6ctAyVBpo9TmAxnOEKw==", "dev": true, "license": "MIT", "dependencies": { @@ -9633,7 +9635,8 @@ "@types/lodash": "^4.17.13", "@types/luxon": "^3.4.2", "@types/qrcode": "^1.5.5", - "ts-json-schema-generator": "2.3.0" + "ts-json-schema-generator": "2.3.0", + "ts-mockito": "^2.6.1" } }, "packages/runtime/node_modules/ajv-errors": { diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 376cee3a2..9737ff10a 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -70,6 +70,7 @@ "@nmshd/crypto": "2.1.0", "@nmshd/iql": "^1.0.2", "@nmshd/transport": "*", + "@nmshd/typescript-ioc": "3.2.4", "ajv": "^8.17.1", "ajv-errors": "^3.0.0", "ajv-formats": "^3.0.1", @@ -78,8 +79,7 @@ "luxon": "^3.5.0", "qrcode": "1.5.4", "reflect-metadata": "^0.2.2", - "ts-simple-nameof": "^1.3.1", - "@nmshd/typescript-ioc": "3.2.4" + "ts-simple-nameof": "^1.3.1" }, "devDependencies": { "@js-soft/docdb-access-loki": "1.1.0", @@ -89,7 +89,8 @@ "@types/lodash": "^4.17.13", "@types/luxon": "^3.4.2", "@types/qrcode": "^1.5.5", - "ts-json-schema-generator": "2.3.0" + "ts-json-schema-generator": "2.3.0", + "ts-mockito": "^2.6.1" }, "publishConfig": { "access": "public", diff --git a/packages/runtime/src/Runtime.ts b/packages/runtime/src/Runtime.ts index 6f2faa8e7..4ebfe50fa 100644 --- a/packages/runtime/src/Runtime.ts +++ b/packages/runtime/src/Runtime.ts @@ -25,6 +25,7 @@ import { IdentityController, IdentityDeletionProcessController, MessageController, + PublicRelationshipTemplateReferencesController, RelationshipsController, RelationshipTemplateController, TokenController, @@ -258,6 +259,10 @@ export abstract class Runtime { .factory(() => this.getAccountController().tokens) .scope(Scope.Request); + Container.bind(PublicRelationshipTemplateReferencesController) + .factory(() => this.getAccountController().publicRelationshipTemplateReferences) + .scope(Scope.Request); + Container.bind(ChallengeController) .factory(() => this.getAccountController().challenges) .scope(Scope.Request); diff --git a/packages/runtime/src/extensibility/TransportServices.ts b/packages/runtime/src/extensibility/TransportServices.ts index ecf2c97ac..32cdac56e 100644 --- a/packages/runtime/src/extensibility/TransportServices.ts +++ b/packages/runtime/src/extensibility/TransportServices.ts @@ -6,6 +6,7 @@ import { FilesFacade, IdentityDeletionProcessesFacade, MessagesFacade, + PublicRelationshipTemplateReferencesFacade, RelationshipsFacade, RelationshipTemplatesFacade, TokensFacade @@ -21,6 +22,7 @@ export class TransportServices { @Inject public readonly account: AccountFacade, @Inject public readonly devices: DevicesFacade, @Inject public readonly challenges: ChallengesFacade, - @Inject public readonly identityDeletionProcesses: IdentityDeletionProcessesFacade + @Inject public readonly identityDeletionProcesses: IdentityDeletionProcessesFacade, + @Inject public readonly publicRelationshipTemplateReferences: PublicRelationshipTemplateReferencesFacade ) {} } diff --git a/packages/runtime/src/extensibility/facades/transport/PublicRelationshipTemplateReferencesFacade.ts b/packages/runtime/src/extensibility/facades/transport/PublicRelationshipTemplateReferencesFacade.ts new file mode 100644 index 000000000..88351bf33 --- /dev/null +++ b/packages/runtime/src/extensibility/facades/transport/PublicRelationshipTemplateReferencesFacade.ts @@ -0,0 +1,12 @@ +import { Result } from "@js-soft/ts-utils"; +import { Inject } from "@nmshd/typescript-ioc"; +import { PublicRelationshipTemplateReferenceDTO } from "../../../types"; +import { GetPublicRelationshipTemplateReferencesUseCase } from "../../../useCases"; + +export class PublicRelationshipTemplateReferencesFacade { + public constructor(@Inject private readonly getPublicRelationshipTemplateReferencesUseCase: GetPublicRelationshipTemplateReferencesUseCase) {} + + public async getPublicRelationshipTemplateReferences(): Promise> { + return await this.getPublicRelationshipTemplateReferencesUseCase.execute(); + } +} diff --git a/packages/runtime/src/extensibility/facades/transport/index.ts b/packages/runtime/src/extensibility/facades/transport/index.ts index a14fc3df7..2f313be75 100644 --- a/packages/runtime/src/extensibility/facades/transport/index.ts +++ b/packages/runtime/src/extensibility/facades/transport/index.ts @@ -4,6 +4,7 @@ export * from "./DevicesFacade"; export * from "./FilesFacade"; export * from "./IdentityDeletionProcessesFacade"; export * from "./MessagesFacade"; -export * from "./RelationshipTemplatesFacade"; +export * from "./PublicRelationshipTemplateReferencesFacade"; export * from "./RelationshipsFacade"; +export * from "./RelationshipTemplatesFacade"; export * from "./TokensFacade"; diff --git a/packages/runtime/src/types/transport/PublicRelationshipTemplateReferenceDTO.ts b/packages/runtime/src/types/transport/PublicRelationshipTemplateReferenceDTO.ts new file mode 100644 index 000000000..9e90e73b4 --- /dev/null +++ b/packages/runtime/src/types/transport/PublicRelationshipTemplateReferenceDTO.ts @@ -0,0 +1,5 @@ +export interface PublicRelationshipTemplateReferenceDTO { + title: string; + description: string; + truncatedReference: string; +} diff --git a/packages/runtime/src/types/transport/index.ts b/packages/runtime/src/types/transport/index.ts index 2235cf6e4..c0e6f8064 100644 --- a/packages/runtime/src/types/transport/index.ts +++ b/packages/runtime/src/types/transport/index.ts @@ -2,10 +2,11 @@ export * from "./ChallengeDTO"; export * from "./DeviceDTO"; export * from "./DeviceOnboardingInfoDTO"; export * from "./FileDTO"; -export * from "./IdentityDTO"; export * from "./IdentityDeletionProcessDTO"; +export * from "./IdentityDTO"; export * from "./MessageDTO"; export * from "./MessageWithAttachmentsDTO"; +export * from "./PublicRelationshipTemplateReferenceDTO"; export * from "./RecipientDTO"; export * from "./RelationshipDTO"; export * from "./RelationshipTemplateDTO"; diff --git a/packages/runtime/src/useCases/common/Schemas.ts b/packages/runtime/src/useCases/common/Schemas.ts index 178cd5b8f..10416a7a8 100644 --- a/packages/runtime/src/useCases/common/Schemas.ts +++ b/packages/runtime/src/useCases/common/Schemas.ts @@ -21998,70 +21998,63 @@ export const SendMessageRequest: any = { } } -export const CreateOwnRelationshipTemplateRequest: any = { +export const AcceptRelationshipRequest: any = { "$schema": "http://json-schema.org/draft-07/schema#", - "$ref": "#/definitions/CreateOwnRelationshipTemplateRequest", + "$ref": "#/definitions/AcceptRelationshipRequest", "definitions": { - "CreateOwnRelationshipTemplateRequest": { + "AcceptRelationshipRequest": { "type": "object", "properties": { - "expiresAt": { - "$ref": "#/definitions/ISO8601DateTimeString" - }, - "content": {}, - "maxNumberOfAllocations": { - "type": "number", - "minimum": 1 - }, - "forIdentity": { - "$ref": "#/definitions/AddressString" - }, - "passwordProtection": { - "type": "object", - "properties": { - "password": { - "type": "string", - "minLength": 1 - }, - "passwordIsPin": { - "type": "boolean", - "const": true - } - }, - "required": [ - "password" - ], - "additionalProperties": false + "relationshipId": { + "$ref": "#/definitions/RelationshipIdString" } }, "required": [ - "expiresAt", - "content" + "relationshipId" ], "additionalProperties": false }, - "ISO8601DateTimeString": { + "RelationshipIdString": { "type": "string", - "errorMessage": "must match ISO8601 datetime format", - "pattern": "^([+-]?\\d{4}(?!\\d{2}\\b))((-?)((0[1-9]|1[0-2])(\\3([12]\\d|0[1-9]|3[01]))?|W([0-4]\\d|5[0-2])(-?[1-7])?|(00[1-9]|0[1-9]\\d|[12]\\d{2}|3([0-5]\\d|6[1-6])))([T\\s]((([01]\\d|2[0-3])((:?)[0-5]\\d)?|24:?00)([.,]\\d+(?!:))?)?(\\17[0-5]\\d([.,]\\d+)?)?([zZ]|([+-])([01]\\d|2[0-3]):?([0-5]\\d)?)?)?)?$" + "pattern": "REL[A-Za-z0-9]{17}" + } + } +} + +export const AcceptRelationshipReactivationRequest: any = { + "$schema": "http://json-schema.org/draft-07/schema#", + "$ref": "#/definitions/AcceptRelationshipReactivationRequest", + "definitions": { + "AcceptRelationshipReactivationRequest": { + "type": "object", + "properties": { + "relationshipId": { + "$ref": "#/definitions/RelationshipIdString" + } + }, + "required": [ + "relationshipId" + ], + "additionalProperties": false }, - "AddressString": { + "RelationshipIdString": { "type": "string", - "pattern": "did:e:((([A-Za-z0-9]+(-[A-Za-z0-9]+)*)\\.)+[a-z]{2,}|localhost):dids:[0-9a-f]{22}" + "pattern": "REL[A-Za-z0-9]{17}" } } } -export const CreateQRCodeForOwnTemplateRequest: any = { +export const CanCreateRelationshipRequest: any = { "$schema": "http://json-schema.org/draft-07/schema#", - "$ref": "#/definitions/CreateQRCodeForOwnTemplateRequest", + "$ref": "#/definitions/CanCreateRelationshipRequest", "definitions": { - "CreateQRCodeForOwnTemplateRequest": { + "CanCreateRelationshipRequest": { "type": "object", "properties": { "templateId": { "$ref": "#/definitions/RelationshipTemplateIdString" - } + }, + "creationContent": {} }, "required": [ "templateId" @@ -22075,94 +22068,93 @@ export const CreateQRCodeForOwnTemplateRequest: any = { } } -export const CreateTokenForOwnTemplateRequest: any = { +export const CreateRelationshipRequest: any = { "$schema": "http://json-schema.org/draft-07/schema#", - "$ref": "#/definitions/CreateTokenForOwnTemplateRequest", + "$ref": "#/definitions/CreateRelationshipRequest", "definitions": { - "CreateTokenForOwnTemplateRequest": { + "CreateRelationshipRequest": { "type": "object", "properties": { "templateId": { "$ref": "#/definitions/RelationshipTemplateIdString" }, - "expiresAt": { - "$ref": "#/definitions/ISO8601DateTimeString" - }, - "ephemeral": { - "type": "boolean" - }, - "forIdentity": { - "$ref": "#/definitions/AddressString" - } + "creationContent": {} }, "required": [ - "templateId" + "templateId", + "creationContent" ], "additionalProperties": false }, "RelationshipTemplateIdString": { "type": "string", "pattern": "RLT[A-Za-z0-9]{17}" + } + } +} + +export const DecomposeRelationshipRequest: any = { + "$schema": "http://json-schema.org/draft-07/schema#", + "$ref": "#/definitions/DecomposeRelationshipRequest", + "definitions": { + "DecomposeRelationshipRequest": { + "type": "object", + "properties": { + "relationshipId": { + "$ref": "#/definitions/RelationshipIdString" + } + }, + "required": [ + "relationshipId" + ], + "additionalProperties": false }, - "ISO8601DateTimeString": { - "type": "string", - "errorMessage": "must match ISO8601 datetime format", - "pattern": "^([+-]?\\d{4}(?!\\d{2}\\b))((-?)((0[1-9]|1[0-2])(\\3([12]\\d|0[1-9]|3[01]))?|W([0-4]\\d|5[0-2])(-?[1-7])?|(00[1-9]|0[1-9]\\d|[12]\\d{2}|3([0-5]\\d|6[1-6])))([T\\s]((([01]\\d|2[0-3])((:?)[0-5]\\d)?|24:?00)([.,]\\d+(?!:))?)?(\\17[0-5]\\d([.,]\\d+)?)?([zZ]|([+-])([01]\\d|2[0-3]):?([0-5]\\d)?)?)?)?$" - }, - "AddressString": { + "RelationshipIdString": { "type": "string", - "pattern": "did:e:((([A-Za-z0-9]+(-[A-Za-z0-9]+)*)\\.)+[a-z]{2,}|localhost):dids:[0-9a-f]{22}" + "pattern": "REL[A-Za-z0-9]{17}" } } } -export const CreateTokenQRCodeForOwnTemplateRequest: any = { +export const GetAttributesForRelationshipRequest: any = { "$schema": "http://json-schema.org/draft-07/schema#", - "$ref": "#/definitions/CreateTokenQRCodeForOwnTemplateRequest", + "$ref": "#/definitions/GetAttributesForRelationshipRequest", "definitions": { - "CreateTokenQRCodeForOwnTemplateRequest": { + "GetAttributesForRelationshipRequest": { "type": "object", "properties": { - "templateId": { - "$ref": "#/definitions/RelationshipTemplateIdString" + "id": { + "$ref": "#/definitions/RelationshipIdString" }, - "expiresAt": { - "$ref": "#/definitions/ISO8601DateTimeString" + "hideTechnical": { + "type": "boolean" }, - "forIdentity": { - "$ref": "#/definitions/AddressString" + "onlyLatestVersions": { + "type": "boolean", + "description": "default: true" } }, "required": [ - "templateId" + "id" ], "additionalProperties": false }, - "RelationshipTemplateIdString": { - "type": "string", - "pattern": "RLT[A-Za-z0-9]{17}" - }, - "ISO8601DateTimeString": { - "type": "string", - "errorMessage": "must match ISO8601 datetime format", - "pattern": "^([+-]?\\d{4}(?!\\d{2}\\b))((-?)((0[1-9]|1[0-2])(\\3([12]\\d|0[1-9]|3[01]))?|W([0-4]\\d|5[0-2])(-?[1-7])?|(00[1-9]|0[1-9]\\d|[12]\\d{2}|3([0-5]\\d|6[1-6])))([T\\s]((([01]\\d|2[0-3])((:?)[0-5]\\d)?|24:?00)([.,]\\d+(?!:))?)?(\\17[0-5]\\d([.,]\\d+)?)?([zZ]|([+-])([01]\\d|2[0-3]):?([0-5]\\d)?)?)?)?$" - }, - "AddressString": { + "RelationshipIdString": { "type": "string", - "pattern": "did:e:((([A-Za-z0-9]+(-[A-Za-z0-9]+)*)\\.)+[a-z]{2,}|localhost):dids:[0-9a-f]{22}" + "pattern": "REL[A-Za-z0-9]{17}" } } } -export const GetRelationshipTemplateRequest: any = { +export const GetRelationshipRequest: any = { "$schema": "http://json-schema.org/draft-07/schema#", - "$ref": "#/definitions/GetRelationshipTemplateRequest", + "$ref": "#/definitions/GetRelationshipRequest", "definitions": { - "GetRelationshipTemplateRequest": { + "GetRelationshipRequest": { "type": "object", "properties": { "id": { - "$ref": "#/definitions/RelationshipTemplateIdString" + "$ref": "#/definitions/RelationshipIdString" } }, "required": [ @@ -22170,72 +22162,53 @@ export const GetRelationshipTemplateRequest: any = { ], "additionalProperties": false }, - "RelationshipTemplateIdString": { + "RelationshipIdString": { "type": "string", - "pattern": "RLT[A-Za-z0-9]{17}" + "pattern": "REL[A-Za-z0-9]{17}" } } } -export const GetRelationshipTemplatesRequest: any = { +export const GetRelationshipByAddressRequest: any = { "$schema": "http://json-schema.org/draft-07/schema#", - "$ref": "#/definitions/GetRelationshipTemplatesRequest", + "$ref": "#/definitions/GetRelationshipByAddressRequest", "definitions": { - "GetRelationshipTemplatesRequest": { + "GetRelationshipByAddressRequest": { + "type": "object", + "properties": { + "address": { + "$ref": "#/definitions/AddressString" + } + }, + "required": [ + "address" + ], + "additionalProperties": false + }, + "AddressString": { + "type": "string", + "pattern": "did:e:((([A-Za-z0-9]+(-[A-Za-z0-9]+)*)\\.)+[a-z]{2,}|localhost):dids:[0-9a-f]{22}" + } + } +} + +export const GetRelationshipsRequest: any = { + "$schema": "http://json-schema.org/draft-07/schema#", + "$ref": "#/definitions/GetRelationshipsRequest", + "definitions": { + "GetRelationshipsRequest": { "type": "object", "properties": { "query": { - "$ref": "#/definitions/GetRelationshipTemplatesQuery" - }, - "ownerRestriction": { - "$ref": "#/definitions/OwnerRestriction" + "$ref": "#/definitions/GetRelationshipsQuery" } }, "additionalProperties": false }, - "GetRelationshipTemplatesQuery": { + "GetRelationshipsQuery": { "type": "object", "properties": { - "isOwn": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "array", - "items": { - "type": "string" - } - } - ] - }, - "createdAt": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "array", - "items": { - "type": "string" - } - } - ] - }, - "expiresAt": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "array", - "items": { - "type": "string" - } - } - ] - }, - "createdBy": { + "peer": { "anyOf": [ { "type": "string" @@ -22248,7 +22221,7 @@ export const GetRelationshipTemplatesRequest: any = { } ] }, - "createdByDevice": { + "status": { "anyOf": [ { "type": "string" @@ -22261,20 +22234,7 @@ export const GetRelationshipTemplatesRequest: any = { } ] }, - "maxNumberOfAllocations": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "array", - "items": { - "type": "string" - } - } - ] - }, - "forIdentity": { + "template.id": { "anyOf": [ { "type": "string" @@ -22289,60 +22249,38 @@ export const GetRelationshipTemplatesRequest: any = { } }, "additionalProperties": false - }, - "OwnerRestriction": { - "type": "string", - "enum": [ - "o", - "p" - ] } } } -export const LoadPeerRelationshipTemplateRequest: any = { +export const RejectRelationshipRequest: any = { "$schema": "http://json-schema.org/draft-07/schema#", - "$ref": "#/definitions/LoadPeerRelationshipTemplateRequest", + "$ref": "#/definitions/RejectRelationshipRequest", "definitions": { - "LoadPeerRelationshipTemplateRequest": { + "RejectRelationshipRequest": { "type": "object", "properties": { - "reference": { - "anyOf": [ - { - "$ref": "#/definitions/TokenReferenceString" - }, - { - "$ref": "#/definitions/RelationshipTemplateReferenceString" - } - ] - }, - "password": { - "type": "string" + "relationshipId": { + "$ref": "#/definitions/RelationshipIdString" } }, "required": [ - "reference" + "relationshipId" ], - "additionalProperties": false, - "errorMessage": "token / relationship template reference invalid" - }, - "TokenReferenceString": { - "type": "string", - "pattern": "VE9L.{84}" + "additionalProperties": false }, - "RelationshipTemplateReferenceString": { + "RelationshipIdString": { "type": "string", - "pattern": "UkxU.{84}" + "pattern": "REL[A-Za-z0-9]{17}" } } } -export const AcceptRelationshipRequest: any = { +export const RejectRelationshipReactivationRequest: any = { "$schema": "http://json-schema.org/draft-07/schema#", - "$ref": "#/definitions/AcceptRelationshipRequest", + "$ref": "#/definitions/RejectRelationshipReactivationRequest", "definitions": { - "AcceptRelationshipRequest": { + "RejectRelationshipReactivationRequest": { "type": "object", "properties": { "relationshipId": { @@ -22361,11 +22299,11 @@ export const AcceptRelationshipRequest: any = { } } -export const AcceptRelationshipReactivationRequest: any = { +export const RequestRelationshipReactivationRequest: any = { "$schema": "http://json-schema.org/draft-07/schema#", - "$ref": "#/definitions/AcceptRelationshipReactivationRequest", + "$ref": "#/definitions/RequestRelationshipReactivationRequest", "definitions": { - "AcceptRelationshipReactivationRequest": { + "RequestRelationshipReactivationRequest": { "type": "object", "properties": { "relationshipId": { @@ -22384,60 +22322,57 @@ export const AcceptRelationshipReactivationRequest: any = { } } -export const CanCreateRelationshipRequest: any = { +export const RevokeRelationshipRequest: any = { "$schema": "http://json-schema.org/draft-07/schema#", - "$ref": "#/definitions/CanCreateRelationshipRequest", + "$ref": "#/definitions/RevokeRelationshipRequest", "definitions": { - "CanCreateRelationshipRequest": { + "RevokeRelationshipRequest": { "type": "object", "properties": { - "templateId": { - "$ref": "#/definitions/RelationshipTemplateIdString" - }, - "creationContent": {} + "relationshipId": { + "$ref": "#/definitions/RelationshipIdString" + } }, "required": [ - "templateId" + "relationshipId" ], "additionalProperties": false }, - "RelationshipTemplateIdString": { + "RelationshipIdString": { "type": "string", - "pattern": "RLT[A-Za-z0-9]{17}" + "pattern": "REL[A-Za-z0-9]{17}" } } } -export const CreateRelationshipRequest: any = { +export const RevokeRelationshipReactivationRequest: any = { "$schema": "http://json-schema.org/draft-07/schema#", - "$ref": "#/definitions/CreateRelationshipRequest", + "$ref": "#/definitions/RevokeRelationshipReactivationRequest", "definitions": { - "CreateRelationshipRequest": { + "RevokeRelationshipReactivationRequest": { "type": "object", "properties": { - "templateId": { - "$ref": "#/definitions/RelationshipTemplateIdString" - }, - "creationContent": {} + "relationshipId": { + "$ref": "#/definitions/RelationshipIdString" + } }, "required": [ - "templateId", - "creationContent" + "relationshipId" ], "additionalProperties": false }, - "RelationshipTemplateIdString": { + "RelationshipIdString": { "type": "string", - "pattern": "RLT[A-Za-z0-9]{17}" + "pattern": "REL[A-Za-z0-9]{17}" } } } -export const DecomposeRelationshipRequest: any = { +export const TerminateRelationshipRequest: any = { "$schema": "http://json-schema.org/draft-07/schema#", - "$ref": "#/definitions/DecomposeRelationshipRequest", + "$ref": "#/definitions/TerminateRelationshipRequest", "definitions": { - "DecomposeRelationshipRequest": { + "TerminateRelationshipRequest": { "type": "object", "properties": { "relationshipId": { @@ -22456,75 +22391,117 @@ export const DecomposeRelationshipRequest: any = { } } -export const GetAttributesForRelationshipRequest: any = { +export const CreateOwnRelationshipTemplateRequest: any = { "$schema": "http://json-schema.org/draft-07/schema#", - "$ref": "#/definitions/GetAttributesForRelationshipRequest", + "$ref": "#/definitions/CreateOwnRelationshipTemplateRequest", "definitions": { - "GetAttributesForRelationshipRequest": { + "CreateOwnRelationshipTemplateRequest": { "type": "object", "properties": { - "id": { - "$ref": "#/definitions/RelationshipIdString" + "expiresAt": { + "$ref": "#/definitions/ISO8601DateTimeString" }, - "hideTechnical": { - "type": "boolean" + "content": {}, + "maxNumberOfAllocations": { + "type": "number", + "minimum": 1 }, - "onlyLatestVersions": { - "type": "boolean", - "description": "default: true" + "forIdentity": { + "$ref": "#/definitions/AddressString" + }, + "passwordProtection": { + "type": "object", + "properties": { + "password": { + "type": "string", + "minLength": 1 + }, + "passwordIsPin": { + "type": "boolean", + "const": true + } + }, + "required": [ + "password" + ], + "additionalProperties": false } }, "required": [ - "id" + "expiresAt", + "content" ], "additionalProperties": false }, - "RelationshipIdString": { + "ISO8601DateTimeString": { "type": "string", - "pattern": "REL[A-Za-z0-9]{17}" + "errorMessage": "must match ISO8601 datetime format", + "pattern": "^([+-]?\\d{4}(?!\\d{2}\\b))((-?)((0[1-9]|1[0-2])(\\3([12]\\d|0[1-9]|3[01]))?|W([0-4]\\d|5[0-2])(-?[1-7])?|(00[1-9]|0[1-9]\\d|[12]\\d{2}|3([0-5]\\d|6[1-6])))([T\\s]((([01]\\d|2[0-3])((:?)[0-5]\\d)?|24:?00)([.,]\\d+(?!:))?)?(\\17[0-5]\\d([.,]\\d+)?)?([zZ]|([+-])([01]\\d|2[0-3]):?([0-5]\\d)?)?)?)?$" + }, + "AddressString": { + "type": "string", + "pattern": "did:e:((([A-Za-z0-9]+(-[A-Za-z0-9]+)*)\\.)+[a-z]{2,}|localhost):dids:[0-9a-f]{22}" } } } -export const GetRelationshipRequest: any = { +export const CreateQRCodeForOwnTemplateRequest: any = { "$schema": "http://json-schema.org/draft-07/schema#", - "$ref": "#/definitions/GetRelationshipRequest", + "$ref": "#/definitions/CreateQRCodeForOwnTemplateRequest", "definitions": { - "GetRelationshipRequest": { + "CreateQRCodeForOwnTemplateRequest": { "type": "object", "properties": { - "id": { - "$ref": "#/definitions/RelationshipIdString" + "templateId": { + "$ref": "#/definitions/RelationshipTemplateIdString" } }, "required": [ - "id" + "templateId" ], "additionalProperties": false }, - "RelationshipIdString": { + "RelationshipTemplateIdString": { "type": "string", - "pattern": "REL[A-Za-z0-9]{17}" + "pattern": "RLT[A-Za-z0-9]{17}" } } } -export const GetRelationshipByAddressRequest: any = { +export const CreateTokenForOwnTemplateRequest: any = { "$schema": "http://json-schema.org/draft-07/schema#", - "$ref": "#/definitions/GetRelationshipByAddressRequest", + "$ref": "#/definitions/CreateTokenForOwnTemplateRequest", "definitions": { - "GetRelationshipByAddressRequest": { + "CreateTokenForOwnTemplateRequest": { "type": "object", "properties": { - "address": { + "templateId": { + "$ref": "#/definitions/RelationshipTemplateIdString" + }, + "expiresAt": { + "$ref": "#/definitions/ISO8601DateTimeString" + }, + "ephemeral": { + "type": "boolean" + }, + "forIdentity": { "$ref": "#/definitions/AddressString" } }, "required": [ - "address" + "templateId" ], "additionalProperties": false }, + "RelationshipTemplateIdString": { + "type": "string", + "pattern": "RLT[A-Za-z0-9]{17}" + }, + "ISO8601DateTimeString": { + "type": "string", + "errorMessage": "must match ISO8601 datetime format", + "pattern": "^([+-]?\\d{4}(?!\\d{2}\\b))((-?)((0[1-9]|1[0-2])(\\3([12]\\d|0[1-9]|3[01]))?|W([0-4]\\d|5[0-2])(-?[1-7])?|(00[1-9]|0[1-9]\\d|[12]\\d{2}|3([0-5]\\d|6[1-6])))([T\\s]((([01]\\d|2[0-3])((:?)[0-5]\\d)?|24:?00)([.,]\\d+(?!:))?)?(\\17[0-5]\\d([.,]\\d+)?)?([zZ]|([+-])([01]\\d|2[0-3]):?([0-5]\\d)?)?)?)?$" + }, "AddressString": { "type": "string", "pattern": "did:e:((([A-Za-z0-9]+(-[A-Za-z0-9]+)*)\\.)+[a-z]{2,}|localhost):dids:[0-9a-f]{22}" @@ -22532,23 +22509,87 @@ export const GetRelationshipByAddressRequest: any = { } } -export const GetRelationshipsRequest: any = { +export const CreateTokenQRCodeForOwnTemplateRequest: any = { "$schema": "http://json-schema.org/draft-07/schema#", - "$ref": "#/definitions/GetRelationshipsRequest", + "$ref": "#/definitions/CreateTokenQRCodeForOwnTemplateRequest", "definitions": { - "GetRelationshipsRequest": { + "CreateTokenQRCodeForOwnTemplateRequest": { + "type": "object", + "properties": { + "templateId": { + "$ref": "#/definitions/RelationshipTemplateIdString" + }, + "expiresAt": { + "$ref": "#/definitions/ISO8601DateTimeString" + }, + "forIdentity": { + "$ref": "#/definitions/AddressString" + } + }, + "required": [ + "templateId" + ], + "additionalProperties": false + }, + "RelationshipTemplateIdString": { + "type": "string", + "pattern": "RLT[A-Za-z0-9]{17}" + }, + "ISO8601DateTimeString": { + "type": "string", + "errorMessage": "must match ISO8601 datetime format", + "pattern": "^([+-]?\\d{4}(?!\\d{2}\\b))((-?)((0[1-9]|1[0-2])(\\3([12]\\d|0[1-9]|3[01]))?|W([0-4]\\d|5[0-2])(-?[1-7])?|(00[1-9]|0[1-9]\\d|[12]\\d{2}|3([0-5]\\d|6[1-6])))([T\\s]((([01]\\d|2[0-3])((:?)[0-5]\\d)?|24:?00)([.,]\\d+(?!:))?)?(\\17[0-5]\\d([.,]\\d+)?)?([zZ]|([+-])([01]\\d|2[0-3]):?([0-5]\\d)?)?)?)?$" + }, + "AddressString": { + "type": "string", + "pattern": "did:e:((([A-Za-z0-9]+(-[A-Za-z0-9]+)*)\\.)+[a-z]{2,}|localhost):dids:[0-9a-f]{22}" + } + } +} + +export const GetRelationshipTemplateRequest: any = { + "$schema": "http://json-schema.org/draft-07/schema#", + "$ref": "#/definitions/GetRelationshipTemplateRequest", + "definitions": { + "GetRelationshipTemplateRequest": { + "type": "object", + "properties": { + "id": { + "$ref": "#/definitions/RelationshipTemplateIdString" + } + }, + "required": [ + "id" + ], + "additionalProperties": false + }, + "RelationshipTemplateIdString": { + "type": "string", + "pattern": "RLT[A-Za-z0-9]{17}" + } + } +} + +export const GetRelationshipTemplatesRequest: any = { + "$schema": "http://json-schema.org/draft-07/schema#", + "$ref": "#/definitions/GetRelationshipTemplatesRequest", + "definitions": { + "GetRelationshipTemplatesRequest": { "type": "object", "properties": { "query": { - "$ref": "#/definitions/GetRelationshipsQuery" + "$ref": "#/definitions/GetRelationshipTemplatesQuery" + }, + "ownerRestriction": { + "$ref": "#/definitions/OwnerRestriction" } }, "additionalProperties": false }, - "GetRelationshipsQuery": { + "GetRelationshipTemplatesQuery": { "type": "object", "properties": { - "peer": { + "isOwn": { "anyOf": [ { "type": "string" @@ -22561,7 +22602,7 @@ export const GetRelationshipsRequest: any = { } ] }, - "status": { + "createdAt": { "anyOf": [ { "type": "string" @@ -22574,7 +22615,59 @@ export const GetRelationshipsRequest: any = { } ] }, - "template.id": { + "expiresAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "createdBy": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "createdByDevice": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "maxNumberOfAllocations": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "forIdentity": { "anyOf": [ { "type": "string" @@ -22589,144 +22682,51 @@ export const GetRelationshipsRequest: any = { } }, "additionalProperties": false - } - } -} - -export const RejectRelationshipRequest: any = { - "$schema": "http://json-schema.org/draft-07/schema#", - "$ref": "#/definitions/RejectRelationshipRequest", - "definitions": { - "RejectRelationshipRequest": { - "type": "object", - "properties": { - "relationshipId": { - "$ref": "#/definitions/RelationshipIdString" - } - }, - "required": [ - "relationshipId" - ], - "additionalProperties": false - }, - "RelationshipIdString": { - "type": "string", - "pattern": "REL[A-Za-z0-9]{17}" - } - } -} - -export const RejectRelationshipReactivationRequest: any = { - "$schema": "http://json-schema.org/draft-07/schema#", - "$ref": "#/definitions/RejectRelationshipReactivationRequest", - "definitions": { - "RejectRelationshipReactivationRequest": { - "type": "object", - "properties": { - "relationshipId": { - "$ref": "#/definitions/RelationshipIdString" - } - }, - "required": [ - "relationshipId" - ], - "additionalProperties": false - }, - "RelationshipIdString": { - "type": "string", - "pattern": "REL[A-Za-z0-9]{17}" - } - } -} - -export const RequestRelationshipReactivationRequest: any = { - "$schema": "http://json-schema.org/draft-07/schema#", - "$ref": "#/definitions/RequestRelationshipReactivationRequest", - "definitions": { - "RequestRelationshipReactivationRequest": { - "type": "object", - "properties": { - "relationshipId": { - "$ref": "#/definitions/RelationshipIdString" - } - }, - "required": [ - "relationshipId" - ], - "additionalProperties": false - }, - "RelationshipIdString": { - "type": "string", - "pattern": "REL[A-Za-z0-9]{17}" - } - } -} - -export const RevokeRelationshipRequest: any = { - "$schema": "http://json-schema.org/draft-07/schema#", - "$ref": "#/definitions/RevokeRelationshipRequest", - "definitions": { - "RevokeRelationshipRequest": { - "type": "object", - "properties": { - "relationshipId": { - "$ref": "#/definitions/RelationshipIdString" - } - }, - "required": [ - "relationshipId" - ], - "additionalProperties": false }, - "RelationshipIdString": { + "OwnerRestriction": { "type": "string", - "pattern": "REL[A-Za-z0-9]{17}" + "enum": [ + "o", + "p" + ] } } } -export const RevokeRelationshipReactivationRequest: any = { +export const LoadPeerRelationshipTemplateRequest: any = { "$schema": "http://json-schema.org/draft-07/schema#", - "$ref": "#/definitions/RevokeRelationshipReactivationRequest", + "$ref": "#/definitions/LoadPeerRelationshipTemplateRequest", "definitions": { - "RevokeRelationshipReactivationRequest": { + "LoadPeerRelationshipTemplateRequest": { "type": "object", "properties": { - "relationshipId": { - "$ref": "#/definitions/RelationshipIdString" + "reference": { + "anyOf": [ + { + "$ref": "#/definitions/TokenReferenceString" + }, + { + "$ref": "#/definitions/RelationshipTemplateReferenceString" + } + ] + }, + "password": { + "type": "string" } }, "required": [ - "relationshipId" + "reference" ], - "additionalProperties": false + "additionalProperties": false, + "errorMessage": "token / relationship template reference invalid" }, - "RelationshipIdString": { + "TokenReferenceString": { "type": "string", - "pattern": "REL[A-Za-z0-9]{17}" - } - } -} - -export const TerminateRelationshipRequest: any = { - "$schema": "http://json-schema.org/draft-07/schema#", - "$ref": "#/definitions/TerminateRelationshipRequest", - "definitions": { - "TerminateRelationshipRequest": { - "type": "object", - "properties": { - "relationshipId": { - "$ref": "#/definitions/RelationshipIdString" - } - }, - "required": [ - "relationshipId" - ], - "additionalProperties": false + "pattern": "VE9L.{84}" }, - "RelationshipIdString": { + "RelationshipTemplateReferenceString": { "type": "string", - "pattern": "REL[A-Za-z0-9]{17}" + "pattern": "UkxU.{84}" } } } diff --git a/packages/runtime/src/useCases/transport/index.ts b/packages/runtime/src/useCases/transport/index.ts index 0bce894c3..51425e014 100644 --- a/packages/runtime/src/useCases/transport/index.ts +++ b/packages/runtime/src/useCases/transport/index.ts @@ -4,6 +4,7 @@ export * from "./devices"; export * from "./files"; export * from "./identityDeletionProcesses"; export * from "./messages"; -export * from "./relationshipTemplates"; +export * from "./publicRelationshipTemplateReferences"; export * from "./relationships"; +export * from "./relationshipTemplates"; export * from "./tokens"; diff --git a/packages/runtime/src/useCases/transport/publicRelationshipTemplateReferences/GetPublicRelationshipTemplateReferences.ts b/packages/runtime/src/useCases/transport/publicRelationshipTemplateReferences/GetPublicRelationshipTemplateReferences.ts new file mode 100644 index 000000000..5699b8d5f --- /dev/null +++ b/packages/runtime/src/useCases/transport/publicRelationshipTemplateReferences/GetPublicRelationshipTemplateReferences.ts @@ -0,0 +1,19 @@ +import { Result } from "@js-soft/ts-utils"; +import { PublicRelationshipTemplateReferencesController } from "@nmshd/transport"; +import { Inject } from "@nmshd/typescript-ioc"; +import { PublicRelationshipTemplateReferenceDTO } from "../../../types"; +import { UseCase } from "../../common"; +import { PublicRelationshipTemplateReferenceMapper } from "./PublicRelationshipTemplateReferenceMapper"; + +export class GetPublicRelationshipTemplateReferencesUseCase extends UseCase { + public constructor(@Inject private readonly publicRelationshipTemplateReferencesController: PublicRelationshipTemplateReferencesController) { + super(); + } + + protected async executeInternal(): Promise> { + const publicRelationshipTemplateReferences = await this.publicRelationshipTemplateReferencesController.getPublicRelationshipTemplateReferences(); + const templateReferences = PublicRelationshipTemplateReferenceMapper.toPublicRelationshipTemplateReferenceDTOList(publicRelationshipTemplateReferences); + + return Result.ok(templateReferences); + } +} diff --git a/packages/runtime/src/useCases/transport/publicRelationshipTemplateReferences/PublicRelationshipTemplateReferenceMapper.ts b/packages/runtime/src/useCases/transport/publicRelationshipTemplateReferences/PublicRelationshipTemplateReferenceMapper.ts new file mode 100644 index 000000000..4194f6306 --- /dev/null +++ b/packages/runtime/src/useCases/transport/publicRelationshipTemplateReferences/PublicRelationshipTemplateReferenceMapper.ts @@ -0,0 +1,18 @@ +import { PublicRelationshipTemplateReference } from "@nmshd/transport"; +import { PublicRelationshipTemplateReferenceDTO } from "../../../types/transport/PublicRelationshipTemplateReferenceDTO"; + +export class PublicRelationshipTemplateReferenceMapper { + public static toPublicRelationshipTemplateReferenceDTO(publicRelationshipTemplateReference: PublicRelationshipTemplateReference): PublicRelationshipTemplateReferenceDTO { + return { + title: publicRelationshipTemplateReference.title, + description: publicRelationshipTemplateReference.description, + truncatedReference: publicRelationshipTemplateReference.truncatedReference + }; + } + + public static toPublicRelationshipTemplateReferenceDTOList( + publicRelationshipTemplateReferences: PublicRelationshipTemplateReference[] + ): PublicRelationshipTemplateReferenceDTO[] { + return publicRelationshipTemplateReferences.map((reference) => this.toPublicRelationshipTemplateReferenceDTO(reference)); + } +} diff --git a/packages/runtime/src/useCases/transport/publicRelationshipTemplateReferences/index.ts b/packages/runtime/src/useCases/transport/publicRelationshipTemplateReferences/index.ts new file mode 100644 index 000000000..5aedb3201 --- /dev/null +++ b/packages/runtime/src/useCases/transport/publicRelationshipTemplateReferences/index.ts @@ -0,0 +1 @@ +export * from "./GetPublicRelationshipTemplateReferences"; diff --git a/packages/runtime/test/transport/publicRelationshipTemplateReferences.test.ts b/packages/runtime/test/transport/publicRelationshipTemplateReferences.test.ts new file mode 100644 index 000000000..b9829087c --- /dev/null +++ b/packages/runtime/test/transport/publicRelationshipTemplateReferences.test.ts @@ -0,0 +1,38 @@ +import { ClientResult, PublicRelationshipTemplateReferenceClient } from "@nmshd/transport"; +import { reset, spy, when } from "ts-mockito"; +import { RuntimeServiceProvider, TestRuntimeServices } from "../lib"; + +const serviceProvider = new RuntimeServiceProvider(); +let runtimeServices: TestRuntimeServices; +let mockClient: PublicRelationshipTemplateReferenceClient; + +beforeAll(async () => { + runtimeServices = (await serviceProvider.launch(1))[0]; + const client = runtimeServices.transport.publicRelationshipTemplateReferences["getPublicRelationshipTemplateReferencesUseCase"][ + "publicRelationshipTemplateReferencesController" + ]["client"] as PublicRelationshipTemplateReferenceClient; + + mockClient = spy(client); +}, 30000); + +afterAll(() => serviceProvider.stop()); + +afterEach(() => reset(mockClient)); + +describe("PublicRelationshipTemplateReferences", () => { + test("should read the PublicRelationshipTemplateReferences", async () => { + const mockResponse = [ + { + title: "aTitle", + description: "aDescription", + truncatedReference: "aReference" + } + ]; + + when(mockClient.getPublicRelationshipTemplateReferences()).thenResolve(ClientResult.ok(mockResponse)); + + const publicRelationshipTemplateReferences = await runtimeServices.transport.publicRelationshipTemplateReferences.getPublicRelationshipTemplateReferences(); + + expect(publicRelationshipTemplateReferences.value).toStrictEqual(mockResponse); + }); +}); diff --git a/packages/transport/src/core/TransportController.ts b/packages/transport/src/core/TransportController.ts index 05e549fa1..3743cf316 100644 --- a/packages/transport/src/core/TransportController.ts +++ b/packages/transport/src/core/TransportController.ts @@ -20,6 +20,7 @@ export enum ControllerName { File = "File", Identity = "Identity", Message = "Message", + PublicRelationshipTemplateReferences = "PublicRelationshipTemplateReferences", Relationship = "Relationship", Relationships = "Relationships", RelationshipTemplate = "RelationshipTemplate", diff --git a/packages/transport/src/modules/accounts/AccountController.ts b/packages/transport/src/modules/accounts/AccountController.ts index 6dee85801..96e761ea4 100644 --- a/packages/transport/src/modules/accounts/AccountController.ts +++ b/packages/transport/src/modules/accounts/AccountController.ts @@ -23,6 +23,7 @@ import { DeviceSecretCredentials } from "../devices/local/DeviceSecretCredential import { DeviceSharedSecret } from "../devices/transmission/DeviceSharedSecret"; import { FileController } from "../files/FileController"; import { MessageController } from "../messages/MessageController"; +import { PublicRelationshipTemplateReferencesController } from "../publicRelationshipTemplateReferences/PublicRelationshipTemplateReferencesController"; import { RelationshipTemplateController } from "../relationshipTemplates/RelationshipTemplateController"; import { RelationshipSecretController } from "../relationships/RelationshipSecretController"; import { RelationshipsController } from "../relationships/RelationshipsController"; @@ -59,6 +60,7 @@ export class AccountController { public devices: DevicesController; public files: FileController; public messages: MessageController; + public publicRelationshipTemplateReferences: PublicRelationshipTemplateReferencesController; public relationships: RelationshipsController; public relationshipTemplates: RelationshipTemplateController; private synchronization: SyncController; @@ -213,6 +215,7 @@ export class AccountController { this.relationshipTemplates = await new RelationshipTemplateController(this, this.relationshipSecrets).init(); this.messages = await new MessageController(this).init(); this.tokens = await new TokenController(this).init(); + this.publicRelationshipTemplateReferences = await new PublicRelationshipTemplateReferencesController(this).init(); this.synchronization = await new SyncController(this, this.dependencyOverrides, this.unpushedDatawalletModifications, this.config.datawalletEnabled).init(); diff --git a/packages/transport/src/modules/index.ts b/packages/transport/src/modules/index.ts index cefb1bab6..5d7c511a6 100644 --- a/packages/transport/src/modules/index.ts +++ b/packages/transport/src/modules/index.ts @@ -65,6 +65,9 @@ export * from "./messages/transmission/MessageEnvelope"; export * from "./messages/transmission/MessageEnvelopeRecipient"; export * from "./messages/transmission/MessageSignature"; export * from "./messages/transmission/MessageSigned"; +export * from "./publicRelationshipTemplateReferences/backbone/PublicRelationshipTemplateReferenceClient"; +export * from "./publicRelationshipTemplateReferences/data/PublicRelationshipTemplateReference"; +export * from "./publicRelationshipTemplateReferences/PublicRelationshipTemplateReferencesController"; export * from "./relationships/backbone/BackboneGetRelationships"; export * from "./relationships/backbone/BackbonePostRelationship"; export * from "./relationships/backbone/RelationshipClient"; diff --git a/packages/transport/src/modules/publicRelationshipTemplateReferences/PublicRelationshipTemplateReferencesController.ts b/packages/transport/src/modules/publicRelationshipTemplateReferences/PublicRelationshipTemplateReferencesController.ts new file mode 100644 index 000000000..635c15e36 --- /dev/null +++ b/packages/transport/src/modules/publicRelationshipTemplateReferences/PublicRelationshipTemplateReferencesController.ts @@ -0,0 +1,34 @@ +import { RequestError } from "../../core/backbone/RequestError"; +import { ControllerName, TransportController } from "../../core/TransportController"; +import { AccountController } from "../accounts/AccountController"; +import { PublicRelationshipTemplateReferenceClient } from "./backbone/PublicRelationshipTemplateReferenceClient"; +import { PublicRelationshipTemplateReference } from "./data/PublicRelationshipTemplateReference"; + +export class PublicRelationshipTemplateReferencesController extends TransportController { + public constructor(parent: AccountController) { + super(ControllerName.PublicRelationshipTemplateReferences, parent); + } + + private client: PublicRelationshipTemplateReferenceClient; + + public override async init(): Promise { + await super.init(); + + this.client = new PublicRelationshipTemplateReferenceClient(this.config, this.parent.authenticator, this.transport.correlator); + + return this; + } + + public async getPublicRelationshipTemplateReferences(): Promise { + try { + const result = await this.client.getPublicRelationshipTemplateReferences(); + + const references = result.value.map((reference) => PublicRelationshipTemplateReference.fromAny(reference)); + return references; + } catch (e) { + if (e instanceof RequestError && e.status === 404) return []; + + throw e; + } + } +} diff --git a/packages/transport/src/modules/publicRelationshipTemplateReferences/backbone/PublicRelationshipTemplateReferenceClient.ts b/packages/transport/src/modules/publicRelationshipTemplateReferences/backbone/PublicRelationshipTemplateReferenceClient.ts new file mode 100644 index 000000000..d048413cb --- /dev/null +++ b/packages/transport/src/modules/publicRelationshipTemplateReferences/backbone/PublicRelationshipTemplateReferenceClient.ts @@ -0,0 +1,14 @@ +import { ClientResult } from "../../../core/backbone/ClientResult"; +import { RESTClientAuthenticate } from "../../../core/backbone/RESTClientAuthenticate"; + +export interface BackbonePublicRelationshipTemplateReference { + title: string; + description: string; + truncatedReference: string; +} + +export class PublicRelationshipTemplateReferenceClient extends RESTClientAuthenticate { + public async getPublicRelationshipTemplateReferences(): Promise> { + return await this.get("/api/poc/PublicRelationshipTemplateReferences"); + } +} diff --git a/packages/transport/src/modules/publicRelationshipTemplateReferences/data/PublicRelationshipTemplateReference.ts b/packages/transport/src/modules/publicRelationshipTemplateReferences/data/PublicRelationshipTemplateReference.ts new file mode 100644 index 000000000..10c53b4be --- /dev/null +++ b/packages/transport/src/modules/publicRelationshipTemplateReferences/data/PublicRelationshipTemplateReference.ts @@ -0,0 +1,15 @@ +import { Serializable, serialize, validate } from "@js-soft/ts-serval"; + +export class PublicRelationshipTemplateReference extends Serializable { + @serialize() + @validate() + public title: string; + + @serialize() + @validate() + public description: string; + + @serialize() + @validate() + public truncatedReference: string; +} diff --git a/packages/transport/test/modules/publicRelationshipTemplateReferences/PublicRelationshipTemplateReferencesController.test.ts b/packages/transport/test/modules/publicRelationshipTemplateReferences/PublicRelationshipTemplateReferencesController.test.ts new file mode 100644 index 000000000..536a8e1c9 --- /dev/null +++ b/packages/transport/test/modules/publicRelationshipTemplateReferences/PublicRelationshipTemplateReferencesController.test.ts @@ -0,0 +1,61 @@ +import { IDatabaseConnection } from "@js-soft/docdb-access-abstractions"; +import { reset, spy, when } from "ts-mockito"; +import { AccountController, ClientResult, PublicRelationshipTemplateReferenceClient, RequestError, Transport } from "../../../src"; +import { TestUtil } from "../../testHelpers/TestUtil"; + +let connection: IDatabaseConnection; + +let transport: Transport; +let account: AccountController; +let mockedClient: PublicRelationshipTemplateReferenceClient; + +beforeAll(async function () { + connection = await TestUtil.createDatabaseConnection(); + transport = TestUtil.createTransport(connection); + + await transport.init(); + + const accounts = await TestUtil.provideAccounts(transport, 1); + + account = accounts[0]; + + const client = account.publicRelationshipTemplateReferences["client"]; + mockedClient = spy(client); +}); + +afterAll(async () => { + await account.close(); + + await connection.close(); +}); + +afterEach(() => reset(mockedClient)); + +describe("PublicRelationshipTemplateReferencesController", () => { + test("should return the backbone defined PublicRelationshipTemplateReferences", async () => { + const mockResponse = [{ title: "aTitle", description: "aDescription", truncatedReference: "aReference" }]; + when(mockedClient.getPublicRelationshipTemplateReferences()).thenResolve(ClientResult.ok(mockResponse)); + + const publicRelationshipTemplates = await account.publicRelationshipTemplateReferences.getPublicRelationshipTemplateReferences(); + + expect(publicRelationshipTemplates.map((reference) => reference.toJSON())).toStrictEqual(mockResponse); + }); + + test("should return an empty array if the backbone endpoint returns an empty array", async () => { + when(mockedClient.getPublicRelationshipTemplateReferences()).thenResolve(ClientResult.ok([])); + + const publicRelationshipTemplates = await account.publicRelationshipTemplateReferences.getPublicRelationshipTemplateReferences(); + + expect(publicRelationshipTemplates).toStrictEqual([]); + }); + + test("should return an empty array if the backbone endpoint is not available", async () => { + when(mockedClient.getPublicRelationshipTemplateReferences()).thenResolve( + ClientResult.fail(new RequestError("some method", "some path", undefined, undefined, undefined, undefined, 404)) + ); + + const publicRelationshipTemplates = await account.publicRelationshipTemplateReferences.getPublicRelationshipTemplateReferences(); + + expect(publicRelationshipTemplates).toHaveLength(0); + }); +}); From 2703c38a915082184c6730ac66c1a9e47bf06f74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julian=20K=C3=B6nig?= <33655937+jkoenig134@users.noreply.github.com> Date: Fri, 29 Nov 2024 10:53:12 +0100 Subject: [PATCH 11/21] Configure GitHub repo using settings file (#349) --- .github/settings.yaml | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 .github/settings.yaml diff --git a/.github/settings.yaml b/.github/settings.yaml new file mode 100644 index 000000000..650601a8b --- /dev/null +++ b/.github/settings.yaml @@ -0,0 +1,42 @@ +--- +# https://github.com/repository-settings/app + +repository: + allow_squash_merge: true + allow_merge_commit: false + allow_rebase_merge: false + allow_auto_merge: true + allow_update_branch: true + delete_branch_on_merge: true + +labels: + - name: breaking-change + color: "#16060F" + description: A breaking change + - name: bug + color: "#d73a4a" + description: Something isn't working + - name: chore + color: "#c2e0c6" + description: Some routine work like updating dependencies + - name: ci + color: "#DFB5FD" + description: Continuous Integration related stuff + - name: dependencies + color: "#0366d6" + description: Pull requests that update dependencies + - name: documentation + color: "#0075ca" + description: Improvements or additions to documentation + - name: enhancement + color: "#a2eeef" + description: New feature or request + - name: refactoring + color: "#880361" + description: Refactoring of code + - name: test + color: "#20D89D" + description: This pull request contains only new or changed tests + - name: wip + color: "#32BF4C" + description: Work in Progress (blocks mergify from auto update the branch) From 1d72478f2038cf9161ab24995bbc294244a943f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julian=20K=C3=B6nig?= <33655937+jkoenig134@users.noreply.github.com> Date: Fri, 29 Nov 2024 12:55:35 +0100 Subject: [PATCH 12/21] Change settings file ending (#350) * chore: rm yaml * chore: add yml --- .github/{settings.yaml => settings.yml} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .github/{settings.yaml => settings.yml} (100%) diff --git a/.github/settings.yaml b/.github/settings.yml similarity index 100% rename from .github/settings.yaml rename to .github/settings.yml From 3710be4de2fd342c22cd253e828ae43b98cef40e Mon Sep 17 00:00:00 2001 From: Sebastian Mahr Date: Fri, 29 Nov 2024 13:19:07 +0100 Subject: [PATCH 13/21] test: nagtive test of Adress validation fix (#351) Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com> --- packages/transport/test/utils/IdentityGenerator.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/transport/test/utils/IdentityGenerator.test.ts b/packages/transport/test/utils/IdentityGenerator.test.ts index d9e1536ca..c8d8626db 100644 --- a/packages/transport/test/utils/IdentityGenerator.test.ts +++ b/packages/transport/test/utils/IdentityGenerator.test.ts @@ -120,7 +120,7 @@ describe("IdentityGeneratorTest", function () { test("should negatively check an incorrect address object (wrong checksum)", async function () { const address = await IdentityUtil.createAddress(kp.publicKey, "example.com"); - const index = 5; + const index = 32; let replaceWith = "b"; const currentString = address.address.substr(index, replaceWith.length); if (currentString === replaceWith) { From 61a888294f9c5bff16333ee883f616d07b440c06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julian=20K=C3=B6nig?= <33655937+jkoenig134@users.noreply.github.com> Date: Fri, 29 Nov 2024 14:03:00 +0100 Subject: [PATCH 14/21] Bring `getAccountsInDeletion` and `getAccountsNotInDeletion` to `AccountServices` (#352) * feat: add getAccountsInDeletion and getAccountsNotInDeletion to AccountServices * refactor: simplify getAccounts* methods * chore: test another method --------- Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com> --- .../src/multiAccount/AccountServices.ts | 10 ++++++++++ .../src/multiAccount/MultiAccountController.ts | 16 ++++++++-------- .../multiAccount/MultiAccountController.test.ts | 10 ++++++++++ 3 files changed, 28 insertions(+), 8 deletions(-) diff --git a/packages/app-runtime/src/multiAccount/AccountServices.ts b/packages/app-runtime/src/multiAccount/AccountServices.ts index c292161ab..d71cf384c 100644 --- a/packages/app-runtime/src/multiAccount/AccountServices.ts +++ b/packages/app-runtime/src/multiAccount/AccountServices.ts @@ -23,6 +23,16 @@ export class AccountServices { return localAccounts.map((account) => LocalAccountMapper.toLocalAccountDTO(account)); } + public async getAccountsInDeletion(): Promise { + const localAccounts = await this.multiAccountController.getAccountsInDeletion(); + return localAccounts.map((account) => LocalAccountMapper.toLocalAccountDTO(account)); + } + + public async getAccountsNotInDeletion(): Promise { + const localAccounts = await this.multiAccountController.getAccountsNotInDeletion(); + return localAccounts.map((account) => LocalAccountMapper.toLocalAccountDTO(account)); + } + public async getAccount(id: string): Promise { const localAccount = await this.multiAccountController.getAccount(CoreId.from(id)); return LocalAccountMapper.toLocalAccountDTO(localAccount); diff --git a/packages/app-runtime/src/multiAccount/MultiAccountController.ts b/packages/app-runtime/src/multiAccount/MultiAccountController.ts index 252590aa1..08ce2db46 100644 --- a/packages/app-runtime/src/multiAccount/MultiAccountController.ts +++ b/packages/app-runtime/src/multiAccount/MultiAccountController.ts @@ -78,20 +78,20 @@ export class MultiAccountController { } public async getAccounts(): Promise { - const dbAccounts = await this._localAccounts.list(); - return dbAccounts.map((account) => LocalAccount.from(account)); + return await this._findAccounts(); } public async getAccountsInDeletion(): Promise { - const allAccounts = await this.getAccounts(); - const accountsInDeletion = allAccounts.filter((item) => item.deletionDate !== undefined); - return accountsInDeletion; + return await this._findAccounts({ deletionDate: { $exists: true } }); } public async getAccountsNotInDeletion(): Promise { - const allAccounts = await this.getAccounts(); - const accountsNotInDeletion = allAccounts.filter((item) => item.deletionDate === undefined); - return accountsNotInDeletion; + return await this._findAccounts({ deletionDate: { $exists: false } }); + } + + private async _findAccounts(query?: any) { + const accounts = await this._localAccounts.find(query); + return accounts.map((account) => LocalAccount.from(account)); } public async selectAccount(id: CoreId): Promise<[LocalAccount, AccountController]> { diff --git a/packages/app-runtime/test/multiAccount/MultiAccountController.test.ts b/packages/app-runtime/test/multiAccount/MultiAccountController.test.ts index 22863881b..3cbf0a8fc 100644 --- a/packages/app-runtime/test/multiAccount/MultiAccountController.test.ts +++ b/packages/app-runtime/test/multiAccount/MultiAccountController.test.ts @@ -41,6 +41,16 @@ describe("MultiAccountController", function () { afterAll(async () => await runtime.stop()); + test("should get all accounts", async function () { + const accounts = await runtime.multiAccountController.getAccounts(); + expect(accounts).toHaveLength(3); + + const addresses = accounts.map((account) => account.address!.toString()); + expect(addresses).toContain(account1.address); + expect(addresses).toContain(account2.address); + expect(addresses).toContain(account3.address); + }); + test("should get all accounts in deletion", async function () { await session1.transportServices.identityDeletionProcesses.initiateIdentityDeletionProcess(); await session2.transportServices.identityDeletionProcesses.initiateIdentityDeletionProcess(); From 2241e42f87b85a5df82f566d108b81347f93048f Mon Sep 17 00:00:00 2001 From: Magnus Kuhn <127854942+Magnus-Kuhn@users.noreply.github.com> Date: Fri, 29 Nov 2024 14:23:10 +0100 Subject: [PATCH 15/21] Password-protected Tokens (#300) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add password protection to templates * test: add tests * fix/test: add tests, make fixes * fix: backbone API * feat: change error message * refactor: nameof, toString * feat/test: error message, add validation test * fix: remove only * chore: bump backbone Co-authored-by: Julian König <33655937+jkoenig134@users.noreply.github.com> * fix: missing password pass * refactor: wrong variable name * refactor: review comments * chore: upgrade backbone and adapt client * feat: add password-protection to tokens * test: add anonymous tests * fix: pass password * fix: error in test * chore: build schemas * feat: hash passwords * feat: add separate pin * feat: enhance password type * refactor: align error messages * fix: more enhancing password type * test: adapt tests * test: reference adaptations * wip * refactor/feat: review comments * feat: add transport empty string validation * test: add tests * fix: schemas, error codes * fix: add PINs when loading * feat: add loading validation, tests * test: add validations * fix: test errors * fix/feat: add loading validation, fix tests * test: fix tests * refactor: no PIN validation in loading schema * test: fix copy-paste error * refactor: use schemas for empty string validation * feat: use salt * refactor/test: reference adaptations * feat: adapt automatic version setting * fix: version in reference * feat: remove salt from dto * test: refactor tests * feat: add/adapt salt validation * refactor/test: validations * test: fix ids * chore: bump backbone * feat: remove version * test: fix salt test * chore: transport PR comments * chore: runtime PR comments * fix/refactor: more stuff * test: correct check * test: fix used function * feat: add transport setting validation * refactor: import * chore: build schemas * refactor: passwordinfo * fix: cleanup * test: cleanup * refactor/fix: use password info derivatives * refactor: remove unused error * test: fix error names in tests * feat: password error message * refactor: test names and content, class usage * feat: runtime interface with flag * test: adapt tests * chore: schemaas * fix: mapping, tests * refactor: simplify object access * refactor: rename passwordProtection * refactor: naming * chore: move business logic to object * refactor: use min * fix: tests * feat: passwordIsPin true or undefined * chore: build schemas * chore: remove unused method * test: fix tests * fix: ability to truncate * refactor: add fromTruncted * feat: adapt tokens to templates * feat: error message mentions wrong password * fix/feat: add missing validations, type cleanup * test: adapt token controller tests * test: adapt runtime tests * chore: schemas * fix: tests, loading token * fix: tests, inheritance * refactor: move expiresAt to schema validator * refactor: move passwordProtection into schema validator * fix: use lodash * refactor: line breaks, comments, lodash * fix: validator * test: protect token * test: remove redundant tests * test: add reference test * chore: build schemas * test: fix error messages * test: fix error code * test: fix error messages * chore: bump backbone * fix: missing password * refactor: namings, restructurings * refactor: generic input validator with password only * refactor: separate test files * test: test names, some fixes * refactor: tokenAndTemplateCreationValidator * refactor: improve tokenAndTemplateCreationValidator * chore: build schemas * refactor: invalidPropertyValue * test: adapt tests --------- Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com> Co-authored-by: Julian König <33655937+jkoenig134@users.noreply.github.com> Co-authored-by: Julian König --- .dev/compose.backbone.env | 2 +- .../runtime/src/types/transport/TokenDTO.ts | 4 + .../tokens/LoadPeerTokenAnonymous.ts | 3 +- .../src/useCases/common/RuntimeErrors.ts | 11 +- .../runtime/src/useCases/common/Schemas.ts | 97 +++++ packages/runtime/src/useCases/common/index.ts | 1 + .../TokenAndTemplateCreationValidator.ts | 48 ++ .../account/LoadItemFromTruncatedReference.ts | 11 +- .../transport/files/CreateTokenForFile.ts | 16 +- .../files/CreateTokenQRCodeForFile.ts | 16 +- .../useCases/transport/files/GetOrLoadFile.ts | 11 +- .../CreateOwnRelationshipTemplate.ts | 28 +- .../CreateTokenForOwnRelationshipTemplate.ts | 19 +- ...teTokenQRCodeForOwnRelationshipTemplate.ts | 36 +- .../LoadPeerRelationshipTemplate.ts | 2 +- .../transport/tokens/CreateOwnToken.ts | 25 +- .../transport/tokens/LoadPeerToken.ts | 3 +- .../useCases/transport/tokens/TokenMapper.ts | 8 +- .../runtime/test/anonymous/tokens.test.ts | 33 ++ packages/runtime/test/lib/testUtils.ts | 21 +- .../passwordProtection/files.test.ts | 120 +++++ .../relationshipTemplates.test.ts | 410 ++++++++++++++++++ .../passwordProtection/tokens.test.ts | 108 +++++ .../transport/relationshipTemplates.test.ts | 187 -------- .../src/core/types/PasswordProtection.ts | 12 + .../tokens/AnonymousTokenController.ts | 23 +- .../src/modules/tokens/TokenController.ts | 106 ++++- .../tokens/backbone/AnonymousTokenClient.ts | 5 +- .../tokens/backbone/BackboneGetTokens.ts | 2 +- .../tokens/backbone/BackbonePostTokens.ts | 1 + .../modules/tokens/backbone/TokenClient.ts | 5 +- .../tokens/local/SendTokenParameters.ts | 6 + .../src/modules/tokens/local/Token.ts | 15 +- .../tokens/AnonymousTokenController.test.ts | 39 ++ .../modules/tokens/TokenController.test.ts | 75 ++++ 35 files changed, 1219 insertions(+), 290 deletions(-) create mode 100644 packages/runtime/src/useCases/common/validation/TokenAndTemplateCreationValidator.ts create mode 100644 packages/runtime/test/transport/passwordProtection/files.test.ts create mode 100644 packages/runtime/test/transport/passwordProtection/relationshipTemplates.test.ts create mode 100644 packages/runtime/test/transport/passwordProtection/tokens.test.ts diff --git a/.dev/compose.backbone.env b/.dev/compose.backbone.env index a3a499d00..3855dea43 100644 --- a/.dev/compose.backbone.env +++ b/.dev/compose.backbone.env @@ -1 +1 @@ -BACKBONE_VERSION=6.19.1 +BACKBONE_VERSION=6.20.0 diff --git a/packages/runtime/src/types/transport/TokenDTO.ts b/packages/runtime/src/types/transport/TokenDTO.ts index 953b7e570..b2a68b468 100644 --- a/packages/runtime/src/types/transport/TokenDTO.ts +++ b/packages/runtime/src/types/transport/TokenDTO.ts @@ -6,6 +6,10 @@ export interface TokenDTO { createdAt: string; expiresAt: string; forIdentity?: string; + passwordProtection?: { + password: string; + passwordIsPin?: true; + }; truncatedReference: string; isEphemeral: boolean; } diff --git a/packages/runtime/src/useCases/anonymous/tokens/LoadPeerTokenAnonymous.ts b/packages/runtime/src/useCases/anonymous/tokens/LoadPeerTokenAnonymous.ts index 82d4b9990..84a881d02 100644 --- a/packages/runtime/src/useCases/anonymous/tokens/LoadPeerTokenAnonymous.ts +++ b/packages/runtime/src/useCases/anonymous/tokens/LoadPeerTokenAnonymous.ts @@ -7,6 +7,7 @@ import { TokenMapper } from "../../transport/tokens/TokenMapper"; export interface LoadPeerTokenAnonymousRequest { reference: TokenReferenceString; + password?: string; } class Validator extends SchemaValidator { @@ -24,7 +25,7 @@ export class LoadPeerTokenAnonymousUseCase extends UseCase> { - const createdToken = await this.anonymousTokenController.loadPeerTokenByTruncated(request.reference); + const createdToken = await this.anonymousTokenController.loadPeerTokenByTruncated(request.reference, request.password); return Result.ok(TokenMapper.toTokenDTO(createdToken, true)); } } diff --git a/packages/runtime/src/useCases/common/RuntimeErrors.ts b/packages/runtime/src/useCases/common/RuntimeErrors.ts index 7fc75add3..08e0438f0 100644 --- a/packages/runtime/src/useCases/common/RuntimeErrors.ts +++ b/packages/runtime/src/useCases/common/RuntimeErrors.ts @@ -51,10 +51,6 @@ class General { public cacheEmpty(entityName: string | Function, id: string) { return new ApplicationError("error.runtime.cacheEmpty", `The cache of ${entityName instanceof Function ? entityName.name : entityName} with id '${id}' is empty.`); } - - public invalidPin(): ApplicationError { - return new ApplicationError("error.runtime.validation.invalidPin", "The PIN is invalid. It must consist of 4 to 16 digits from 0 to 9."); - } } class Serval { @@ -88,6 +84,13 @@ class RelationshipTemplates { ); } + public passwordProtectionMustBeInherited(): ApplicationError { + return new ApplicationError( + "error.runtime.relationshipTemplates.passwordProtectionMustBeInherited", + "If a RelationshipTemplate has password protection, Tokens created from it must have the same password protection." + ); + } + public cannotCreateTokenForPeerTemplate(): ApplicationError { return new ApplicationError("error.runtime.relationshipTemplates.cannotCreateTokenForPeerTemplate", "You cannot create a Token for a peer RelationshipTemplate."); } diff --git a/packages/runtime/src/useCases/common/Schemas.ts b/packages/runtime/src/useCases/common/Schemas.ts index 10416a7a8..7dec71699 100644 --- a/packages/runtime/src/useCases/common/Schemas.ts +++ b/packages/runtime/src/useCases/common/Schemas.ts @@ -7,6 +7,9 @@ export const LoadPeerTokenAnonymousRequest: any = { "properties": { "reference": { "$ref": "#/definitions/TokenReferenceString" + }, + "password": { + "type": "string" } }, "required": [ @@ -20553,6 +20556,9 @@ export const LoadItemFromTruncatedReferenceRequest: any = { "$ref": "#/definitions/RelationshipTemplateReferenceString" } ] + }, + "password": { + "type": "string" } }, "required": [ @@ -21295,6 +21301,23 @@ export const CreateTokenForFileRequest: any = { }, "forIdentity": { "$ref": "#/definitions/AddressString" + }, + "passwordProtection": { + "type": "object", + "properties": { + "password": { + "type": "string", + "minLength": 1 + }, + "passwordIsPin": { + "type": "boolean", + "const": true + } + }, + "required": [ + "password" + ], + "additionalProperties": false } }, "required": [ @@ -21333,6 +21356,23 @@ export const CreateTokenQRCodeForFileRequest: any = { }, "forIdentity": { "$ref": "#/definitions/AddressString" + }, + "passwordProtection": { + "type": "object", + "properties": { + "password": { + "type": "string", + "minLength": 1 + }, + "passwordIsPin": { + "type": "boolean", + "const": true + } + }, + "required": [ + "password" + ], + "additionalProperties": false } }, "required": [ @@ -21557,6 +21597,9 @@ export const GetOrLoadFileRequest: any = { "$ref": "#/definitions/FileReferenceString" } ] + }, + "password": { + "type": "string" } }, "required": [ @@ -22486,6 +22529,23 @@ export const CreateTokenForOwnTemplateRequest: any = { }, "forIdentity": { "$ref": "#/definitions/AddressString" + }, + "passwordProtection": { + "type": "object", + "properties": { + "password": { + "type": "string", + "minLength": 1 + }, + "passwordIsPin": { + "type": "boolean", + "const": true + } + }, + "required": [ + "password" + ], + "additionalProperties": false } }, "required": [ @@ -22524,6 +22584,23 @@ export const CreateTokenQRCodeForOwnTemplateRequest: any = { }, "forIdentity": { "$ref": "#/definitions/AddressString" + }, + "passwordProtection": { + "type": "object", + "properties": { + "password": { + "type": "string", + "minLength": 1 + }, + "passwordIsPin": { + "type": "boolean", + "const": true + } + }, + "required": [ + "password" + ], + "additionalProperties": false } }, "required": [ @@ -22747,6 +22824,23 @@ export const CreateOwnTokenRequest: any = { }, "forIdentity": { "$ref": "#/definitions/AddressString" + }, + "passwordProtection": { + "type": "object", + "properties": { + "password": { + "type": "string", + "minLength": 1 + }, + "passwordIsPin": { + "type": "boolean", + "const": true + } + }, + "required": [ + "password" + ], + "additionalProperties": false } }, "required": [ @@ -22923,6 +23017,9 @@ export const LoadPeerTokenRequest: any = { }, "ephemeral": { "type": "boolean" + }, + "password": { + "type": "string" } }, "required": [ diff --git a/packages/runtime/src/useCases/common/index.ts b/packages/runtime/src/useCases/common/index.ts index cc3b1d7d0..7f1c80950 100644 --- a/packages/runtime/src/useCases/common/index.ts +++ b/packages/runtime/src/useCases/common/index.ts @@ -6,6 +6,7 @@ export * from "./RuntimeErrors"; export * from "./SchemaRepository"; export * from "./UseCase"; export * from "./validation/SchemaValidator"; +export * from "./validation/TokenAndTemplateCreationValidator"; export * from "./validation/ValidatableStrings"; export * from "./validation/ValidationFailure"; export * from "./validation/ValidationResult"; diff --git a/packages/runtime/src/useCases/common/validation/TokenAndTemplateCreationValidator.ts b/packages/runtime/src/useCases/common/validation/TokenAndTemplateCreationValidator.ts new file mode 100644 index 000000000..008132ab1 --- /dev/null +++ b/packages/runtime/src/useCases/common/validation/TokenAndTemplateCreationValidator.ts @@ -0,0 +1,48 @@ +import { CoreDate } from "@nmshd/core-types"; +import { RuntimeErrors } from "../RuntimeErrors"; +import { JsonSchema } from "../SchemaRepository"; +import { SchemaValidator } from "./SchemaValidator"; +import { ISO8601DateTimeString } from "./ValidatableStrings"; +import { ValidationFailure } from "./ValidationFailure"; +import { ValidationResult } from "./ValidationResult"; + +export class TokenAndTemplateCreationValidator< + T extends { + expiresAt?: ISO8601DateTimeString; + passwordProtection?: { + password: string; + passwordIsPin?: true; + }; + } +> extends SchemaValidator { + public constructor(protected override readonly schema: JsonSchema) { + super(schema); + } + + public override validate(input: T): ValidationResult { + const validationResult = super.validate(input); + + if (input.expiresAt && CoreDate.from(input.expiresAt).isExpired()) { + validationResult.addFailure(new ValidationFailure(RuntimeErrors.general.invalidPropertyValue(`'expiresAt' must be in the future`), "expiresAt")); + } + + if (input.passwordProtection) { + const passwordProtection = input.passwordProtection; + + if (passwordProtection.passwordIsPin) { + if (!/^[0-9]{4,16}$/.test(passwordProtection.password)) { + validationResult.addFailure( + new ValidationFailure( + RuntimeErrors.general.invalidPropertyValue( + `'passwordProtection.passwordIsPin' is true, hence 'passwordProtection.password' must consist of 4 to 16 digits from 0 to 9.` + ), + "passwordProtection" + ) + ); + } + } + } + + return validationResult; + } +} diff --git a/packages/runtime/src/useCases/transport/account/LoadItemFromTruncatedReference.ts b/packages/runtime/src/useCases/transport/account/LoadItemFromTruncatedReference.ts index 87b9ef427..1ec7f67d9 100644 --- a/packages/runtime/src/useCases/transport/account/LoadItemFromTruncatedReference.ts +++ b/packages/runtime/src/useCases/transport/account/LoadItemFromTruncatedReference.ts @@ -28,6 +28,7 @@ import { TokenMapper } from "../tokens/TokenMapper"; export interface LoadItemFromTruncatedReferenceRequest { reference: TokenReferenceString | FileReferenceString | RelationshipTemplateReferenceString; + password?: string; } class Validator extends SchemaValidator { @@ -65,7 +66,7 @@ export class LoadItemFromTruncatedReferenceUseCase extends UseCase> { - const token = await this.tokenController.loadPeerTokenByTruncated(tokenReference, true); + private async handleTokenReference(tokenReference: string, password?: string): Promise> { + const token = await this.tokenController.loadPeerTokenByTruncated(tokenReference, true, password); if (!token.cache) { throw RuntimeErrors.general.cacheEmpty(Token, token.id.toString()); @@ -93,7 +94,7 @@ export class LoadItemFromTruncatedReferenceUseCase extends UseCase { +class Validator extends TokenAndTemplateCreationValidator { public constructor(@Inject schemaRepository: SchemaRepository) { super(schemaRepository.getSchema("CreateTokenForFileRequest")); } @@ -48,7 +55,8 @@ export class CreateTokenForFileUseCase extends UseCase { +class Validator extends TokenAndTemplateCreationValidator { public constructor(@Inject schemaRepository: SchemaRepository) { super(schemaRepository.getSchema("CreateTokenQRCodeForFileRequest")); } @@ -47,7 +54,8 @@ export class CreateTokenQRCodeForFileUseCase extends UseCase { @@ -31,20 +32,20 @@ export class GetOrLoadFileUseCase extends UseCase } protected async executeInternal(request: GetOrLoadFileRequest): Promise> { - const result = await this.loadFileFromReference(request.reference); + const result = await this.loadFileFromReference(request.reference, request.password); await this.accountController.syncDatawallet(); return result; } - private async loadFileFromReference(reference: string): Promise> { + private async loadFileFromReference(reference: string, password?: string): Promise> { if (reference.startsWith(Base64ForIdPrefix.File)) { return await this.loadFileFromFileReference(reference); } if (reference.startsWith(Base64ForIdPrefix.Token)) { - return await this.loadFileFromTokenReference(reference); + return await this.loadFileFromTokenReference(reference, password); } throw RuntimeErrors.files.invalidReference(reference); @@ -55,8 +56,8 @@ export class GetOrLoadFileUseCase extends UseCase return Result.ok(FileMapper.toFileDTO(file)); } - private async loadFileFromTokenReference(truncatedReference: string): Promise> { - const token = await this.tokenController.loadPeerTokenByTruncated(truncatedReference, true); + private async loadFileFromTokenReference(truncatedReference: string, password?: string): Promise> { + const token = await this.tokenController.loadPeerTokenByTruncated(truncatedReference, true, password); if (!token.cache) { throw RuntimeErrors.general.cacheEmpty(Token, token.id.toString()); diff --git a/packages/runtime/src/useCases/transport/relationshipTemplates/CreateOwnRelationshipTemplate.ts b/packages/runtime/src/useCases/transport/relationshipTemplates/CreateOwnRelationshipTemplate.ts index 0a5d14954..712856d40 100644 --- a/packages/runtime/src/useCases/transport/relationshipTemplates/CreateOwnRelationshipTemplate.ts +++ b/packages/runtime/src/useCases/transport/relationshipTemplates/CreateOwnRelationshipTemplate.ts @@ -5,10 +5,8 @@ import { ArbitraryRelationshipTemplateContent, RelationshipTemplateContent } fro import { CoreAddress, CoreDate } from "@nmshd/core-types"; import { AccountController, PasswordProtectionCreationParameters, RelationshipTemplateController } from "@nmshd/transport"; import { Inject } from "@nmshd/typescript-ioc"; -import { DateTime } from "luxon"; -import { nameof } from "ts-simple-nameof"; import { RelationshipTemplateDTO } from "../../../types"; -import { AddressString, ISO8601DateTimeString, RuntimeErrors, SchemaRepository, SchemaValidator, UseCase, ValidationFailure, ValidationResult } from "../../common"; +import { AddressString, ISO8601DateTimeString, RuntimeErrors, SchemaRepository, TokenAndTemplateCreationValidator, UseCase } from "../../common"; import { RelationshipTemplateMapper } from "./RelationshipTemplateMapper"; export interface CreateOwnRelationshipTemplateRequest { @@ -28,32 +26,10 @@ export interface CreateOwnRelationshipTemplateRequest { }; } -class Validator extends SchemaValidator { +class Validator extends TokenAndTemplateCreationValidator { public constructor(@Inject schemaRepository: SchemaRepository) { super(schemaRepository.getSchema("CreateOwnRelationshipTemplateRequest")); } - - public override validate(input: CreateOwnRelationshipTemplateRequest): ValidationResult { - const validationResult = super.validate(input); - if (!validationResult.isValid()) return validationResult; - - if (DateTime.fromISO(input.expiresAt) <= DateTime.utc()) { - validationResult.addFailure( - new ValidationFailure( - RuntimeErrors.general.invalidPropertyValue(`'${nameof((r) => r.expiresAt)}' must be in the future`), - nameof((r) => r.expiresAt) - ) - ); - } - - if (input.passwordProtection?.passwordIsPin) { - if (!/^[0-9]{4,16}$/.test(input.passwordProtection.password)) { - validationResult.addFailure(new ValidationFailure(RuntimeErrors.general.invalidPin())); - } - } - - return validationResult; - } } export class CreateOwnRelationshipTemplateUseCase extends UseCase { diff --git a/packages/runtime/src/useCases/transport/relationshipTemplates/CreateTokenForOwnRelationshipTemplate.ts b/packages/runtime/src/useCases/transport/relationshipTemplates/CreateTokenForOwnRelationshipTemplate.ts index 1af27744c..07336d2db 100644 --- a/packages/runtime/src/useCases/transport/relationshipTemplates/CreateTokenForOwnRelationshipTemplate.ts +++ b/packages/runtime/src/useCases/transport/relationshipTemplates/CreateTokenForOwnRelationshipTemplate.ts @@ -2,6 +2,7 @@ import { Result } from "@js-soft/ts-utils"; import { CoreAddress, CoreDate, CoreId } from "@nmshd/core-types"; import { AccountController, + PasswordProtectionCreationParameters, RelationshipTemplate, RelationshipTemplateController, SharedPasswordProtection, @@ -10,7 +11,7 @@ import { } from "@nmshd/transport"; import { Inject } from "@nmshd/typescript-ioc"; import { TokenDTO } from "../../../types"; -import { AddressString, ISO8601DateTimeString, RelationshipTemplateIdString, RuntimeErrors, SchemaRepository, SchemaValidator, UseCase } from "../../common"; +import { AddressString, ISO8601DateTimeString, RelationshipTemplateIdString, RuntimeErrors, SchemaRepository, TokenAndTemplateCreationValidator, UseCase } from "../../common"; import { TokenMapper } from "../tokens/TokenMapper"; export interface CreateTokenForOwnTemplateRequest { @@ -18,9 +19,16 @@ export interface CreateTokenForOwnTemplateRequest { expiresAt?: ISO8601DateTimeString; ephemeral?: boolean; forIdentity?: AddressString; + passwordProtection?: { + /** + * @minLength 1 + */ + password: string; + passwordIsPin?: true; + }; } -class Validator extends SchemaValidator { +class Validator extends TokenAndTemplateCreationValidator { public constructor(@Inject schemaRepository: SchemaRepository) { super(schemaRepository.getSchema("CreateTokenForOwnTemplateRequest")); } @@ -51,6 +59,10 @@ export class CreateTokenForOwnTemplateUseCase extends UseCase { +class Validator extends TokenAndTemplateCreationValidator { public constructor(@Inject schemaRepository: SchemaRepository) { super(schemaRepository.getSchema("CreateTokenQRCodeForOwnTemplateRequest")); } @@ -44,6 +67,10 @@ export class CreateTokenQRCodeForOwnTemplateUseCase extends UseCase> { - const token = await this.tokenController.loadPeerTokenByTruncated(tokenReference, true); + const token = await this.tokenController.loadPeerTokenByTruncated(tokenReference, true, password); if (!token.cache) { throw RuntimeErrors.general.cacheEmpty(Token, token.id.toString()); diff --git a/packages/runtime/src/useCases/transport/tokens/CreateOwnToken.ts b/packages/runtime/src/useCases/transport/tokens/CreateOwnToken.ts index a20a15d2f..0699c356b 100644 --- a/packages/runtime/src/useCases/transport/tokens/CreateOwnToken.ts +++ b/packages/runtime/src/useCases/transport/tokens/CreateOwnToken.ts @@ -1,12 +1,21 @@ import { Serializable } from "@js-soft/ts-serval"; import { Result } from "@js-soft/ts-utils"; import { CoreAddress, CoreDate } from "@nmshd/core-types"; -import { AccountController, TokenController } from "@nmshd/transport"; +import { AccountController, PasswordProtectionCreationParameters, TokenController } from "@nmshd/transport"; import { Inject } from "@nmshd/typescript-ioc"; import { DateTime } from "luxon"; import { nameof } from "ts-simple-nameof"; import { TokenDTO } from "../../../types"; -import { AddressString, ISO8601DateTimeString, RuntimeErrors, SchemaRepository, SchemaValidator, UseCase, ValidationFailure, ValidationResult } from "../../common"; +import { + AddressString, + ISO8601DateTimeString, + RuntimeErrors, + SchemaRepository, + TokenAndTemplateCreationValidator, + UseCase, + ValidationFailure, + ValidationResult +} from "../../common"; import { TokenMapper } from "./TokenMapper"; export interface CreateOwnTokenRequest { @@ -14,9 +23,16 @@ export interface CreateOwnTokenRequest { expiresAt: ISO8601DateTimeString; ephemeral: boolean; forIdentity?: AddressString; + passwordProtection?: { + /** + * @minLength 1 + */ + password: string; + passwordIsPin?: true; + }; } -class Validator extends SchemaValidator { +class Validator extends TokenAndTemplateCreationValidator { public constructor(@Inject schemaRepository: SchemaRepository) { super(schemaRepository.getSchema("CreateOwnTokenRequest")); } @@ -59,7 +75,8 @@ export class CreateOwnTokenUseCase extends UseCase { @@ -29,7 +30,7 @@ export class LoadPeerTokenUseCase extends UseCase> { - const result = await this.tokenController.loadPeerTokenByTruncated(request.reference, request.ephemeral); + const result = await this.tokenController.loadPeerTokenByTruncated(request.reference, request.ephemeral, request.password); if (!request.ephemeral) { await this.accountController.syncDatawallet(); diff --git a/packages/runtime/src/useCases/transport/tokens/TokenMapper.ts b/packages/runtime/src/useCases/transport/tokens/TokenMapper.ts index 84b0a2b54..288be2245 100644 --- a/packages/runtime/src/useCases/transport/tokens/TokenMapper.ts +++ b/packages/runtime/src/useCases/transport/tokens/TokenMapper.ts @@ -18,7 +18,13 @@ export class TokenMapper { expiresAt: token.cache.expiresAt.toString(), truncatedReference: reference.truncate(), isEphemeral: ephemeral, - forIdentity: token.cache.forIdentity?.toString() + forIdentity: token.cache.forIdentity?.toString(), + passwordProtection: token.passwordProtection + ? { + password: token.passwordProtection.password, + passwordIsPin: token.passwordProtection.passwordType.startsWith("pin") ? true : undefined + } + : undefined }; } diff --git a/packages/runtime/test/anonymous/tokens.test.ts b/packages/runtime/test/anonymous/tokens.test.ts index 2c44d0bc9..8fcb6405f 100644 --- a/packages/runtime/test/anonymous/tokens.test.ts +++ b/packages/runtime/test/anonymous/tokens.test.ts @@ -40,4 +40,37 @@ describe("Anonymous tokens", () => { }); expect(result).toBeAnError(/.*/, "error.transport.general.notIntendedForYou"); }); + + describe("Password-protected tokens", () => { + let tokenReference: string; + + beforeAll(async () => { + tokenReference = (await uploadOwnToken(runtimeService.transport, undefined, { password: "password" })).truncatedReference; + }); + + test("send and receive a password-protected token", async () => { + const result = await noLoginRuntime.anonymousServices.tokens.loadPeerToken({ + reference: tokenReference, + password: "password" + }); + expect(result).toBeSuccessful(); + expect(result.value.passwordProtection?.password).toBe("password"); + expect(result.value.passwordProtection?.passwordIsPin).toBeUndefined(); + }); + + test("error when loading a token with a wrong password", async () => { + const result = await noLoginRuntime.anonymousServices.tokens.loadPeerToken({ + reference: tokenReference, + password: "wrong-password" + }); + expect(result).toBeAnError(/.*/, "error.runtime.recordNotFound"); + }); + + test("error when loading a token with a missing password", async () => { + const result = await noLoginRuntime.anonymousServices.tokens.loadPeerToken({ + reference: tokenReference + }); + expect(result).toBeAnError(/.*/, "error.transport.noPasswordProvided"); + }); + }); }); diff --git a/packages/runtime/test/lib/testUtils.ts b/packages/runtime/test/lib/testUtils.ts index 5207554d3..2f749d352 100644 --- a/packages/runtime/test/lib/testUtils.ts +++ b/packages/runtime/test/lib/testUtils.ts @@ -160,12 +160,17 @@ export async function syncUntilHasEvent( return event; } -export async function uploadOwnToken(transportServices: TransportServices, forIdentity?: string): Promise { +export async function uploadOwnToken( + transportServices: TransportServices, + forIdentity?: string, + passwordProtection?: { password: string; passwordIsPin?: true } +): Promise { const response = await transportServices.tokens.createOwnToken({ content: { aKey: "aValue" }, expiresAt: DateTime.utc().plus({ days: 1 }).toString(), ephemeral: false, - forIdentity + forIdentity, + passwordProtection }); expect(response).toBeSuccessful(); @@ -211,13 +216,19 @@ export const emptyRelationshipTemplateContent: ArbitraryRelationshipTemplateCont export const emptyRelationshipCreationContent: ArbitraryRelationshipCreationContentJSON = ArbitraryRelationshipCreationContent.from({ value: {} }).toJSON(); -export async function createTemplate(transportServices: TransportServices, body?: RelationshipTemplateContentJSON, templateExpiresAt?: DateTime): Promise { +export async function createTemplate( + transportServices: TransportServices, + body?: RelationshipTemplateContentJSON, + passwordProtection?: { password: string; passwordIsPin?: true }, + templateExpiresAt?: DateTime +): Promise { const defaultExpirationDateTime = DateTime.utc().plus({ minutes: 10 }).toString(); const response = await transportServices.relationshipTemplates.createOwnRelationshipTemplate({ maxNumberOfAllocations: 1, expiresAt: templateExpiresAt ? templateExpiresAt.toString() : defaultExpirationDateTime, - content: _.cloneDeep(body) ?? emptyRelationshipTemplateContent + content: _.cloneDeep(body) ?? emptyRelationshipTemplateContent, + passwordProtection }); expect(response).toBeSuccessful(); @@ -240,7 +251,7 @@ export async function exchangeTemplate( content?: RelationshipTemplateContentJSON, templateExpiresAt?: DateTime ): Promise { - const template = await createTemplate(transportServicesCreator, content, templateExpiresAt); + const template = await createTemplate(transportServicesCreator, content, undefined, templateExpiresAt); const response = await transportServicesRecipient.relationshipTemplates.loadPeerRelationshipTemplate({ reference: template.truncatedReference }); expect(response).toBeSuccessful(); diff --git a/packages/runtime/test/transport/passwordProtection/files.test.ts b/packages/runtime/test/transport/passwordProtection/files.test.ts new file mode 100644 index 000000000..0a152c735 --- /dev/null +++ b/packages/runtime/test/transport/passwordProtection/files.test.ts @@ -0,0 +1,120 @@ +import { RuntimeServiceProvider, TestRuntimeServices, uploadFile } from "../../lib"; + +const serviceProvider = new RuntimeServiceProvider(); +let runtimeServices1: TestRuntimeServices; +let runtimeServices2: TestRuntimeServices; + +beforeAll(async () => { + const runtimeServices = await serviceProvider.launch(2); + runtimeServices1 = runtimeServices[0]; + runtimeServices2 = runtimeServices[1]; +}, 30000); +afterAll(() => serviceProvider.stop()); + +describe("Password-protected tokens for files", () => { + let fileId: string; + + beforeAll(async () => { + fileId = (await uploadFile(runtimeServices1.transport)).id; + }); + + test("send and receive a file via password-protected token", async () => { + const createResult = await runtimeServices1.transport.files.createTokenForFile({ + fileId, + passwordProtection: { password: "password" } + }); + expect(createResult).toBeSuccessful(); + expect(createResult.value.passwordProtection?.password).toBe("password"); + expect(createResult.value.passwordProtection?.passwordIsPin).toBeUndefined(); + + const loadResult = await runtimeServices2.transport.files.getOrLoadFile({ reference: createResult.value.truncatedReference, password: "password" }); + expect(loadResult).toBeSuccessful(); + }); + + test("send and receive a file via PIN-protected token", async () => { + const createResult = await runtimeServices1.transport.files.createTokenForFile({ + fileId, + passwordProtection: { password: "1234", passwordIsPin: true } + }); + expect(createResult).toBeSuccessful(); + expect(createResult.value.passwordProtection?.password).toBe("1234"); + expect(createResult.value.passwordProtection?.passwordIsPin).toBe(true); + + const loadResult = await runtimeServices2.transport.files.getOrLoadFile({ reference: createResult.value.truncatedReference, password: "1234" }); + expect(loadResult).toBeSuccessful(); + }); + + test("error when loading the file with a wrong password", async () => { + const createResult = await runtimeServices1.transport.files.createTokenForFile({ + fileId, + passwordProtection: { password: "password" } + }); + expect(createResult).toBeSuccessful(); + + const loadResult = await runtimeServices2.transport.files.getOrLoadFile({ reference: createResult.value.truncatedReference, password: "wrong-password" }); + expect(loadResult).toBeAnError(/.*/, "error.runtime.recordNotFound"); + }); + + test("error when loading the file with no password", async () => { + const createResult = await runtimeServices1.transport.files.createTokenForFile({ + fileId, + passwordProtection: { password: "password" } + }); + expect(createResult).toBeSuccessful(); + + const loadResult = await runtimeServices2.transport.files.getOrLoadFile({ reference: createResult.value.truncatedReference }); + expect(loadResult).toBeAnError(/.*/, "error.transport.noPasswordProvided"); + }); + + test("validation error when creating a token with empty string as the password", async () => { + const createResult = await runtimeServices1.transport.files.createTokenForFile({ + fileId, + passwordProtection: { password: "" } + }); + expect(createResult).toBeAnError("password must NOT have fewer than 1 characters", "error.runtime.validation.invalidPropertyValue"); + }); + + test("validation error when creating a token with an invalid PIN", async () => { + const createResult = await runtimeServices1.transport.files.createTokenForFile({ + fileId, + passwordProtection: { password: "invalid-pin", passwordIsPin: true } + }); + expect(createResult).toBeAnError( + "'passwordProtection.passwordIsPin' is true, hence 'passwordProtection.password' must consist of 4 to 16 digits from 0 to 9.", + "error.runtime.validation.invalidPropertyValue" + ); + }); + + describe("LoadItemFromTruncatedReferenceUseCase", () => { + test("send and receive a file via password-protected token", async () => { + const createResult = await runtimeServices1.transport.files.createTokenForFile({ + fileId, + passwordProtection: { password: "password" } + }); + const loadResult = await runtimeServices2.transport.account.loadItemFromTruncatedReference({ reference: createResult.value.truncatedReference, password: "password" }); + expect(loadResult).toBeSuccessful(); + expect(loadResult.value.type).toBe("File"); + }); + + test("error when loading the file with a wrong password", async () => { + const createResult = await runtimeServices1.transport.files.createTokenForFile({ + fileId, + passwordProtection: { password: "password" } + }); + const loadResult = await runtimeServices2.transport.account.loadItemFromTruncatedReference({ + reference: createResult.value.truncatedReference, + password: "wrong-password" + }); + expect(loadResult).toBeAnError(/.*/, "error.runtime.recordNotFound"); + }); + + test("error when loading the file with no password", async () => { + const createResult = await runtimeServices1.transport.files.createTokenForFile({ + fileId, + passwordProtection: { password: "password" } + }); + const loadResult = await runtimeServices2.transport.account.loadItemFromTruncatedReference({ reference: createResult.value.truncatedReference }); + expect(loadResult).toBeAnError(/.*/, "error.transport.noPasswordProvided"); + }); + }); +}); diff --git a/packages/runtime/test/transport/passwordProtection/relationshipTemplates.test.ts b/packages/runtime/test/transport/passwordProtection/relationshipTemplates.test.ts new file mode 100644 index 000000000..9d84b6a00 --- /dev/null +++ b/packages/runtime/test/transport/passwordProtection/relationshipTemplates.test.ts @@ -0,0 +1,410 @@ +import { RelationshipTemplateReference } from "@nmshd/transport"; +import { DateTime } from "luxon"; +import { createTemplate, emptyRelationshipTemplateContent, RuntimeServiceProvider, TestRuntimeServices } from "../../lib"; + +const serviceProvider = new RuntimeServiceProvider(); +let runtimeServices1: TestRuntimeServices; +let runtimeServices2: TestRuntimeServices; + +beforeAll(async () => { + const runtimeServices = await serviceProvider.launch(2); + runtimeServices1 = runtimeServices[0]; + runtimeServices2 = runtimeServices[1]; +}, 30000); +afterAll(() => serviceProvider.stop()); + +describe("Password-protected templates", () => { + test("send and receive a password-protected template", async () => { + const createResult = await runtimeServices1.transport.relationshipTemplates.createOwnRelationshipTemplate({ + content: emptyRelationshipTemplateContent, + expiresAt: DateTime.utc().plus({ minutes: 1 }).toString(), + passwordProtection: { + password: "password" + } + }); + expect(createResult).toBeSuccessful(); + expect(createResult.value.passwordProtection!.password).toBe("password"); + expect(createResult.value.passwordProtection!.passwordIsPin).toBeUndefined(); + const reference = RelationshipTemplateReference.from(createResult.value.truncatedReference); + expect(reference.passwordProtection!.passwordType).toBe("pw"); + + const loadResult = await runtimeServices2.transport.relationshipTemplates.loadPeerRelationshipTemplate({ + reference: createResult.value.truncatedReference, + password: "password" + }); + expect(loadResult).toBeSuccessful(); + expect(loadResult.value.passwordProtection!.password).toBe("password"); + expect(loadResult.value.passwordProtection!.passwordIsPin).toBeUndefined(); + }); + + test("send and receive a PIN-protected template", async () => { + const createResult = await runtimeServices1.transport.relationshipTemplates.createOwnRelationshipTemplate({ + content: emptyRelationshipTemplateContent, + expiresAt: DateTime.utc().plus({ minutes: 1 }).toString(), + passwordProtection: { + password: "1234", + passwordIsPin: true + } + }); + expect(createResult).toBeSuccessful(); + expect(createResult.value.passwordProtection!.password).toBe("1234"); + expect(createResult.value.passwordProtection!.passwordIsPin).toBe(true); + const reference = RelationshipTemplateReference.from(createResult.value.truncatedReference); + expect(reference.passwordProtection!.passwordType).toBe("pin4"); + + const loadResult = await runtimeServices2.transport.relationshipTemplates.loadPeerRelationshipTemplate({ + reference: createResult.value.truncatedReference, + password: "1234" + }); + expect(loadResult).toBeSuccessful(); + expect(loadResult.value.passwordProtection!.password).toBe("1234"); + expect(loadResult.value.passwordProtection!.passwordIsPin).toBe(true); + }); + + test("error when loading a password-protected template with a wrong password", async () => { + const createResult = await runtimeServices1.transport.relationshipTemplates.createOwnRelationshipTemplate({ + content: emptyRelationshipTemplateContent, + expiresAt: DateTime.utc().plus({ minutes: 1 }).toString(), + passwordProtection: { + password: "password" + } + }); + expect(createResult).toBeSuccessful(); + + const loadResult = await runtimeServices2.transport.relationshipTemplates.loadPeerRelationshipTemplate({ + reference: createResult.value.truncatedReference, + password: "wrong-password" + }); + expect(loadResult).toBeAnError("RelationshipTemplate not found. Make sure the ID exists and the record is not expired.", "error.runtime.recordNotFound"); + }); + + test("error when loading a password-protected template with no password", async () => { + const createResult = await runtimeServices1.transport.relationshipTemplates.createOwnRelationshipTemplate({ + content: emptyRelationshipTemplateContent, + expiresAt: DateTime.utc().plus({ minutes: 1 }).toString(), + passwordProtection: { + password: "password" + } + }); + expect(createResult).toBeSuccessful(); + + const loadResult = await runtimeServices2.transport.relationshipTemplates.loadPeerRelationshipTemplate({ + reference: createResult.value.truncatedReference + }); + expect(loadResult).toBeAnError(/.*/, "error.transport.noPasswordProvided"); + }); + + test("validation error when creating a template with empty string as the password", async () => { + const createResult = await runtimeServices1.transport.relationshipTemplates.createOwnRelationshipTemplate({ + content: emptyRelationshipTemplateContent, + expiresAt: DateTime.utc().plus({ minutes: 1 }).toString(), + passwordProtection: { + password: "" + } + }); + expect(createResult).toBeAnError("password must NOT have fewer than 1 characters", "error.runtime.validation.invalidPropertyValue"); + }); + + test("validation error when creating a template with an invalid PIN", async () => { + const createResult = await runtimeServices1.transport.relationshipTemplates.createOwnRelationshipTemplate({ + content: emptyRelationshipTemplateContent, + expiresAt: DateTime.utc().plus({ minutes: 1 }).toString(), + passwordProtection: { + password: "invalid-pin", + passwordIsPin: true + } + }); + expect(createResult).toBeAnError( + "'passwordProtection.passwordIsPin' is true, hence 'passwordProtection.password' must consist of 4 to 16 digits from 0 to 9.", + "error.runtime.validation.invalidPropertyValue" + ); + }); + + describe("LoadItemFromTruncatedReferenceUseCase", () => { + test("send and receive a password-protected template", async () => { + const createResult = await runtimeServices1.transport.relationshipTemplates.createOwnRelationshipTemplate({ + content: emptyRelationshipTemplateContent, + expiresAt: DateTime.utc().plus({ minutes: 1 }).toString(), + passwordProtection: { + password: "password" + } + }); + + const result = await runtimeServices2.transport.account.loadItemFromTruncatedReference({ + reference: createResult.value.truncatedReference, + password: "password" + }); + expect(result).toBeSuccessful(); + expect(result.value.type).toBe("RelationshipTemplate"); + }); + + test("error when loading a password-protected template with wrong password", async () => { + const createResult = await runtimeServices1.transport.relationshipTemplates.createOwnRelationshipTemplate({ + content: emptyRelationshipTemplateContent, + expiresAt: DateTime.utc().plus({ minutes: 1 }).toString(), + passwordProtection: { + password: "password" + } + }); + + const result = await runtimeServices2.transport.account.loadItemFromTruncatedReference({ + reference: createResult.value.truncatedReference, + password: "wrong-password" + }); + expect(result).toBeAnError(/.*/, "error.runtime.recordNotFound"); + }); + + test("error when loading a password-protected template with no password", async () => { + const createResult = await runtimeServices1.transport.relationshipTemplates.createOwnRelationshipTemplate({ + content: emptyRelationshipTemplateContent, + expiresAt: DateTime.utc().plus({ minutes: 1 }).toString(), + passwordProtection: { + password: "password" + } + }); + + const result = await runtimeServices2.transport.account.loadItemFromTruncatedReference({ + reference: createResult.value.truncatedReference + }); + expect(result).toBeAnError(/.*/, "error.transport.noPasswordProvided"); + }); + }); +}); + +describe("Password-protected templates via tokens", () => { + test("send and receive a password-protected template via token", async () => { + const templateId = (await createTemplate(runtimeServices1.transport, undefined, { password: "password" })).id; + + const createResult = await runtimeServices1.transport.relationshipTemplates.createTokenForOwnTemplate({ + templateId, + passwordProtection: { + password: "password" + } + }); + + const loadResult = await runtimeServices2.transport.relationshipTemplates.loadPeerRelationshipTemplate({ + reference: createResult.value.truncatedReference, + password: "password" + }); + expect(loadResult).toBeSuccessful(); + expect(loadResult.value.passwordProtection!.password).toBe("password"); + expect(loadResult.value.passwordProtection!.passwordIsPin).toBeUndefined(); + }); + + test("send and receive a PIN-protected template via token", async () => { + const templateId = (await createTemplate(runtimeServices1.transport, undefined, { password: "1234", passwordIsPin: true })).id; + + const createResult = await runtimeServices1.transport.relationshipTemplates.createTokenForOwnTemplate({ + templateId, + passwordProtection: { + password: "1234", + passwordIsPin: true + } + }); + + const loadResult = await runtimeServices2.transport.relationshipTemplates.loadPeerRelationshipTemplate({ + reference: createResult.value.truncatedReference, + password: "1234" + }); + expect(loadResult).toBeSuccessful(); + expect(loadResult.value.passwordProtection!.password).toBe("1234"); + expect(loadResult.value.passwordProtection!.passwordIsPin).toBe(true); + }); + + test("error when loading a password-protected template via token with wrong password", async () => { + const templateId = (await createTemplate(runtimeServices1.transport, undefined, { password: "password" })).id; + + const createResult = await runtimeServices1.transport.relationshipTemplates.createTokenForOwnTemplate({ + templateId, + passwordProtection: { + password: "password" + } + }); + + const loadResult = await runtimeServices2.transport.relationshipTemplates.loadPeerRelationshipTemplate({ + reference: createResult.value.truncatedReference, + password: "wrong-password" + }); + expect(loadResult).toBeAnError("Token not found. Make sure the ID exists and the record is not expired.", "error.runtime.recordNotFound"); + }); + + test("error when loading a password-protected template via token with no password", async () => { + const templateId = (await createTemplate(runtimeServices1.transport, undefined, { password: "password" })).id; + + const createResult = await runtimeServices1.transport.relationshipTemplates.createTokenForOwnTemplate({ + templateId, + passwordProtection: { + password: "password" + } + }); + + const loadResult = await runtimeServices2.transport.relationshipTemplates.loadPeerRelationshipTemplate({ + reference: createResult.value.truncatedReference + }); + expect(loadResult).toBeAnError(/.*/, "error.transport.noPasswordProvided"); + }); + + test("validation error when token password protection doesn't inherit template password protection", async () => { + const templateId = (await createTemplate(runtimeServices1.transport, undefined, { password: "password" })).id; + + const createResult = await runtimeServices1.transport.relationshipTemplates.createTokenForOwnTemplate({ + templateId + }); + + expect(createResult).toBeAnError(/.*/, "error.runtime.relationshipTemplates.passwordProtectionMustBeInherited"); + }); + + describe("LoadItemFromTruncatedReferenceUseCase", () => { + test("send and receive a password-protected template via token", async () => { + const template = await createTemplate(runtimeServices1.transport, undefined, { password: "password" }); + + const result = await runtimeServices2.transport.account.loadItemFromTruncatedReference({ + reference: template.truncatedReference, + password: "password" + }); + expect(result).toBeSuccessful(); + expect(result.value.type).toBe("RelationshipTemplate"); + }); + + test("error when loading a password-protected template via token with wrong password", async () => { + const template = await createTemplate(runtimeServices1.transport, undefined, { password: "password" }); + + const result = await runtimeServices2.transport.account.loadItemFromTruncatedReference({ + reference: template.truncatedReference, + password: "wrong-password" + }); + expect(result).toBeAnError(/.*/, "error.runtime.recordNotFound"); + }); + + test("error when loading a password-protected template via token with no password", async () => { + const template = await createTemplate(runtimeServices1.transport, undefined, { password: "password" }); + + const result = await runtimeServices2.transport.account.loadItemFromTruncatedReference({ + reference: template.truncatedReference + }); + expect(result).toBeAnError(/.*/, "error.transport.noPasswordProvided"); + }); + }); +}); + +describe("Password-protected tokens for unprotected templates", () => { + let templateId: string; + + beforeAll(async () => { + templateId = (await createTemplate(runtimeServices1.transport)).id; + }); + + test("send and receive a template via password-protected token", async () => { + const createResult = await runtimeServices1.transport.relationshipTemplates.createTokenForOwnTemplate({ + templateId, + passwordProtection: { password: "password" } + }); + expect(createResult).toBeSuccessful(); + expect(createResult.value.passwordProtection?.password).toBe("password"); + expect(createResult.value.passwordProtection?.passwordIsPin).toBeUndefined(); + + const loadResult = await runtimeServices2.transport.relationshipTemplates.loadPeerRelationshipTemplate({ + reference: createResult.value.truncatedReference, + password: "password" + }); + expect(loadResult).toBeSuccessful(); + }); + + test("send and receive a template via PIN-protected token", async () => { + const createResult = await runtimeServices1.transport.relationshipTemplates.createTokenForOwnTemplate({ + templateId, + passwordProtection: { password: "1234", passwordIsPin: true } + }); + expect(createResult).toBeSuccessful(); + expect(createResult.value.passwordProtection?.password).toBe("1234"); + expect(createResult.value.passwordProtection?.passwordIsPin).toBe(true); + + const loadResult = await runtimeServices2.transport.relationshipTemplates.loadPeerRelationshipTemplate({ + reference: createResult.value.truncatedReference, + password: "1234" + }); + expect(loadResult).toBeSuccessful(); + }); + + test("error when loading the template with a wrong password", async () => { + const createResult = await runtimeServices1.transport.relationshipTemplates.createTokenForOwnTemplate({ + templateId, + passwordProtection: { password: "password" } + }); + expect(createResult).toBeSuccessful(); + + const loadResult = await runtimeServices2.transport.files.getOrLoadFile({ reference: createResult.value.truncatedReference, password: "wrong-password" }); + expect(loadResult).toBeAnError(/.*/, "error.runtime.recordNotFound"); + }); + + test("error when loading the template with no password", async () => { + const createResult = await runtimeServices1.transport.relationshipTemplates.createTokenForOwnTemplate({ + templateId, + passwordProtection: { password: "password" } + }); + expect(createResult).toBeSuccessful(); + + const loadResult = await runtimeServices2.transport.files.getOrLoadFile({ reference: createResult.value.truncatedReference }); + expect(loadResult).toBeAnError(/.*/, "error.transport.noPasswordProvided"); + }); + + test("validation error when creating a token with empty string as the password", async () => { + const createResult = await runtimeServices1.transport.relationshipTemplates.createTokenForOwnTemplate({ + templateId, + passwordProtection: { password: "" } + }); + expect(createResult).toBeAnError("password must NOT have fewer than 1 characters", "error.runtime.validation.invalidPropertyValue"); + }); + + test("validation error when creating a token with an invalid PIN", async () => { + const createResult = await runtimeServices1.transport.relationshipTemplates.createTokenForOwnTemplate({ + templateId: templateId, + passwordProtection: { password: "invalid-pin", passwordIsPin: true } + }); + expect(createResult).toBeAnError( + "'passwordProtection.passwordIsPin' is true, hence 'passwordProtection.password' must consist of 4 to 16 digits from 0 to 9.", + "error.runtime.validation.invalidPropertyValue" + ); + }); + + describe("LoadItemFromTruncatedReferenceUseCase", () => { + test("send and receive a template via password-protected token", async () => { + const createResult = await runtimeServices1.transport.relationshipTemplates.createTokenForOwnTemplate({ + templateId, + passwordProtection: { password: "password" } + }); + + const result = await runtimeServices2.transport.account.loadItemFromTruncatedReference({ + reference: createResult.value.truncatedReference, + password: "password" + }); + expect(result).toBeSuccessful(); + expect(result.value.type).toBe("RelationshipTemplate"); + }); + + test("error when loading a template with wrong password", async () => { + const createResult = await runtimeServices1.transport.relationshipTemplates.createTokenForOwnTemplate({ + templateId, + passwordProtection: { password: "password" } + }); + + const result = await runtimeServices2.transport.account.loadItemFromTruncatedReference({ + reference: createResult.value.truncatedReference, + password: "wrong-password" + }); + expect(result).toBeAnError(/.*/, "error.runtime.recordNotFound"); + }); + + test("error when loading a template with no password", async () => { + const createResult = await runtimeServices1.transport.relationshipTemplates.createTokenForOwnTemplate({ + templateId, + passwordProtection: { password: "password" } + }); + + const result = await runtimeServices2.transport.account.loadItemFromTruncatedReference({ + reference: createResult.value.truncatedReference + }); + expect(result).toBeAnError(/.*/, "error.transport.noPasswordProvided"); + }); + }); +}); diff --git a/packages/runtime/test/transport/passwordProtection/tokens.test.ts b/packages/runtime/test/transport/passwordProtection/tokens.test.ts new file mode 100644 index 000000000..126d650d4 --- /dev/null +++ b/packages/runtime/test/transport/passwordProtection/tokens.test.ts @@ -0,0 +1,108 @@ +import { CoreDate } from "@nmshd/core-types"; +import { TokenReference } from "@nmshd/transport"; +import { RuntimeServiceProvider, TestRuntimeServices, uploadOwnToken } from "../../lib"; + +const serviceProvider = new RuntimeServiceProvider(); +let runtimeServices1: TestRuntimeServices; +let runtimeServices2: TestRuntimeServices; + +beforeAll(async () => { + const runtimeServices = await serviceProvider.launch(2); + runtimeServices1 = runtimeServices[0]; + runtimeServices2 = runtimeServices[1]; +}, 30000); +afterAll(() => serviceProvider.stop()); + +describe("Password-protected tokens", () => { + test("send and receive a password-protected token", async () => { + const token = await uploadOwnToken(runtimeServices1.transport, undefined, { password: "password" }); + expect(token.passwordProtection?.password).toBe("password"); + expect(token.passwordProtection?.passwordIsPin).toBeUndefined(); + + const reference = TokenReference.from(token.truncatedReference); + expect(reference.passwordProtection!.passwordType).toBe("pw"); + + const loadResult = await runtimeServices2.transport.tokens.loadPeerToken({ reference: token.truncatedReference, ephemeral: true, password: "password" }); + expect(loadResult).toBeSuccessful(); + expect(loadResult.value.passwordProtection?.password).toBe("password"); + expect(loadResult.value.passwordProtection?.passwordIsPin).toBeUndefined(); + }); + + test("send and receive a PIN-protected token", async () => { + const token = await uploadOwnToken(runtimeServices1.transport, undefined, { password: "1234", passwordIsPin: true }); + expect(token.passwordProtection?.password).toBe("1234"); + expect(token.passwordProtection?.passwordIsPin).toBe(true); + + const reference = TokenReference.from(token.truncatedReference); + expect(reference.passwordProtection!.passwordType).toBe("pin4"); + + const loadResult = await runtimeServices2.transport.tokens.loadPeerToken({ reference: token.truncatedReference, ephemeral: true, password: "1234" }); + expect(loadResult).toBeSuccessful(); + expect(loadResult.value.passwordProtection?.password).toBe("1234"); + expect(loadResult.value.passwordProtection?.passwordIsPin).toBe(true); + }); + + test("error when loading a token with a wrong password", async () => { + const token = await uploadOwnToken(runtimeServices1.transport, undefined, { password: "password" }); + + const loadResult = await runtimeServices2.transport.tokens.loadPeerToken({ reference: token.truncatedReference, ephemeral: true, password: "wrong-password" }); + expect(loadResult).toBeAnError(/.*/, "error.runtime.recordNotFound"); + }); + + test("error when loading a token with no password", async () => { + const token = await uploadOwnToken(runtimeServices1.transport, undefined, { password: "password" }); + + const loadResult = await runtimeServices2.transport.tokens.loadPeerToken({ + reference: token.truncatedReference, + ephemeral: true + }); + expect(loadResult).toBeAnError(/.*/, "error.transport.noPasswordProvided"); + }); + + test("validation error when creating a token with empty string as the password", async () => { + const createResult = await runtimeServices1.transport.tokens.createOwnToken({ + content: { key: "value" }, + expiresAt: CoreDate.utc().add({ minutes: 10 }).toISOString(), + ephemeral: true, + passwordProtection: { password: "" } + }); + expect(createResult).toBeAnError("password must NOT have fewer than 1 characters", "error.runtime.validation.invalidPropertyValue"); + }); + + test("validation error when creating a token with an invalid PIN", async () => { + const createResult = await runtimeServices1.transport.tokens.createOwnToken({ + content: { key: "value" }, + expiresAt: CoreDate.utc().add({ minutes: 10 }).toISOString(), + ephemeral: true, + passwordProtection: { password: "invalid-pin", passwordIsPin: true } + }); + expect(createResult).toBeAnError( + "'passwordProtection.passwordIsPin' is true, hence 'passwordProtection.password' must consist of 4 to 16 digits from 0 to 9.", + "error.runtime.validation.invalidPropertyValue" + ); + }); + + describe("LoadItemFromTruncatedReferenceUseCase", () => { + test("send and receive a password-protected token", async () => { + const token = await uploadOwnToken(runtimeServices1.transport, undefined, { password: "password" }); + + const loadResult = await runtimeServices2.transport.account.loadItemFromTruncatedReference({ reference: token.truncatedReference, password: "password" }); + expect(loadResult).toBeSuccessful(); + expect(loadResult.value.type).toBe("Token"); + }); + + test("error when loading a token with a wrong password", async () => { + const token = await uploadOwnToken(runtimeServices1.transport, undefined, { password: "password" }); + + const loadResult = await runtimeServices2.transport.account.loadItemFromTruncatedReference({ reference: token.truncatedReference, password: "wrong-password" }); + expect(loadResult).toBeAnError(/.*/, "error.runtime.recordNotFound"); + }); + + test("error when loading a token with no password", async () => { + const token = await uploadOwnToken(runtimeServices1.transport, undefined, { password: "password" }); + + const loadResult = await runtimeServices2.transport.account.loadItemFromTruncatedReference({ reference: token.truncatedReference }); + expect(loadResult).toBeAnError(/.*/, "error.transport.noPasswordProvided"); + }); + }); +}); diff --git a/packages/runtime/test/transport/relationshipTemplates.test.ts b/packages/runtime/test/transport/relationshipTemplates.test.ts index 6d5ee8fb2..d46560234 100644 --- a/packages/runtime/test/transport/relationshipTemplates.test.ts +++ b/packages/runtime/test/transport/relationshipTemplates.test.ts @@ -1,5 +1,4 @@ import { RelationshipTemplateContent, RelationshipTemplateContentJSON } from "@nmshd/content"; -import { RelationshipTemplateReference } from "@nmshd/transport"; import { DateTime } from "luxon"; import { GetRelationshipTemplatesQuery, OwnerRestriction } from "../../src"; import { emptyRelationshipTemplateContent, QueryParamConditions, RuntimeServiceProvider, TestRuntimeServices } from "../lib"; @@ -248,192 +247,6 @@ describe("RelationshipTemplate Tests", () => { expect(createQRCodeWithoutPersonalizationResult).toBeAnError(/.*/, "error.runtime.relationshipTemplates.personalizationMustBeInherited"); }); }); - - describe("Password-protected templates", () => { - test("send and receive a password-protected template", async () => { - const createResult = await runtimeServices1.transport.relationshipTemplates.createOwnRelationshipTemplate({ - content: emptyRelationshipTemplateContent, - expiresAt: DateTime.utc().plus({ minutes: 1 }).toString(), - passwordProtection: { - password: "password" - } - }); - expect(createResult).toBeSuccessful(); - expect(createResult.value.passwordProtection!.password).toBe("password"); - expect(createResult.value.passwordProtection!.passwordIsPin).toBeUndefined(); - const reference = RelationshipTemplateReference.from(createResult.value.truncatedReference); - expect(reference.passwordProtection!.passwordType).toBe("pw"); - - const loadResult = await runtimeServices2.transport.relationshipTemplates.loadPeerRelationshipTemplate({ - reference: createResult.value.truncatedReference, - password: "password" - }); - expect(loadResult).toBeSuccessful(); - expect(loadResult.value.passwordProtection!.password).toBe("password"); - expect(loadResult.value.passwordProtection!.passwordIsPin).toBeUndefined(); - }); - - test("send and receive a PIN-protected template", async () => { - const createResult = await runtimeServices1.transport.relationshipTemplates.createOwnRelationshipTemplate({ - content: emptyRelationshipTemplateContent, - expiresAt: DateTime.utc().plus({ minutes: 1 }).toString(), - passwordProtection: { - password: "1234", - passwordIsPin: true - } - }); - expect(createResult).toBeSuccessful(); - expect(createResult.value.passwordProtection!.password).toBe("1234"); - expect(createResult.value.passwordProtection!.passwordIsPin).toBe(true); - const reference = RelationshipTemplateReference.from(createResult.value.truncatedReference); - expect(reference.passwordProtection!.passwordType).toBe("pin4"); - - const loadResult = await runtimeServices2.transport.relationshipTemplates.loadPeerRelationshipTemplate({ - reference: createResult.value.truncatedReference, - password: "1234" - }); - expect(loadResult).toBeSuccessful(); - expect(loadResult.value.passwordProtection!.password).toBe("1234"); - expect(loadResult.value.passwordProtection!.passwordIsPin).toBe(true); - }); - - test("send and receive a password-protected template via a token", async () => { - const templateId = ( - await runtimeServices1.transport.relationshipTemplates.createOwnRelationshipTemplate({ - content: emptyRelationshipTemplateContent, - expiresAt: DateTime.utc().plus({ minutes: 1 }).toString(), - passwordProtection: { - password: "password" - } - }) - ).value.id; - const createResult = await runtimeServices1.transport.relationshipTemplates.createTokenForOwnTemplate({ templateId }); - - const loadResult = await runtimeServices2.transport.relationshipTemplates.loadPeerRelationshipTemplate({ - reference: createResult.value.truncatedReference, - password: "password" - }); - expect(loadResult).toBeSuccessful(); - expect(loadResult.value.passwordProtection!.password).toBe("password"); - expect(loadResult.value.passwordProtection!.passwordIsPin).toBeUndefined(); - }); - - test("send and receive a PIN-protected template via a token", async () => { - const templateId = ( - await runtimeServices1.transport.relationshipTemplates.createOwnRelationshipTemplate({ - content: emptyRelationshipTemplateContent, - expiresAt: DateTime.utc().plus({ minutes: 1 }).toString(), - passwordProtection: { - password: "1234", - passwordIsPin: true - } - }) - ).value.id; - - const createResult = await runtimeServices1.transport.relationshipTemplates.createTokenForOwnTemplate({ templateId }); - - const loadResult = await runtimeServices2.transport.relationshipTemplates.loadPeerRelationshipTemplate({ - reference: createResult.value.truncatedReference, - password: "1234" - }); - expect(loadResult).toBeSuccessful(); - expect(loadResult.value.passwordProtection!.password).toBe("1234"); - expect(loadResult.value.passwordProtection!.passwordIsPin).toBe(true); - }); - - test("error when loading a password-protected template with a wrong password", async () => { - const createResult = await runtimeServices1.transport.relationshipTemplates.createOwnRelationshipTemplate({ - content: emptyRelationshipTemplateContent, - expiresAt: DateTime.utc().plus({ minutes: 1 }).toString(), - passwordProtection: { - password: "password" - } - }); - expect(createResult).toBeSuccessful(); - - const loadResult = await runtimeServices2.transport.relationshipTemplates.loadPeerRelationshipTemplate({ - reference: createResult.value.truncatedReference, - password: "wrong-password" - }); - expect(loadResult).toBeAnError("RelationshipTemplate not found. Make sure the ID exists and the record is not expired.", "error.runtime.recordNotFound"); - }); - - test("error when loading a password-protected template via token with wrong password", async () => { - const templateId = ( - await runtimeServices1.transport.relationshipTemplates.createOwnRelationshipTemplate({ - content: emptyRelationshipTemplateContent, - expiresAt: DateTime.utc().plus({ minutes: 1 }).toString(), - passwordProtection: { - password: "password" - } - }) - ).value.id; - const createResult = await runtimeServices1.transport.relationshipTemplates.createTokenForOwnTemplate({ templateId }); - - const loadResult = await runtimeServices2.transport.relationshipTemplates.loadPeerRelationshipTemplate({ - reference: createResult.value.truncatedReference, - password: "wrong-password" - }); - expect(loadResult).toBeAnError("RelationshipTemplate not found. Make sure the ID exists and the record is not expired.", "error.runtime.recordNotFound"); - }); - - test("error when loading a password-protected template with no password", async () => { - const createResult = await runtimeServices1.transport.relationshipTemplates.createOwnRelationshipTemplate({ - content: emptyRelationshipTemplateContent, - expiresAt: DateTime.utc().plus({ minutes: 1 }).toString(), - passwordProtection: { - password: "password" - } - }); - expect(createResult).toBeSuccessful(); - - const loadResult = await runtimeServices2.transport.relationshipTemplates.loadPeerRelationshipTemplate({ - reference: createResult.value.truncatedReference - }); - expect(loadResult).toBeAnError(/.*/, "error.transport.noPasswordProvided"); - }); - - test("error when loading a password-protected template via token with no password", async () => { - const templateId = ( - await runtimeServices1.transport.relationshipTemplates.createOwnRelationshipTemplate({ - content: emptyRelationshipTemplateContent, - expiresAt: DateTime.utc().plus({ minutes: 1 }).toString(), - passwordProtection: { - password: "password" - } - }) - ).value.id; - const createResult = await runtimeServices1.transport.relationshipTemplates.createTokenForOwnTemplate({ templateId }); - - const loadResult = await runtimeServices2.transport.relationshipTemplates.loadPeerRelationshipTemplate({ - reference: createResult.value.truncatedReference - }); - expect(loadResult).toBeAnError(/.*/, "error.transport.noPasswordProvided"); - }); - - test("validation error when creating a template with empty string as the password", async () => { - const createResult = await runtimeServices1.transport.relationshipTemplates.createOwnRelationshipTemplate({ - content: emptyRelationshipTemplateContent, - expiresAt: DateTime.utc().plus({ minutes: 1 }).toString(), - passwordProtection: { - password: "" - } - }); - expect(createResult).toBeAnError("password must NOT have fewer than 1 characters", "error.runtime.validation.invalidPropertyValue"); - }); - - test("validation error when creating a template with an invalid PIN", async () => { - const createResult = await runtimeServices1.transport.relationshipTemplates.createOwnRelationshipTemplate({ - content: emptyRelationshipTemplateContent, - expiresAt: DateTime.utc().plus({ minutes: 1 }).toString(), - passwordProtection: { - password: "invalid-pin", - passwordIsPin: true - } - }); - expect(createResult).toBeAnError(/.*/, "error.runtime.validation.invalidPin"); - }); - }); }); describe("Serialization Errors", () => { diff --git a/packages/transport/src/core/types/PasswordProtection.ts b/packages/transport/src/core/types/PasswordProtection.ts index 0f61087ff..5e2be302e 100644 --- a/packages/transport/src/core/types/PasswordProtection.ts +++ b/packages/transport/src/core/types/PasswordProtection.ts @@ -1,5 +1,6 @@ import { ISerializable, Serializable, serialize, validate } from "@js-soft/ts-serval"; import { CoreBuffer, ICoreBuffer } from "@nmshd/crypto"; +import { PasswordProtectionCreationParameters } from "./PasswordProtectionCreationParameters"; import { SharedPasswordProtection } from "./SharedPasswordProtection"; export interface IPasswordProtection extends ISerializable { @@ -31,4 +32,15 @@ export class PasswordProtection extends Serializable implements IPasswordProtect salt: this.salt }); } + + public matchesInputForNewPasswordProtection(newPasswordProtection: { password: string; passwordIsPin?: true } | undefined): boolean { + const newCreationParameters = PasswordProtectionCreationParameters.create(newPasswordProtection); + if (!newCreationParameters) return false; + + return this.matchesCreationParameters(newCreationParameters); + } + + private matchesCreationParameters(creationParameters: PasswordProtectionCreationParameters): boolean { + return this.passwordType === creationParameters.passwordType && this.password === creationParameters.password; + } } diff --git a/packages/transport/src/modules/tokens/AnonymousTokenController.ts b/packages/transport/src/modules/tokens/AnonymousTokenController.ts index ea0e27f7f..b950bf000 100644 --- a/packages/transport/src/modules/tokens/AnonymousTokenController.ts +++ b/packages/transport/src/modules/tokens/AnonymousTokenController.ts @@ -2,6 +2,7 @@ import { Serializable } from "@js-soft/ts-serval"; import { CoreAddress, CoreDate, CoreId } from "@nmshd/core-types"; import { CryptoCipher, CryptoSecretKey } from "@nmshd/crypto"; import { CoreCrypto, IConfig, ICorrelator, TransportCoreErrors } from "../../core"; +import { PasswordProtection } from "../../core/types/PasswordProtection"; import { AnonymousTokenClient } from "./backbone/AnonymousTokenClient"; import { CachedToken } from "./local/CachedToken"; import { Token } from "./local/Token"; @@ -13,17 +14,28 @@ export class AnonymousTokenController { this.client = new AnonymousTokenClient(config, correlator); } - public async loadPeerTokenByTruncated(truncated: string): Promise { + public async loadPeerTokenByTruncated(truncated: string, password?: string): Promise { const reference = TokenReference.fromTruncated(truncated); - return await this.loadPeerToken(reference.id, reference.key, reference.forIdentityTruncated); + + if (reference.passwordProtection && !password) throw TransportCoreErrors.general.noPasswordProvided(); + const passwordProtection = reference.passwordProtection + ? PasswordProtection.from({ + salt: reference.passwordProtection.salt, + passwordType: reference.passwordProtection.passwordType, + password: password! + }) + : undefined; + + return await this.loadPeerToken(reference.id, reference.key, reference.forIdentityTruncated, passwordProtection); } - private async loadPeerToken(id: CoreId, secretKey: CryptoSecretKey, forIdentityTruncated?: string): Promise { + private async loadPeerToken(id: CoreId, secretKey: CryptoSecretKey, forIdentityTruncated?: string, passwordProtection?: PasswordProtection): Promise { if (forIdentityTruncated) { throw TransportCoreErrors.general.notIntendedForYou(id.toString()); } - const response = (await this.client.getToken(id.toString())).value; + const hashedPassword = passwordProtection ? (await CoreCrypto.deriveHashOutOfPassword(passwordProtection.password, passwordProtection.salt)).toBase64() : undefined; + const response = (await this.client.getToken(id.toString(), hashedPassword)).value; const cipher = CryptoCipher.fromBase64(response.content); const plaintextTokenBuffer = await CoreCrypto.decrypt(cipher, secretKey); @@ -35,7 +47,8 @@ export class AnonymousTokenController { const token = Token.from({ id: id, secretKey: secretKey, - isOwn: false + isOwn: false, + passwordProtection }); const cachedToken = CachedToken.from({ diff --git a/packages/transport/src/modules/tokens/TokenController.ts b/packages/transport/src/modules/tokens/TokenController.ts index a54dd31b5..01afa9b08 100644 --- a/packages/transport/src/modules/tokens/TokenController.ts +++ b/packages/transport/src/modules/tokens/TokenController.ts @@ -5,6 +5,7 @@ import { CoreBuffer, CryptoCipher, CryptoSecretKey } from "@nmshd/crypto"; import { CoreCrypto, TransportCoreErrors, TransportError } from "../../core"; import { DbCollectionName } from "../../core/DbCollectionName"; import { ControllerName, TransportController } from "../../core/TransportController"; +import { PasswordProtection } from "../../core/types/PasswordProtection"; import { AccountController } from "../accounts/AccountController"; import { SynchronizedCollection } from "../sync/SynchronizedCollection"; import { BackboneGetTokensResponse } from "./backbone/BackboneGetTokens"; @@ -44,11 +45,16 @@ export class TokenController extends TransportController { const cipher = await CoreCrypto.encrypt(serializedTokenBuffer, secretKey); + const password = parameters.passwordProtection?.password; + const salt = password ? await CoreCrypto.random(16) : undefined; + const hashedPassword = password ? (await CoreCrypto.deriveHashOutOfPassword(password, salt!)).toBase64() : undefined; + const response = ( await this.client.createToken({ content: cipher.toBase64(), expiresAt: input.expiresAt.toString(), - forIdentity: input.forIdentity?.toString() + forIdentity: input.forIdentity?.toString(), + password: hashedPassword }) ).value; @@ -61,10 +67,19 @@ export class TokenController extends TransportController { forIdentity: input.forIdentity }); + const passwordProtection = parameters.passwordProtection + ? PasswordProtection.from({ + password: parameters.passwordProtection.password, + passwordType: parameters.passwordProtection.passwordType, + salt: salt! + }) + : undefined; + const token = Token.from({ id: CoreId.from(response.id), secretKey: secretKey, isOwn: true, + passwordProtection, cache: cachedToken, cachedAt: CoreDate.utc() }); @@ -100,8 +115,21 @@ export class TokenController extends TransportController { if (ids.length < 1) { return []; } + const tokens = await this.readTokens(ids); + + const resultItems = ( + await this.client.getTokens({ + tokens: await Promise.all( + tokens.map(async (t) => { + const hashedPassword = t.passwordProtection + ? (await CoreCrypto.deriveHashOutOfPassword(t.passwordProtection.password, t.passwordProtection.salt)).toBase64() + : undefined; + return { id: t.id.toString(), password: hashedPassword }; + }) + ) + }) + ).value; - const resultItems = (await this.client.getTokens({ ids })).value; const promises = []; for await (const resultItem of resultItems) { promises.push(this.updateCacheOfExistingTokenInDb(resultItem.id, resultItem)); @@ -113,27 +141,43 @@ export class TokenController extends TransportController { public async fetchCaches(ids: CoreId[]): Promise<{ id: CoreId; cache: CachedToken }[]> { if (ids.length === 0) return []; - - const backboneTokens = await (await this.client.getTokens({ ids: ids.map((id) => id.id) })).value.collect(); + const tokens = await this.readTokens(ids.map((id) => id.toString())); + + const backboneTokens = await ( + await this.client.getTokens({ + tokens: await Promise.all( + tokens.map(async (t) => { + const hashedPassword = t.passwordProtection + ? (await CoreCrypto.deriveHashOutOfPassword(t.passwordProtection.password, t.passwordProtection.salt)).toBase64() + : undefined; + return { id: t.id.toString(), password: hashedPassword }; + }) + ) + }) + ).value.collect(); const decryptionPromises = backboneTokens.map(async (t) => { - const tokenDoc = await this.tokens.read(t.id); - if (!tokenDoc) { - this._log.error( - `Token '${t.id}' not found in local database and the cache fetching was therefore skipped. This should not happen and might be a bug in the application logic.` - ); - return; - } - - const token = Token.from(tokenDoc); - - return { id: CoreId.from(t), cache: await this.decryptToken(t, token.secretKey) }; + const token = tokens.find((token) => token.id.toString() === t.id); + if (!token) return; + return { id: CoreId.from(t.id), cache: await this.decryptToken(t, token.secretKey) }; }); const caches = await Promise.all(decryptionPromises); return caches.filter((c) => c !== undefined); } + private async readTokens(ids: string[]): Promise { + const tokenPromises = ids.map(async (id) => { + const tokenDoc = await this.tokens.read(id); + if (!tokenDoc) { + this._log.error(`Token '${id}' not found in local database. This should not happen and might be a bug in the application logic.`); + return; + } + return Token.from(tokenDoc); + }); + return (await Promise.all(tokenPromises)).filter((t) => t !== undefined); + } + @log() private async updateCacheOfExistingTokenInDb(id: string, response?: BackboneGetTokensResponse) { const tokenDoc = await this.tokens.read(id); @@ -150,10 +194,11 @@ export class TokenController extends TransportController { } private async updateCacheOfToken(token: Token, response?: BackboneGetTokensResponse): Promise { - const tokenId = token.id.toString(); - if (!response) { - response = (await this.client.getToken(tokenId)).value; + const hashedPassword = token.passwordProtection + ? (await CoreCrypto.deriveHashOutOfPassword(token.passwordProtection.password, token.passwordProtection.salt)).toBase64() + : undefined; + response = (await this.client.getToken(token.id.toString(), hashedPassword)).value; } const cachedToken = await this.decryptToken(response, token.secretKey); @@ -184,12 +229,28 @@ export class TokenController extends TransportController { return cachedToken; } - public async loadPeerTokenByTruncated(truncated: string, ephemeral: boolean): Promise { + public async loadPeerTokenByTruncated(truncated: string, ephemeral: boolean, password?: string): Promise { const reference = TokenReference.fromTruncated(truncated); - return await this.loadPeerToken(reference.id, reference.key, ephemeral, reference.forIdentityTruncated); + + if (reference.passwordProtection && !password) throw TransportCoreErrors.general.noPasswordProvided(); + const passwordProtection = reference.passwordProtection + ? PasswordProtection.from({ + salt: reference.passwordProtection.salt, + passwordType: reference.passwordProtection.passwordType, + password: password! + }) + : undefined; + + return await this.loadPeerToken(reference.id, reference.key, ephemeral, reference.forIdentityTruncated, passwordProtection); } - private async loadPeerToken(id: CoreId, secretKey: CryptoSecretKey, ephemeral: boolean, forIdentityTruncated?: string): Promise { + private async loadPeerToken( + id: CoreId, + secretKey: CryptoSecretKey, + ephemeral: boolean, + forIdentityTruncated?: string, + passwordProtection?: PasswordProtection + ): Promise { const tokenDoc = await this.tokens.read(id.toString()); if (!tokenDoc && forIdentityTruncated && !this.parent.identity.address.toString().endsWith(forIdentityTruncated)) { throw TransportCoreErrors.general.notIntendedForYou(id.toString()); @@ -213,7 +274,8 @@ export class TokenController extends TransportController { const token = Token.from({ id: id, secretKey: secretKey, - isOwn: false + isOwn: false, + passwordProtection }); await this.updateCacheOfToken(token); diff --git a/packages/transport/src/modules/tokens/backbone/AnonymousTokenClient.ts b/packages/transport/src/modules/tokens/backbone/AnonymousTokenClient.ts index 142032e48..9a5041566 100644 --- a/packages/transport/src/modules/tokens/backbone/AnonymousTokenClient.ts +++ b/packages/transport/src/modules/tokens/backbone/AnonymousTokenClient.ts @@ -3,7 +3,8 @@ import { ClientResult } from "../../../core/backbone/ClientResult"; import { BackboneGetTokensResponse } from "./BackboneGetTokens"; export class AnonymousTokenClient extends RESTClient { - public async getToken(id: string): Promise> { - return await this.get(`/api/v1/Tokens/${id}`); + public async getToken(id: string, password?: string): Promise> { + const request = password ? { password } : undefined; + return await this.get(`/api/v1/Tokens/${id}`, request); } } diff --git a/packages/transport/src/modules/tokens/backbone/BackboneGetTokens.ts b/packages/transport/src/modules/tokens/backbone/BackboneGetTokens.ts index b7484aa25..f026cbbbc 100644 --- a/packages/transport/src/modules/tokens/backbone/BackboneGetTokens.ts +++ b/packages/transport/src/modules/tokens/backbone/BackboneGetTokens.ts @@ -1,5 +1,5 @@ export interface BackboneGetTokensRequest { - ids: string[]; + tokens: { id: string; password?: string }[]; } export interface BackboneGetTokensResponse { diff --git a/packages/transport/src/modules/tokens/backbone/BackbonePostTokens.ts b/packages/transport/src/modules/tokens/backbone/BackbonePostTokens.ts index 7d1255b6c..6ac8c24f2 100644 --- a/packages/transport/src/modules/tokens/backbone/BackbonePostTokens.ts +++ b/packages/transport/src/modules/tokens/backbone/BackbonePostTokens.ts @@ -2,6 +2,7 @@ export interface BackbonePostTokensRequest { content: string; expiresAt: string; forIdentity?: string; + password?: string; } export interface BackbonePostTokensResponse { diff --git a/packages/transport/src/modules/tokens/backbone/TokenClient.ts b/packages/transport/src/modules/tokens/backbone/TokenClient.ts index 39dd2a8a5..401d3305f 100644 --- a/packages/transport/src/modules/tokens/backbone/TokenClient.ts +++ b/packages/transport/src/modules/tokens/backbone/TokenClient.ts @@ -13,8 +13,9 @@ export class TokenClient extends RESTClientAuthenticate { return await this.getPaged("/api/v1/Tokens", request); } - public async getToken(id: string): Promise> { - return await this.get(`/api/v1/Tokens/${id}`); + public async getToken(id: string, password?: string): Promise> { + const request = password ? { password } : undefined; + return await this.get(`/api/v1/Tokens/${id}`, request); } public async deleteToken(id: string): Promise> { diff --git a/packages/transport/src/modules/tokens/local/SendTokenParameters.ts b/packages/transport/src/modules/tokens/local/SendTokenParameters.ts index 366cecb06..b7c0f034d 100644 --- a/packages/transport/src/modules/tokens/local/SendTokenParameters.ts +++ b/packages/transport/src/modules/tokens/local/SendTokenParameters.ts @@ -1,11 +1,13 @@ import { ISerializable, Serializable, serialize, type, validate } from "@js-soft/ts-serval"; import { CoreAddress, CoreDate, ICoreAddress, ICoreDate } from "@nmshd/core-types"; +import { IPasswordProtectionCreationParameters, PasswordProtectionCreationParameters } from "../../../core/types/PasswordProtectionCreationParameters"; export interface ISendTokenParameters extends ISerializable { content: ISerializable; expiresAt: ICoreDate; ephemeral: boolean; forIdentity?: ICoreAddress; + passwordProtection?: IPasswordProtectionCreationParameters; } @type("SendTokenParameters") @@ -26,6 +28,10 @@ export class SendTokenParameters extends Serializable implements ISendTokenParam @serialize() public forIdentity?: CoreAddress; + @validate({ nullable: true }) + @serialize() + public passwordProtection?: PasswordProtectionCreationParameters; + public static from(value: ISendTokenParameters): SendTokenParameters { return this.fromAny(value); } diff --git a/packages/transport/src/modules/tokens/local/Token.ts b/packages/transport/src/modules/tokens/local/Token.ts index ee5a23b21..68f906083 100644 --- a/packages/transport/src/modules/tokens/local/Token.ts +++ b/packages/transport/src/modules/tokens/local/Token.ts @@ -3,12 +3,14 @@ import { CoreDate, ICoreDate } from "@nmshd/core-types"; import { CryptoSecretKey, ICryptoSecretKey } from "@nmshd/crypto"; import { nameof } from "ts-simple-nameof"; import { CoreSynchronizable, ICoreSynchronizable } from "../../../core"; +import { IPasswordProtection, PasswordProtection } from "../../../core/types/PasswordProtection"; import { TokenReference } from "../transmission/TokenReference"; import { CachedToken, ICachedToken } from "./CachedToken"; export interface IToken extends ICoreSynchronizable { secretKey: ICryptoSecretKey; isOwn: boolean; + passwordProtection?: IPasswordProtection; cache?: ICachedToken; cachedAt?: ICoreDate; metadata?: any; @@ -18,7 +20,7 @@ export interface IToken extends ICoreSynchronizable { @type("Token") export class Token extends CoreSynchronizable implements IToken { public override readonly technicalProperties = ["@type", "@context", nameof((r) => r.secretKey), nameof((r) => r.isOwn)]; - + public override readonly userdataProperties = [nameof((r) => r.passwordProtection)]; public override readonly metadataProperties = [nameof((r) => r.metadata), nameof((r) => r.metadataModifiedAt)]; @validate() @@ -29,6 +31,10 @@ export class Token extends CoreSynchronizable implements IToken { @serialize() public isOwn: boolean; + @validate({ nullable: true }) + @serialize() + public passwordProtection?: PasswordProtection; + @validate({ nullable: true }) @serialize() public cache?: CachedToken; @@ -50,7 +56,12 @@ export class Token extends CoreSynchronizable implements IToken { } public toTokenReference(): TokenReference { - return TokenReference.from({ id: this.id, key: this.secretKey, forIdentityTruncated: this.cache!.forIdentity?.toString().slice(-4) }); + return TokenReference.from({ + id: this.id, + key: this.secretKey, + forIdentityTruncated: this.cache!.forIdentity?.toString().slice(-4), + passwordProtection: this.passwordProtection?.toSharedPasswordProtection() + }); } public truncate(): string { diff --git a/packages/transport/test/modules/tokens/AnonymousTokenController.test.ts b/packages/transport/test/modules/tokens/AnonymousTokenController.test.ts index b8b6800cf..857c668d5 100644 --- a/packages/transport/test/modules/tokens/AnonymousTokenController.test.ts +++ b/packages/transport/test/modules/tokens/AnonymousTokenController.test.ts @@ -95,4 +95,43 @@ describe("AnonymousTokenController", function () { await expect(anonymousTokenController.loadPeerTokenByTruncated(truncatedReference)).rejects.toThrow("error.platform.recordNotFound"); }); + + test("should load a password-protected token", async function () { + const tempDate = CoreDate.utc().subtract(TestUtil.tempDateThreshold); + const expiresAt = CoreDate.utc().add({ minutes: 5 }); + const content = Serializable.fromAny({ content: "TestToken" }); + const sentToken = await sender.tokens.sendToken({ + content, + expiresAt, + ephemeral: false, + passwordProtection: { password: "password", passwordType: "pw" } + }); + const reference = sentToken.toTokenReference().truncate(); + const receivedToken = await anonymousTokenController.loadPeerTokenByTruncated(reference, "password"); + + testTokens(sentToken, receivedToken, tempDate); + expect(sentToken.cache?.expiresAt.toISOString()).toBe(expiresAt.toISOString()); + expect(sentToken.cache?.content).toBeInstanceOf(Serializable); + expect(receivedToken.cache?.content).toBeInstanceOf(JSONWrapper); + expect((sentToken.cache?.content.toJSON() as any).content).toBe("TestToken"); + expect((receivedToken.cache?.content as any).content).toBe((sentToken.cache?.content as any).content); + expect(receivedToken.passwordProtection!.password).toBe("password"); + expect(receivedToken.passwordProtection!.salt).toStrictEqual(sentToken.passwordProtection!.salt); + expect(receivedToken.passwordProtection!.passwordType).toBe("pw"); + }); + + test("should throw an error if loaded with a wrong or missing password", async function () { + const expiresAt = CoreDate.utc().add({ minutes: 5 }); + const content = Serializable.fromAny({ content: "TestToken" }); + const sentToken = await sender.tokens.sendToken({ + content, + expiresAt, + ephemeral: false, + passwordProtection: { password: "password", passwordType: "pw" } + }); + const reference = sentToken.toTokenReference().truncate(); + + await expect(anonymousTokenController.loadPeerTokenByTruncated(reference, "wrong-password")).rejects.toThrow("error.platform.recordNotFound"); + await expect(anonymousTokenController.loadPeerTokenByTruncated(reference)).rejects.toThrow("error.transport.noPasswordProvided"); + }); }); diff --git a/packages/transport/test/modules/tokens/TokenController.test.ts b/packages/transport/test/modules/tokens/TokenController.test.ts index 2c4821598..12099f356 100644 --- a/packages/transport/test/modules/tokens/TokenController.test.ts +++ b/packages/transport/test/modules/tokens/TokenController.test.ts @@ -302,6 +302,7 @@ describe("TokenController", function () { await recipient.tokens.loadPeerTokenByTruncated(sentToken.toTokenReference().truncate(), true); }).rejects.toThrow("transport.general.notIntendedForYou"); }); + test("should throw if a personalized token is not loaded by the right identity and it's uncaught before reaching the Backbone", async function () { const expiresAt = CoreDate.utc().add({ minutes: 5 }); const content = Serializable.fromAny({ content: "TestToken" }); @@ -321,6 +322,80 @@ describe("TokenController", function () { }).rejects.toThrow("error.platform.recordNotFound"); }); + test("should create and load a password-protected token", async function () { + tempDate = CoreDate.utc().subtract(TestUtil.tempDateThreshold); + const expiresAt = CoreDate.utc().add({ minutes: 5 }); + const content = Serializable.fromAny({ content: "TestToken" }); + const sentToken = await sender.tokens.sendToken({ + content, + expiresAt, + ephemeral: false, + passwordProtection: { password: "password", passwordType: "pw" } + }); + const reference = sentToken.toTokenReference(); + const receivedToken = await recipient.tokens.loadPeerTokenByTruncated(reference.truncate(), false, "password"); + tempId1 = sentToken.id; + + testTokens(sentToken, receivedToken, tempDate); + expect(sentToken.cache?.expiresAt.toISOString()).toBe(expiresAt.toISOString()); + expect(sentToken.cache?.content).toBeInstanceOf(Serializable); + expect(sentToken.passwordProtection!.password).toBe("password"); + expect(sentToken.passwordProtection!.salt).toBeDefined(); + expect(sentToken.passwordProtection!.salt).toHaveLength(16); + expect(sentToken.passwordProtection!.passwordType).toBe("pw"); + + expect(reference.passwordProtection!.passwordType).toBe("pw"); + expect(reference.passwordProtection!.salt).toStrictEqual(sentToken.passwordProtection!.salt); + + expect(receivedToken.cache?.content).toBeInstanceOf(JSONWrapper); + expect((receivedToken.cache?.content as any).content).toBe((sentToken.cache?.content as any).content); + expect(receivedToken.passwordProtection!.password).toBe("password"); + expect(receivedToken.passwordProtection!.salt).toStrictEqual(sentToken.passwordProtection!.salt); + expect(receivedToken.passwordProtection!.passwordType).toBe("pw"); + }); + + test("should throw an error if loaded with a wrong or missing password", async function () { + tempDate = CoreDate.utc().subtract(TestUtil.tempDateThreshold); + const expiresAt = CoreDate.utc().add({ minutes: 5 }); + const content = Serializable.fromAny({ content: "TestToken" }); + const sentToken = await sender.tokens.sendToken({ + content, + expiresAt, + ephemeral: false, + passwordProtection: { password: "password", passwordType: "pw" } + }); + const reference = sentToken.toTokenReference().truncate(); + + await expect(recipient.tokens.loadPeerTokenByTruncated(reference, true, "wrongPassword")).rejects.toThrow("error.platform.recordNotFound"); + await expect(recipient.tokens.loadPeerTokenByTruncated(reference, true)).rejects.toThrow("error.transport.noPasswordProvided"); + }); + + test("should fetch multiple password-protected tokens", async function () { + tempDate = CoreDate.utc().subtract(TestUtil.tempDateThreshold); + const expiresAt = CoreDate.utc().add({ minutes: 5 }); + const content = Serializable.fromAny({ content: "TestToken" }); + const sentToken1 = await sender.tokens.sendToken({ + content, + expiresAt, + ephemeral: false, + passwordProtection: { password: "password", passwordType: "pw" } + }); + const reference1 = sentToken1.toTokenReference().truncate(); + + const sentToken2 = await sender.tokens.sendToken({ + content, + expiresAt, + ephemeral: false, + passwordProtection: { password: "1234", passwordType: "pin4" } + }); + const reference2 = sentToken2.toTokenReference().truncate(); + + const receivedToken1 = await recipient.tokens.loadPeerTokenByTruncated(reference1, false, "password"); + const receivedToken2 = await recipient.tokens.loadPeerTokenByTruncated(reference2, false, "1234"); + const fetchCachesResult = await recipient.tokens.fetchCaches([receivedToken1.id, receivedToken2.id]); + expect(fetchCachesResult).toHaveLength(2); + }); + test("should delete a token", async function () { const expiresAt = CoreDate.utc().add({ minutes: 5 }); const content = Serializable.fromAny({ content: "TestToken" }); From 95dc437727f599b686e226ed9942dc42f85b9200 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julian=20K=C3=B6nig?= <33655937+jkoenig134@users.noreply.github.com> Date: Mon, 2 Dec 2024 15:25:11 +0100 Subject: [PATCH 16/21] Process personalized and password protected objects in the StringProcessor (#299) * feat: add enterPassword function to UIBridge * feat: prepare tests * refactor: be more precise about what's going wrong * test: make app-runtimes EventBus mockable * fix: make UIBridge mockable * add eslint assert function * chore: add test for personalized RelationshipTemplate * test: add second test for no matching relationship * refactor: make password protection typesafe * refactor: adapt to more runtime changes * chore: use any casts for testing * fix: eslint * fix: add substring * feat: use the provided password to load objects * feat: proper eventbus * fix: properly await the UIBridge * fix: proper mock event bus usage * fix: proper mock event bus usage * chore: add MockUIBridge * refactor: simplify tests * feat: add password protection tests * chore: remove forIdentity * chore: add combinated test * chore: re-simplify uiBridge calls * chore: wording * feat: add passwordProtection to CreateDeviceOnboardingTokenRequest * test: test and assert more stuff * chore: remove todos * fix: make fully mockable * refactor: migrate to custom matchers * chore: move enterPassword to private method * chore: PR comments * refactor: Thomas' PR comments * fix: bulletproof pin parsing * chore: messages * chore: PR comments * chore: wording --------- Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com> --- .eslintrc | 1 + packages/app-runtime/package.json | 1 + packages/app-runtime/src/AppRuntime.ts | 36 +--- packages/app-runtime/src/AppRuntimeErrors.ts | 8 + .../app-runtime/src/AppStringProcessor.ts | 102 +++++++--- .../src/extensibility/ui/IUIBridge.ts | 1 + .../modules/appEvents/MailReceivedModule.ts | 3 +- packages/app-runtime/test/customMatchers.ts | 65 ++++++ packages/app-runtime/test/lib/FakeUIBridge.ts | 4 + packages/app-runtime/test/lib/MockEventBus.ts | 86 ++++++++ .../test/lib/MockUIBridge.matchers.ts | 122 +++++++++++ packages/app-runtime/test/lib/MockUIBridge.ts | 92 +++++++++ packages/app-runtime/test/lib/TestUtil.ts | 10 +- packages/app-runtime/test/lib/index.ts | 2 + .../test/runtime/AppStringProcessor.test.ts | 192 +++++++++++++++++- .../runtime/src/useCases/common/Schemas.ts | 16 ++ .../devices/CreateDeviceOnboardingToken.ts | 13 +- packages/runtime/test/lib/MockEventBus.ts | 2 +- packages/transport/src/core/Reference.ts | 4 +- .../src/core/types/PasswordProtection.ts | 4 +- .../PasswordProtectionCreationParameters.ts | 4 +- .../core/types/SharedPasswordProtection.ts | 6 +- .../test/modules/files/FileReference.test.ts | 2 +- .../RelationshipTemplateReference.test.ts | 2 +- .../test/modules/tokens/TokenContent.test.ts | 2 +- .../modules/tokens/TokenReference.test.ts | 2 +- 26 files changed, 703 insertions(+), 79 deletions(-) create mode 100644 packages/app-runtime/test/customMatchers.ts create mode 100644 packages/app-runtime/test/lib/MockEventBus.ts create mode 100644 packages/app-runtime/test/lib/MockUIBridge.matchers.ts create mode 100644 packages/app-runtime/test/lib/MockUIBridge.ts diff --git a/.eslintrc b/.eslintrc index b6ba9e982..83e210136 100644 --- a/.eslintrc +++ b/.eslintrc @@ -17,6 +17,7 @@ "*.expectThrows*", "Then.*", "*.expectPublishedEvents", + "*.expectLastPublishedEvent", "*.executeTests", "expectThrows*" ] diff --git a/packages/app-runtime/package.json b/packages/app-runtime/package.json index 3140422b7..3dcbde9ca 100644 --- a/packages/app-runtime/package.json +++ b/packages/app-runtime/package.json @@ -37,6 +37,7 @@ "maxWorkers": 5, "preset": "ts-jest", "setupFilesAfterEnv": [ + "./test/customMatchers.ts", "jest-expect-message" ], "testEnvironment": "node", diff --git a/packages/app-runtime/src/AppRuntime.ts b/packages/app-runtime/src/AppRuntime.ts index 6852d4327..62a409cd2 100644 --- a/packages/app-runtime/src/AppRuntime.ts +++ b/packages/app-runtime/src/AppRuntime.ts @@ -1,6 +1,6 @@ import { IDatabaseConnection } from "@js-soft/docdb-access-abstractions"; import { LokiJsConnection } from "@js-soft/docdb-access-loki"; -import { Result } from "@js-soft/ts-utils"; +import { EventBus, Result } from "@js-soft/ts-utils"; import { ConsumptionController } from "@nmshd/consumption"; import { CoreId, ICoreAddress } from "@nmshd/core-types"; import { ModuleConfiguration, Runtime, RuntimeHealth } from "@nmshd/runtime"; @@ -25,7 +25,7 @@ import { RelationshipChangedModule, RelationshipTemplateProcessedModule } from "./modules"; -import { AccountServices, LocalAccountDTO, LocalAccountMapper, LocalAccountSession, MultiAccountController } from "./multiAccount"; +import { AccountServices, LocalAccountMapper, LocalAccountSession, MultiAccountController } from "./multiAccount"; import { INativeBootstrapper, INativeEnvironment, INativeTranslationProvider } from "./natives"; import { SessionStorage } from "./SessionStorage"; import { UserfriendlyResult } from "./UserfriendlyResult"; @@ -33,9 +33,10 @@ import { UserfriendlyResult } from "./UserfriendlyResult"; export class AppRuntime extends Runtime { public constructor( private readonly _nativeEnvironment: INativeEnvironment, - appConfig: AppConfig + appConfig: AppConfig, + eventBus?: EventBus ) { - super(appConfig, _nativeEnvironment.loggerFactory); + super(appConfig, _nativeEnvironment.loggerFactory, eventBus); this._stringProcessor = new AppStringProcessor(this, this.loggerFactory); } @@ -47,16 +48,17 @@ export class AppRuntime extends Runtime { private _uiBridge: IUIBridge | undefined; private _uiBridgeResolver?: { promise: Promise; resolve(uiBridge: IUIBridge): void }; - public async uiBridge(): Promise { + public uiBridge(): Promise | IUIBridge { if (this._uiBridge) return this._uiBridge; - if (this._uiBridgeResolver) return await this._uiBridgeResolver.promise; + + if (this._uiBridgeResolver) return this._uiBridgeResolver.promise; let resolve: (uiBridge: IUIBridge) => void = () => ""; const promise = new Promise((r) => (resolve = r)); this._uiBridgeResolver = { promise, resolve }; try { - return await this._uiBridgeResolver.promise; + return this._uiBridgeResolver.promise; } finally { this._uiBridgeResolver = undefined; } @@ -187,22 +189,6 @@ export class AppRuntime extends Runtime { return session; } - public async requestAccountSelection( - title = "i18n://uibridge.accountSelection.title", - description = "i18n://uibridge.accountSelection.description" - ): Promise> { - const accounts = await this.accountServices.getAccounts(); - - const bridge = await this.uiBridge(); - const accountSelectionResult = await bridge.requestAccountSelection(accounts, title, description); - if (accountSelectionResult.isError) { - return UserfriendlyResult.fail(AppRuntimeErrors.general.noAccountAvailable(accountSelectionResult.error)); - } - - if (accountSelectionResult.value) await this.selectAccount(accountSelectionResult.value.id); - return UserfriendlyResult.ok(accountSelectionResult.value); - } - public getHealth(): Promise { const health = { isHealthy: true, @@ -217,7 +203,7 @@ export class AppRuntime extends Runtime { this._accountServices = new AccountServices(this._multiAccountController); } - public static async create(nativeBootstrapper: INativeBootstrapper, appConfig?: AppConfigOverwrite): Promise { + public static async create(nativeBootstrapper: INativeBootstrapper, appConfig?: AppConfigOverwrite, eventBus?: EventBus): Promise { // TODO: JSSNMSHDD-2524 (validate app config) if (!nativeBootstrapper.isInitialized) { @@ -250,7 +236,7 @@ export class AppRuntime extends Runtime { databaseFolder: databaseFolder }); - const runtime = new AppRuntime(nativeBootstrapper.nativeEnvironment, mergedConfig); + const runtime = new AppRuntime(nativeBootstrapper.nativeEnvironment, mergedConfig, eventBus); await runtime.init(); runtime.logger.trace("Runtime initialized"); diff --git a/packages/app-runtime/src/AppRuntimeErrors.ts b/packages/app-runtime/src/AppRuntimeErrors.ts index 14685caf5..ed9339da9 100644 --- a/packages/app-runtime/src/AppRuntimeErrors.ts +++ b/packages/app-runtime/src/AppRuntimeErrors.ts @@ -30,6 +30,14 @@ class General { error ); } + + public noAccountAvailableForIdentityTruncated(): UserfriendlyApplicationError { + return new UserfriendlyApplicationError( + "error.appruntime.general.noAccountAvailableForIdentityTruncated", + "There is no account matching the given 'forIdentityTruncated'.", + "It seems no eligible account is available for this action, because the scanned code is intended for a specific Identity that is not available on this device." + ); + } } class Startup { diff --git a/packages/app-runtime/src/AppStringProcessor.ts b/packages/app-runtime/src/AppStringProcessor.ts index c8ed7d334..4629a5505 100644 --- a/packages/app-runtime/src/AppStringProcessor.ts +++ b/packages/app-runtime/src/AppStringProcessor.ts @@ -3,11 +3,11 @@ import { Serializable } from "@js-soft/ts-serval"; import { EventBus, Result } from "@js-soft/ts-utils"; import { ICoreAddress } from "@nmshd/core-types"; import { AnonymousServices, Base64ForIdPrefix, DeviceMapper } from "@nmshd/runtime"; -import { TokenContentDeviceSharedSecret } from "@nmshd/transport"; +import { Reference, SharedPasswordProtection, TokenContentDeviceSharedSecret } from "@nmshd/transport"; import { AppRuntimeErrors } from "./AppRuntimeErrors"; import { AppRuntimeServices } from "./AppRuntimeServices"; import { IUIBridge } from "./extensibility"; -import { LocalAccountDTO } from "./multiAccount"; +import { AccountServices, LocalAccountDTO, LocalAccountSession } from "./multiAccount"; import { UserfriendlyApplicationError } from "./UserfriendlyApplicationError"; import { UserfriendlyResult } from "./UserfriendlyResult"; @@ -17,11 +17,12 @@ export class AppStringProcessor { public constructor( protected readonly runtime: { get anonymousServices(): AnonymousServices; - requestAccountSelection(title?: string, description?: string): Promise>; - uiBridge(): Promise; + get accountServices(): AccountServices; + uiBridge(): Promise | IUIBridge; getServices(accountReference: string | ICoreAddress): Promise; translate(key: string, ...values: any[]): Promise>; get eventBus(): EventBus; + selectAccount(accountReference: string): Promise; }, loggerFactory: ILoggerFactory ) { @@ -40,11 +41,20 @@ export class AppStringProcessor { } public async processTruncatedReference(truncatedReference: string, account?: LocalAccountDTO): Promise> { - if (account) return await this._handleTruncatedReference(truncatedReference, account); + let reference: Reference; + try { + reference = Reference.fromTruncated(truncatedReference); + } catch (_) { + return UserfriendlyResult.fail( + new UserfriendlyApplicationError("error.appStringProcessor.truncatedReferenceInvalid", "The given code does not contain a valid truncated reference.") + ); + } + + if (account) return await this._handleReference(reference, account); // process Files and RelationshipTemplates and ask for an account if (truncatedReference.startsWith(Base64ForIdPrefix.File) || truncatedReference.startsWith(Base64ForIdPrefix.RelationshipTemplate)) { - const result = await this.runtime.requestAccountSelection(); + const result = await this.selectAccount(reference.forIdentityTruncated); if (result.isError) { this.logger.error("Could not query account", result.error); return UserfriendlyResult.fail(result.error); @@ -55,7 +65,7 @@ export class AppStringProcessor { return UserfriendlyResult.ok(undefined); } - return await this._handleTruncatedReference(truncatedReference, result.value); + return await this._handleReference(reference, result.value); } if (!truncatedReference.startsWith(Base64ForIdPrefix.Token)) { @@ -63,11 +73,21 @@ export class AppStringProcessor { return UserfriendlyResult.fail(error); } - const tokenResult = await this.runtime.anonymousServices.tokens.loadPeerToken({ reference: truncatedReference }); - if (tokenResult.isError) { - return UserfriendlyResult.fail(UserfriendlyApplicationError.fromError(tokenResult.error)); + const uiBridge = await this.runtime.uiBridge(); + + let password: string | undefined; + if (reference.passwordProtection) { + const passwordResult = await this.enterPassword(reference.passwordProtection); + if (passwordResult.isError) { + return UserfriendlyResult.fail(new UserfriendlyApplicationError("error.appStringProcessor.passwordNotProvided", "No password was provided.")); + } + + password = passwordResult.value; } + const tokenResult = await this.runtime.anonymousServices.tokens.loadPeerToken({ reference: truncatedReference, password: password }); + if (tokenResult.isError) return UserfriendlyResult.fail(UserfriendlyApplicationError.fromError(tokenResult.error)); + const tokenDTO = tokenResult.value; const tokenContent = this.parseTokenContent(tokenDTO.content); if (!tokenContent) { @@ -76,12 +96,11 @@ export class AppStringProcessor { } if (tokenContent instanceof TokenContentDeviceSharedSecret) { - const uiBridge = await this.runtime.uiBridge(); await uiBridge.showDeviceOnboarding(DeviceMapper.toDeviceOnboardingInfoDTO(tokenContent.sharedSecret)); return UserfriendlyResult.ok(undefined); } - const accountSelectionResult = await this.runtime.requestAccountSelection(); + const accountSelectionResult = await this.selectAccount(reference.forIdentityTruncated); if (accountSelectionResult.isError) { return UserfriendlyResult.fail(accountSelectionResult.error); } @@ -92,26 +111,26 @@ export class AppStringProcessor { return UserfriendlyResult.ok(undefined); } - return await this._handleTruncatedReference(truncatedReference, selectedAccount); + return await this._handleReference(reference, selectedAccount, password); } - private async _handleTruncatedReference(truncatedReference: string, account: LocalAccountDTO): Promise> { + private async _handleReference(reference: Reference, account: LocalAccountDTO, existingPassword?: string): Promise> { const services = await this.runtime.getServices(account.id); const uiBridge = await this.runtime.uiBridge(); - const result = await services.transportServices.account.loadItemFromTruncatedReference({ - reference: truncatedReference - }); - if (result.isError) { - if (result.error.code === "error.runtime.validation.invalidPropertyValue") { - return UserfriendlyResult.fail( - new UserfriendlyApplicationError("error.appStringProcessor.truncatedReferenceInvalid", "The given code does not contain a valid truncated reference.") - ); + let password: string | undefined = existingPassword; + if (reference.passwordProtection && !password) { + const passwordResult = await this.enterPassword(reference.passwordProtection); + if (passwordResult.isError) { + return UserfriendlyResult.fail(new UserfriendlyApplicationError("error.appStringProcessor.passwordNotProvided", "No password was provided.")); } - return UserfriendlyResult.fail(UserfriendlyApplicationError.fromError(result.error)); + password = passwordResult.value; } + const result = await services.transportServices.account.loadItemFromTruncatedReference({ reference: reference.truncate(), password: password }); + if (result.isError) return UserfriendlyResult.fail(UserfriendlyApplicationError.fromError(result.error)); + switch (result.value.type) { case "File": const file = await services.dataViewExpander.expandFileDTO(result.value.value); @@ -144,4 +163,41 @@ export class AppStringProcessor { return undefined; } } + + private async enterPassword(passwordProtection: SharedPasswordProtection): Promise> { + const uiBridge = await this.runtime.uiBridge(); + const passwordResult = await uiBridge.enterPassword( + passwordProtection.passwordType === "pw" ? "pw" : "pin", + passwordProtection.passwordType.startsWith("pin") ? parseInt(passwordProtection.passwordType.substring(3)) : undefined + ); + + return passwordResult; + } + + private async selectAccount(forIdentityTruncated?: string): Promise> { + const accounts = await this.runtime.accountServices.getAccounts(); + + const title = "i18n://uibridge.accountSelection.title"; + const description = "i18n://uibridge.accountSelection.description"; + if (!forIdentityTruncated) return await this.requestManualAccountSelection(accounts, title, description); + + const accountsWithPostfix = accounts.filter((account) => account.address?.endsWith(forIdentityTruncated)); + if (accountsWithPostfix.length === 0) return UserfriendlyResult.fail(AppRuntimeErrors.general.noAccountAvailableForIdentityTruncated()); + if (accountsWithPostfix.length === 1) return UserfriendlyResult.ok(accountsWithPostfix[0]); + + // This catches the extremely rare case where two accounts are available that have the same last 4 characters in their address. In that case + // the user will have to decide which account to use, which could not work because it is not the exactly same address specified when personalizing the object. + return await this.requestManualAccountSelection(accountsWithPostfix, title, description); + } + + private async requestManualAccountSelection(accounts: LocalAccountDTO[], title: string, description: string): Promise> { + const uiBridge = await this.runtime.uiBridge(); + const accountSelectionResult = await uiBridge.requestAccountSelection(accounts, title, description); + if (accountSelectionResult.isError) { + return UserfriendlyResult.fail(AppRuntimeErrors.general.noAccountAvailable(accountSelectionResult.error)); + } + + if (accountSelectionResult.value) await this.runtime.selectAccount(accountSelectionResult.value.id); + return UserfriendlyResult.ok(accountSelectionResult.value); + } } diff --git a/packages/app-runtime/src/extensibility/ui/IUIBridge.ts b/packages/app-runtime/src/extensibility/ui/IUIBridge.ts index 6e3350699..afbbefacd 100644 --- a/packages/app-runtime/src/extensibility/ui/IUIBridge.ts +++ b/packages/app-runtime/src/extensibility/ui/IUIBridge.ts @@ -11,4 +11,5 @@ export interface IUIBridge { showRequest(account: LocalAccountDTO, request: LocalRequestDVO): Promise>; showError(error: UserfriendlyApplicationError, account?: LocalAccountDTO): Promise>; requestAccountSelection(possibleAccounts: LocalAccountDTO[], title?: string, description?: string): Promise>; + enterPassword(passwordType: "pw" | "pin", pinLength?: number): Promise>; } diff --git a/packages/app-runtime/src/modules/appEvents/MailReceivedModule.ts b/packages/app-runtime/src/modules/appEvents/MailReceivedModule.ts index 601d8bb7e..480bc08d8 100644 --- a/packages/app-runtime/src/modules/appEvents/MailReceivedModule.ts +++ b/packages/app-runtime/src/modules/appEvents/MailReceivedModule.ts @@ -23,7 +23,8 @@ export class MailReceivedModule extends AppRuntimeModule { - await (await this.runtime.uiBridge()).showMessage(session.account, sender, mail); + const uiBridge = await this.runtime.uiBridge(); + await uiBridge.showMessage(session.account, sender, mail); } }); } diff --git a/packages/app-runtime/test/customMatchers.ts b/packages/app-runtime/test/customMatchers.ts new file mode 100644 index 000000000..dbae8a10b --- /dev/null +++ b/packages/app-runtime/test/customMatchers.ts @@ -0,0 +1,65 @@ +import { ApplicationError, EventConstructor, Result } from "@js-soft/ts-utils"; +import { MockEventBus } from "./lib"; + +import "./lib/MockUIBridge.matchers"; + +expect.extend({ + toBeSuccessful(actual: Result) { + if (!(actual instanceof Result)) { + return { pass: false, message: () => "expected an instance of Result." }; + } + + return { pass: actual.isSuccess, message: () => `expected a successful result; got an error result with the error message '${actual.error.message}'.` }; + }, + + toBeAnError(actual: Result, expectedMessage: string | RegExp, expectedCode: string | RegExp) { + if (!(actual instanceof Result)) { + return { pass: false, message: () => "expected an instance of Result." }; + } + + if (!actual.isError) { + return { pass: false, message: () => "expected an error result, but it was successful." }; + } + + if (actual.error.message.match(new RegExp(expectedMessage)) === null) { + return { pass: false, message: () => `expected the error message of the result to match '${expectedMessage}', but received '${actual.error.message}'.` }; + } + + if (actual.error.code.match(new RegExp(expectedCode)) === null) { + return { pass: false, message: () => `expected the error code of the result to match '${expectedCode}', but received '${actual.error.code}'.` }; + } + + return { pass: true, message: () => "" }; + }, + + async toHavePublished(eventBus: unknown, eventConstructor: EventConstructor, eventConditions?: (event: TEvent) => boolean) { + if (!(eventBus instanceof MockEventBus)) { + throw new Error("This method can only be used with expect(MockEventBus)."); + } + + await eventBus.waitForRunningEventHandlers(); + const matchingEvents = eventBus.publishedEvents.filter((x) => x instanceof eventConstructor && (eventConditions?.(x) ?? true)); + if (matchingEvents.length > 0) { + return { + pass: true, + message: () => + `There were one or more events that matched the specified criteria, even though there should be none. The matching events are: ${JSON.stringify( + matchingEvents, + undefined, + 2 + )}` + }; + } + return { pass: false, message: () => `The expected event wasn't published. The published events are: ${JSON.stringify(eventBus.publishedEvents, undefined, 2)}` }; + } +}); + +declare global { + namespace jest { + interface Matchers { + toBeSuccessful(): R; + toBeAnError(expectedMessage: string | RegExp, expectedCode: string | RegExp): R; + toHavePublished(eventConstructor: EventConstructor, eventConditions?: (event: TEvent) => boolean): Promise; + } + } +} diff --git a/packages/app-runtime/test/lib/FakeUIBridge.ts b/packages/app-runtime/test/lib/FakeUIBridge.ts index f9b9e7a6c..f411ab891 100644 --- a/packages/app-runtime/test/lib/FakeUIBridge.ts +++ b/packages/app-runtime/test/lib/FakeUIBridge.ts @@ -29,4 +29,8 @@ export class FakeUIBridge implements IUIBridge { public requestAccountSelection(): Promise> { throw new Error("Method not implemented."); } + + public enterPassword(_passwordType: "pw" | "pin", _pinLength?: number): Promise> { + throw new Error("Method not implemented."); + } } diff --git a/packages/app-runtime/test/lib/MockEventBus.ts b/packages/app-runtime/test/lib/MockEventBus.ts new file mode 100644 index 000000000..685db5efb --- /dev/null +++ b/packages/app-runtime/test/lib/MockEventBus.ts @@ -0,0 +1,86 @@ +import { Event, EventBus, EventEmitter2EventBus, getEventNamespaceFromObject, SubscriptionTarget } from "@js-soft/ts-utils"; + +export class MockEventBus extends EventEmitter2EventBus { + public publishedEvents: Event[] = []; + private publishPromises: Promise[] = []; + private readonly publishPromisesWithName: { promise: Promise; name: string }[] = []; + + public constructor() { + super((_) => { + // no-op + }); + } + + public override publish(event: Event): void { + this.publishedEvents.push(event); + + const namespace = getEventNamespaceFromObject(event); + + if (!namespace) { + throw Error("The event needs a namespace. Use the EventNamespace-decorator in order to define a namespace for an event."); + } + + const promise = this.emitter.emitAsync(namespace, event); + + this.publishPromises.push(promise); + this.publishPromisesWithName.push({ promise: promise, name: namespace }); + } + + public async waitForEvent( + subscriptionTarget: SubscriptionTarget & { namespace: string }, + predicate?: (event: TEvent) => boolean + ): Promise { + const alreadyTriggeredEvents = this.publishedEvents.find( + (e) => + e.namespace === subscriptionTarget.namespace && + (typeof subscriptionTarget === "string" || e instanceof subscriptionTarget) && + (!predicate || predicate(e as TEvent)) + ) as TEvent | undefined; + if (alreadyTriggeredEvents) { + return alreadyTriggeredEvents; + } + + const event = await waitForEvent(this, subscriptionTarget, predicate); + return event; + } + + public async waitForRunningEventHandlers(): Promise { + await Promise.all(this.publishPromises); + } + + public reset(): void { + this.publishedEvents = []; + this.publishPromises = []; + } +} + +async function waitForEvent( + eventBus: EventBus, + subscriptionTarget: SubscriptionTarget, + assertionFunction?: (t: TEvent) => boolean, + timeout = 5000 +): Promise { + let subscriptionId: number; + + const eventPromise = new Promise((resolve) => { + subscriptionId = eventBus.subscribe(subscriptionTarget, (event: TEvent) => { + if (assertionFunction && !assertionFunction(event)) return; + + resolve(event); + }); + }); + if (!timeout) return await eventPromise.finally(() => eventBus.unsubscribe(subscriptionId)); + + let timeoutId: NodeJS.Timeout; + const timeoutPromise = new Promise((_resolve, reject) => { + timeoutId = setTimeout( + () => reject(new Error(`timeout exceeded for waiting for event ${typeof subscriptionTarget === "string" ? subscriptionTarget : subscriptionTarget.name}`)), + timeout + ); + }); + + return await Promise.race([eventPromise, timeoutPromise]).finally(() => { + eventBus.unsubscribe(subscriptionId); + clearTimeout(timeoutId); + }); +} diff --git a/packages/app-runtime/test/lib/MockUIBridge.matchers.ts b/packages/app-runtime/test/lib/MockUIBridge.matchers.ts new file mode 100644 index 000000000..b3720888c --- /dev/null +++ b/packages/app-runtime/test/lib/MockUIBridge.matchers.ts @@ -0,0 +1,122 @@ +import { MockUIBridge } from "./MockUIBridge"; + +expect.extend({ + showDeviceOnboardingCalled(mockUIBridge: unknown, deviceId: string) { + if (!(mockUIBridge instanceof MockUIBridge)) { + throw new Error("This method can only be used with expect(MockUIBridge)."); + } + + const calls = mockUIBridge.calls.filter((x) => x.method === "showDeviceOnboarding"); + if (calls.length === 0) { + return { pass: false, message: () => "The method showDeviceOnboarding was not called." }; + } + + const matchingCalls = calls.filter((x) => x.deviceOnboardingInfo.id === deviceId); + if (matchingCalls.length === 0) { + return { + pass: false, + message: () => + `The method showDeviceOnboarding was called, but not with the specified device id '${deviceId}', instead with ids '${calls.map((e) => e.deviceOnboardingInfo.id).join(", ")}'.` + }; + } + + return { pass: true, message: () => "" }; + }, + showDeviceOnboardingNotCalled(mockUIBridge: unknown) { + if (!(mockUIBridge instanceof MockUIBridge)) { + throw new Error("This method can only be used with expect(MockUIBridge)."); + } + + const calls = mockUIBridge.calls.filter((x) => x.method === "showDeviceOnboarding"); + if (calls.length > 0) { + return { pass: false, message: () => "The method showDeviceOnboarding was called." }; + } + + return { pass: true, message: () => "" }; + }, + requestAccountSelectionCalled(mockUIBridge: unknown, possibleAccountsLength: number) { + if (!(mockUIBridge instanceof MockUIBridge)) { + throw new Error("This method can only be used with expect(MockUIBridge)."); + } + + const calls = mockUIBridge.calls.filter((x) => x.method === "requestAccountSelection"); + if (calls.length === 0) { + return { pass: false, message: () => "The method requestAccountSelection was not called." }; + } + + const matchingCalls = calls.filter((x) => x.possibleAccounts.length === possibleAccountsLength); + if (matchingCalls.length === 0) { + return { + pass: false, + message: () => + `The method requestAccountSelection was called, but not with the specified possible accounts length '${possibleAccountsLength}', instead with lengths '${calls.map((e) => e.possibleAccounts.length).join(", ")}'.` + }; + } + + return { pass: true, message: () => "" }; + }, + requestAccountSelectionNotCalled(mockUIBridge: unknown) { + if (!(mockUIBridge instanceof MockUIBridge)) { + throw new Error("This method can only be used with expect(MockUIBridge)."); + } + + const calls = mockUIBridge.calls.filter((x) => x.method === "requestAccountSelection"); + if (calls.length > 0) { + return { pass: false, message: () => "The method requestAccountSelection was called." }; + } + + return { pass: true, message: () => "" }; + }, + enterPasswordCalled(mockUIBridge: unknown, passwordType: "pw" | "pin", pinLength?: number) { + if (!(mockUIBridge instanceof MockUIBridge)) { + throw new Error("This method can only be used with expect(MockUIBridge)."); + } + + const calls = mockUIBridge.calls.filter((x) => x.method === "enterPassword"); + if (calls.length === 0) { + return { pass: false, message: () => "The method enterPassword was not called." }; + } + + const matchingCalls = calls.filter((x) => x.passwordType === passwordType && x.pinLength === pinLength); + if (matchingCalls.length === 0) { + const parameters = calls + .map((e) => { + return { passwordType: e.passwordType, pinLength: e.pinLength }; + }) + .join(", "); + + return { + pass: false, + message: () => + `The method enterPassword was called, but not with the specified password type '${passwordType}' and pin length '${pinLength}', instead with parameters '${parameters}'.` + }; + } + + return { pass: true, message: () => "" }; + }, + enterPasswordNotCalled(mockUIBridge: unknown) { + if (!(mockUIBridge instanceof MockUIBridge)) { + throw new Error("This method can only be used with expect(MockUIBridge)."); + } + + const calls = mockUIBridge.calls.filter((x) => x.method === "enterPassword"); + if (calls.length > 0) { + return { pass: false, message: () => "The method enterPassword was called." }; + } + + return { pass: true, message: () => "" }; + } +}); + +declare global { + namespace jest { + interface Matchers { + showDeviceOnboardingCalled(deviceId: string): R; + showDeviceOnboardingNotCalled(): R; + requestAccountSelectionCalled(possibleAccountsLength: number): R; + requestAccountSelectionNotCalled(): R; + enterPasswordCalled(passwordType: "pw" | "pin", pinLength?: number): R; + enterPasswordNotCalled(): R; + } + } +} diff --git a/packages/app-runtime/test/lib/MockUIBridge.ts b/packages/app-runtime/test/lib/MockUIBridge.ts new file mode 100644 index 000000000..5e9d40982 --- /dev/null +++ b/packages/app-runtime/test/lib/MockUIBridge.ts @@ -0,0 +1,92 @@ +import { ApplicationError, Result } from "@js-soft/ts-utils"; +import { DeviceOnboardingInfoDTO, FileDVO, IdentityDVO, LocalRequestDVO, MailDVO, MessageDVO, RequestMessageDVO } from "@nmshd/runtime"; +import { IUIBridge, LocalAccountDTO, UserfriendlyApplicationError } from "../../src"; + +export type MockUIBridgeCall = + | { method: "showMessage"; account: LocalAccountDTO; relationship: IdentityDVO; message: MessageDVO | MailDVO | RequestMessageDVO } + | { method: "showRelationship"; account: LocalAccountDTO; relationship: IdentityDVO } + | { method: "showFile"; account: LocalAccountDTO; file: FileDVO } + | { method: "showDeviceOnboarding"; deviceOnboardingInfo: DeviceOnboardingInfoDTO } + | { method: "showRequest"; account: LocalAccountDTO; request: LocalRequestDVO } + | { method: "showError"; error: UserfriendlyApplicationError; account?: LocalAccountDTO } + | { method: "requestAccountSelection"; possibleAccounts: LocalAccountDTO[]; title?: string; description?: string } + | { method: "enterPassword"; passwordType: "pw" | "pin"; pinLength?: number }; + +export class MockUIBridge implements IUIBridge { + private _accountIdToReturn: string | undefined; + public set accountIdToReturn(value: string | undefined) { + this._accountIdToReturn = value; + } + + private _passwordToReturn: string | undefined; + public set passwordToReturn(value: string | undefined) { + this._passwordToReturn = value; + } + + private _calls: MockUIBridgeCall[] = []; + public get calls(): MockUIBridgeCall[] { + return this._calls; + } + + public reset(): void { + this._passwordToReturn = undefined; + this._accountIdToReturn = undefined; + + this._calls = []; + } + + public showMessage(_account: LocalAccountDTO, _relationship: IdentityDVO, _message: MessageDVO | MailDVO | RequestMessageDVO): Promise> { + this._calls.push({ method: "showMessage", account: _account, relationship: _relationship, message: _message }); + + return Promise.resolve(Result.ok(undefined)); + } + + public showRelationship(account: LocalAccountDTO, relationship: IdentityDVO): Promise> { + this._calls.push({ method: "showRelationship", account, relationship }); + + return Promise.resolve(Result.ok(undefined)); + } + + public showFile(account: LocalAccountDTO, file: FileDVO): Promise> { + this._calls.push({ method: "showFile", account, file }); + + return Promise.resolve(Result.ok(undefined)); + } + + public showDeviceOnboarding(deviceOnboardingInfo: DeviceOnboardingInfoDTO): Promise> { + this._calls.push({ method: "showDeviceOnboarding", deviceOnboardingInfo }); + + return Promise.resolve(Result.ok(undefined)); + } + + public showRequest(account: LocalAccountDTO, request: LocalRequestDVO): Promise> { + this._calls.push({ method: "showRequest", account, request }); + + return Promise.resolve(Result.ok(undefined)); + } + + public showError(error: UserfriendlyApplicationError, account?: LocalAccountDTO): Promise> { + this._calls.push({ method: "showError", error, account }); + + return Promise.resolve(Result.ok(undefined)); + } + + public requestAccountSelection(possibleAccounts: LocalAccountDTO[], title?: string, description?: string): Promise> { + this._calls.push({ method: "requestAccountSelection", possibleAccounts, title, description }); + + if (!this._accountIdToReturn) return Promise.resolve(Result.fail(new ApplicationError("code", "message"))); + + const foundAccount = possibleAccounts.find((x) => x.id === this._accountIdToReturn); + if (!foundAccount) return Promise.resolve(Result.fail(new ApplicationError("code", "message"))); + + return Promise.resolve(Result.ok(foundAccount)); + } + + public enterPassword(passwordType: "pw" | "pin", pinLength?: number): Promise> { + this._calls.push({ method: "enterPassword", passwordType, pinLength }); + + if (!this._passwordToReturn) return Promise.resolve(Result.fail(new ApplicationError("code", "message"))); + + return Promise.resolve(Result.ok(this._passwordToReturn)); + } +} diff --git a/packages/app-runtime/test/lib/TestUtil.ts b/packages/app-runtime/test/lib/TestUtil.ts index 6b8b8f0e2..aca739d1c 100644 --- a/packages/app-runtime/test/lib/TestUtil.ts +++ b/packages/app-runtime/test/lib/TestUtil.ts @@ -1,7 +1,7 @@ /* eslint-disable jest/no-standalone-expect */ import { ILoggerFactory } from "@js-soft/logging-abstractions"; import { SimpleLoggerFactory } from "@js-soft/simple-logger"; -import { Result, sleep, SubscriptionTarget } from "@js-soft/ts-utils"; +import { EventBus, Result, sleep, SubscriptionTarget } from "@js-soft/ts-utils"; import { ArbitraryMessageContent, ArbitraryRelationshipCreationContent, ArbitraryRelationshipTemplateContent } from "@nmshd/content"; import { CoreDate } from "@nmshd/core-types"; import { @@ -16,18 +16,18 @@ import { } from "@nmshd/runtime"; import { IConfigOverwrite, TransportLoggerFactory } from "@nmshd/transport"; import { LogLevel } from "typescript-logging"; -import { AppConfig, AppRuntime, LocalAccountDTO, LocalAccountSession, createAppConfig as runtime_createAppConfig } from "../../src"; +import { AppConfig, AppRuntime, IUIBridge, LocalAccountDTO, LocalAccountSession, createAppConfig as runtime_createAppConfig } from "../../src"; import { FakeUIBridge } from "./FakeUIBridge"; import { FakeNativeBootstrapper } from "./natives/FakeNativeBootstrapper"; export class TestUtil { - public static async createRuntime(configOverride?: any): Promise { + public static async createRuntime(configOverride?: any, uiBridge: IUIBridge = new FakeUIBridge(), eventBus?: EventBus): Promise { const config = this.createAppConfig(configOverride); const nativeBootstrapper = new FakeNativeBootstrapper(); await nativeBootstrapper.init(); - const runtime = await AppRuntime.create(nativeBootstrapper, config); - runtime.registerUIBridge(new FakeUIBridge()); + const runtime = await AppRuntime.create(nativeBootstrapper, config, eventBus); + runtime.registerUIBridge(uiBridge); return runtime; } diff --git a/packages/app-runtime/test/lib/index.ts b/packages/app-runtime/test/lib/index.ts index e2e0ce821..b714eec25 100644 --- a/packages/app-runtime/test/lib/index.ts +++ b/packages/app-runtime/test/lib/index.ts @@ -1,3 +1,5 @@ export * from "./EventListener"; export * from "./FakeUIBridge"; +export * from "./MockEventBus"; +export * from "./MockUIBridge"; export * from "./TestUtil"; diff --git a/packages/app-runtime/test/runtime/AppStringProcessor.test.ts b/packages/app-runtime/test/runtime/AppStringProcessor.test.ts index 2f1c1414f..9055b1d69 100644 --- a/packages/app-runtime/test/runtime/AppStringProcessor.test.ts +++ b/packages/app-runtime/test/runtime/AppStringProcessor.test.ts @@ -1,23 +1,199 @@ -import { AppRuntime } from "../../src"; -import { TestUtil } from "../lib"; +import { ArbitraryRelationshipTemplateContentJSON } from "@nmshd/content"; +import { CoreDate } from "@nmshd/core-types"; +import { PeerRelationshipTemplateLoadedEvent } from "@nmshd/runtime"; +import assert from "assert"; +import { AppRuntime, LocalAccountSession } from "../../src"; +import { MockEventBus, MockUIBridge, TestUtil } from "../lib"; describe("AppStringProcessor", function () { - let runtime: AppRuntime; + const mockUiBridge = new MockUIBridge(); + const eventBus = new MockEventBus(); + + let runtime1: AppRuntime; + let runtime1Session: LocalAccountSession; + + let runtime2: AppRuntime; + let runtime2SessionA: LocalAccountSession; + + const templateContent: ArbitraryRelationshipTemplateContentJSON = { "@type": "ArbitraryRelationshipTemplateContent", value: "value" }; beforeAll(async function () { - runtime = await TestUtil.createRuntime(); + runtime1 = await TestUtil.createRuntime(); + await runtime1.start(); + + const account = await TestUtil.provideAccounts(runtime1, 1); + runtime1Session = await runtime1.selectAccount(account[0].id); + + runtime2 = await TestUtil.createRuntime(undefined, mockUiBridge, eventBus); + await runtime2.start(); + + const accounts = await TestUtil.provideAccounts(runtime2, 2); + runtime2SessionA = await runtime2.selectAccount(accounts[0].id); + + // second account to make sure everything works with multiple accounts + await runtime2.selectAccount(accounts[1].id); }); afterAll(async function () { - await runtime.stop(); + await runtime1.stop(); + await runtime2.stop(); }); - test("should process a URL", async function () { - const account = await runtime.accountServices.createAccount(Math.random().toString(36).substring(7)); + afterEach(function () { + mockUiBridge.reset(); + }); - const result = await runtime.stringProcessor.processURL("nmshd://qr#", account); + test("should process a URL", async function () { + const result = await runtime1.stringProcessor.processURL("nmshd://qr#", runtime1Session.account); expect(result.isError).toBeDefined(); expect(result.error.code).toBe("error.appStringProcessor.truncatedReferenceInvalid"); + + expect(mockUiBridge).enterPasswordNotCalled(); + expect(mockUiBridge).requestAccountSelectionNotCalled(); + }); + + test("should properly handle a personalized RelationshipTemplate with the correct Identity available", async function () { + const runtime2SessionAAddress = runtime2SessionA.account.address!; + assert(runtime2SessionAAddress); + + const templateResult = await runtime1Session.transportServices.relationshipTemplates.createOwnRelationshipTemplate({ + content: templateContent, + expiresAt: CoreDate.utc().add({ days: 1 }).toISOString(), + forIdentity: runtime2SessionAAddress + }); + + const result = await runtime2.stringProcessor.processTruncatedReference(templateResult.value.truncatedReference); + expect(result).toBeSuccessful(); + + await expect(eventBus).toHavePublished(PeerRelationshipTemplateLoadedEvent); + + expect(mockUiBridge).enterPasswordNotCalled(); + expect(mockUiBridge).requestAccountSelectionNotCalled(); + }); + + test("should properly handle a personalized RelationshipTemplate with the correct Identity not available", async function () { + const runtime1SessionAddress = runtime1Session.account.address!; + assert(runtime1SessionAddress); + + const templateResult = await runtime1Session.transportServices.relationshipTemplates.createOwnRelationshipTemplate({ + content: templateContent, + expiresAt: CoreDate.utc().add({ days: 1 }).toISOString(), + forIdentity: runtime1SessionAddress + }); + + const result = await runtime2.stringProcessor.processTruncatedReference(templateResult.value.truncatedReference); + expect(result).toBeAnError("There is no account matching the given 'forIdentityTruncated'.", "error.appruntime.general.noAccountAvailableForIdentityTruncated"); + + expect(mockUiBridge).enterPasswordNotCalled(); + expect(mockUiBridge).requestAccountSelectionNotCalled(); + }); + + test("should properly handle a password protected RelationshipTemplate", async function () { + const templateResult = await runtime1Session.transportServices.relationshipTemplates.createOwnRelationshipTemplate({ + content: templateContent, + expiresAt: CoreDate.utc().add({ days: 1 }).toISOString(), + passwordProtection: { password: "password" } + }); + + mockUiBridge.passwordToReturn = "password"; + mockUiBridge.accountIdToReturn = runtime2SessionA.account.id; + + const result = await runtime2.stringProcessor.processTruncatedReference(templateResult.value.truncatedReference); + expect(result).toBeSuccessful(); + expect(result.value).toBeUndefined(); + + await expect(eventBus).toHavePublished(PeerRelationshipTemplateLoadedEvent); + + expect(mockUiBridge).enterPasswordCalled("pw"); + expect(mockUiBridge).requestAccountSelectionCalled(2); + }); + + test("should properly handle a pin protected RelationshipTemplate", async function () { + const templateResult = await runtime1Session.transportServices.relationshipTemplates.createOwnRelationshipTemplate({ + content: templateContent, + expiresAt: CoreDate.utc().add({ days: 1 }).toISOString(), + passwordProtection: { password: "000000", passwordIsPin: true } + }); + + mockUiBridge.passwordToReturn = "000000"; + mockUiBridge.accountIdToReturn = runtime2SessionA.account.id; + + const result = await runtime2.stringProcessor.processTruncatedReference(templateResult.value.truncatedReference); + expect(result).toBeSuccessful(); + expect(result.value).toBeUndefined(); + + await expect(eventBus).toHavePublished(PeerRelationshipTemplateLoadedEvent); + + expect(mockUiBridge).enterPasswordCalled("pin", 6); + expect(mockUiBridge).requestAccountSelectionCalled(2); + }); + + test("should properly handle a password protected personalized RelationshipTemplate", async function () { + const templateResult = await runtime1Session.transportServices.relationshipTemplates.createOwnRelationshipTemplate({ + content: templateContent, + expiresAt: CoreDate.utc().add({ days: 1 }).toISOString(), + passwordProtection: { password: "password" }, + forIdentity: runtime2SessionA.account.address! + }); + + mockUiBridge.passwordToReturn = "password"; + + const result = await runtime2.stringProcessor.processTruncatedReference(templateResult.value.truncatedReference); + expect(result).toBeSuccessful(); + expect(result.value).toBeUndefined(); + + await expect(eventBus).toHavePublished(PeerRelationshipTemplateLoadedEvent); + + expect(mockUiBridge).enterPasswordCalled("pw"); + expect(mockUiBridge).requestAccountSelectionNotCalled(); + }); + + test("should properly handle a pin protected personalized RelationshipTemplate", async function () { + const templateResult = await runtime1Session.transportServices.relationshipTemplates.createOwnRelationshipTemplate({ + content: templateContent, + expiresAt: CoreDate.utc().add({ days: 1 }).toISOString(), + passwordProtection: { password: "000000", passwordIsPin: true }, + forIdentity: runtime2SessionA.account.address! + }); + + mockUiBridge.passwordToReturn = "000000"; + + const result = await runtime2.stringProcessor.processTruncatedReference(templateResult.value.truncatedReference); + expect(result).toBeSuccessful(); + expect(result.value).toBeUndefined(); + + await expect(eventBus).toHavePublished(PeerRelationshipTemplateLoadedEvent); + + expect(mockUiBridge).enterPasswordCalled("pin", 6); + expect(mockUiBridge).requestAccountSelectionNotCalled(); + }); + + describe("onboarding", function () { + let runtime3: AppRuntime; + const runtime3MockUiBridge = new MockUIBridge(); + + beforeAll(async function () { + runtime3 = await TestUtil.createRuntime(undefined, runtime3MockUiBridge, eventBus); + await runtime3.start(); + }); + + afterAll(async () => await runtime3.stop()); + + test("device onboarding with a password protected Token", async function () { + const deviceResult = await runtime1Session.transportServices.devices.createDevice({}); + const tokenResult = await runtime1Session.transportServices.devices.getDeviceOnboardingToken({ + id: deviceResult.value.id, + passwordProtection: { password: "password" } + }); + + mockUiBridge.passwordToReturn = "password"; + + const result = await runtime2.stringProcessor.processTruncatedReference(tokenResult.value.truncatedReference); + expect(result).toBeSuccessful(); + expect(result.value).toBeUndefined(); + + expect(mockUiBridge).showDeviceOnboardingCalled(deviceResult.value.id); + }); }); }); diff --git a/packages/runtime/src/useCases/common/Schemas.ts b/packages/runtime/src/useCases/common/Schemas.ts index 7dec71699..5fa6ec0c5 100644 --- a/packages/runtime/src/useCases/common/Schemas.ts +++ b/packages/runtime/src/useCases/common/Schemas.ts @@ -20933,6 +20933,22 @@ export const CreateDeviceOnboardingTokenRequest: any = { }, "profileName": { "type": "string" + }, + "passwordProtection": { + "type": "object", + "properties": { + "password": { + "type": "string" + }, + "passwordIsPin": { + "type": "boolean", + "const": true + } + }, + "required": [ + "password" + ], + "additionalProperties": false } }, "required": [ diff --git a/packages/runtime/src/useCases/transport/devices/CreateDeviceOnboardingToken.ts b/packages/runtime/src/useCases/transport/devices/CreateDeviceOnboardingToken.ts index 4235e3ca0..0fcfc1bc4 100644 --- a/packages/runtime/src/useCases/transport/devices/CreateDeviceOnboardingToken.ts +++ b/packages/runtime/src/useCases/transport/devices/CreateDeviceOnboardingToken.ts @@ -1,18 +1,22 @@ import { Result } from "@js-soft/ts-utils"; import { CoreDate, CoreId } from "@nmshd/core-types"; -import { DevicesController, TokenContentDeviceSharedSecret, TokenController } from "@nmshd/transport"; +import { DevicesController, PasswordProtectionCreationParameters, TokenContentDeviceSharedSecret, TokenController } from "@nmshd/transport"; import { Inject } from "@nmshd/typescript-ioc"; import { TokenDTO } from "../../../types"; -import { DeviceIdString, ISO8601DateTimeString, SchemaRepository, SchemaValidator, UseCase } from "../../common"; +import { DeviceIdString, ISO8601DateTimeString, SchemaRepository, TokenAndTemplateCreationValidator, UseCase } from "../../common"; import { TokenMapper } from "../tokens/TokenMapper"; export interface CreateDeviceOnboardingTokenRequest { id: DeviceIdString; expiresAt?: ISO8601DateTimeString; profileName?: string; + passwordProtection?: { + password: string; + passwordIsPin?: true; + }; } -class Validator extends SchemaValidator { +class Validator extends TokenAndTemplateCreationValidator { public constructor(@Inject schemaRepository: SchemaRepository) { super(schemaRepository.getSchema("CreateDeviceOnboardingTokenRequest")); } @@ -35,7 +39,8 @@ export class CreateDeviceOnboardingTokenUseCase extends UseCase (v.buffer.byteLength === 16 ? undefined : "must be 16 bytes long") }) @serialize() diff --git a/packages/transport/src/core/types/PasswordProtectionCreationParameters.ts b/packages/transport/src/core/types/PasswordProtectionCreationParameters.ts index 5af7adc42..0780709f6 100644 --- a/packages/transport/src/core/types/PasswordProtectionCreationParameters.ts +++ b/packages/transport/src/core/types/PasswordProtectionCreationParameters.ts @@ -1,14 +1,14 @@ import { ISerializable, Serializable, serialize, validate } from "@js-soft/ts-serval"; export interface IPasswordProtectionCreationParameters extends ISerializable { - passwordType: string; + passwordType: "pw" | `pin${number}`; password: string; } export class PasswordProtectionCreationParameters extends Serializable implements IPasswordProtectionCreationParameters { @validate({ regExp: /^(pw|pin(4|5|6|7|8|9|10|11|12|13|14|15|16))$/ }) @serialize() - public passwordType: string; + public passwordType: "pw" | `pin${number}`; @validate({ min: 1 }) @serialize() diff --git a/packages/transport/src/core/types/SharedPasswordProtection.ts b/packages/transport/src/core/types/SharedPasswordProtection.ts index 629a3eecd..cd8267bff 100644 --- a/packages/transport/src/core/types/SharedPasswordProtection.ts +++ b/packages/transport/src/core/types/SharedPasswordProtection.ts @@ -3,14 +3,14 @@ import { CoreBuffer, ICoreBuffer } from "@nmshd/crypto"; import { TransportCoreErrors } from "../TransportCoreErrors"; export interface ISharedPasswordProtection extends ISerializable { - passwordType: string; + passwordType: "pw" | `pin${number}`; salt: ICoreBuffer; } export class SharedPasswordProtection extends Serializable implements ISharedPasswordProtection { @validate({ regExp: /^(pw|pin(4|5|6|7|8|9|10|11|12|13|14|15|16))$/ }) @serialize() - public passwordType: string; + public passwordType: "pw" | `pin${number}`; @validate({ customValidator: (v: ICoreBuffer) => (v.buffer.byteLength === 16 ? undefined : "must be 16 bytes long") }) @serialize() @@ -28,7 +28,7 @@ export class SharedPasswordProtection extends Serializable implements ISharedPas throw TransportCoreErrors.general.invalidTruncatedReference("The password part of a TruncatedReference must consist of exactly 2 components."); } - const passwordType = splittedPasswordParts[0]; + const passwordType = splittedPasswordParts[0] as "pw" | `pin${number}`; try { const salt = CoreBuffer.fromBase64(splittedPasswordParts[1]); return SharedPasswordProtection.from({ passwordType, salt }); diff --git a/packages/transport/test/modules/files/FileReference.test.ts b/packages/transport/test/modules/files/FileReference.test.ts index 04e23d1ea..e74b52be7 100644 --- a/packages/transport/test/modules/files/FileReference.test.ts +++ b/packages/transport/test/modules/files/FileReference.test.ts @@ -208,7 +208,7 @@ describe("FileReference", function () { key: await CryptoEncryption.generateKey(), id: await BackboneIds.file.generateUnsafe(), passwordProtection: { - passwordType: "pc", + passwordType: "pc" as any, salt: await CoreCrypto.random(16) } }); diff --git a/packages/transport/test/modules/relationshipTemplates/RelationshipTemplateReference.test.ts b/packages/transport/test/modules/relationshipTemplates/RelationshipTemplateReference.test.ts index 416e5d64f..ed6c2526c 100644 --- a/packages/transport/test/modules/relationshipTemplates/RelationshipTemplateReference.test.ts +++ b/packages/transport/test/modules/relationshipTemplates/RelationshipTemplateReference.test.ts @@ -208,7 +208,7 @@ describe("RelationshipTemplateReference", function () { key: await CryptoEncryption.generateKey(), id: await BackboneIds.relationshipTemplate.generateUnsafe(), passwordProtection: { - passwordType: "pc", + passwordType: "pc" as any, salt: await CoreCrypto.random(16) } }); diff --git a/packages/transport/test/modules/tokens/TokenContent.test.ts b/packages/transport/test/modules/tokens/TokenContent.test.ts index c4c56f6c1..2b1039547 100644 --- a/packages/transport/test/modules/tokens/TokenContent.test.ts +++ b/packages/transport/test/modules/tokens/TokenContent.test.ts @@ -198,7 +198,7 @@ describe("TokenContent", function () { secretKey: await CryptoEncryption.generateKey(), templateId: await CoreIdHelper.notPrefixed.generate(), passwordProtection: { - passwordType: "pc", + passwordType: "pc" as any, salt: await CoreCrypto.random(16) } }); diff --git a/packages/transport/test/modules/tokens/TokenReference.test.ts b/packages/transport/test/modules/tokens/TokenReference.test.ts index 1396f98c3..3ce4de017 100644 --- a/packages/transport/test/modules/tokens/TokenReference.test.ts +++ b/packages/transport/test/modules/tokens/TokenReference.test.ts @@ -208,7 +208,7 @@ describe("TokenReference", function () { key: await CryptoEncryption.generateKey(), id: await BackboneIds.token.generateUnsafe(), passwordProtection: { - passwordType: "pc", + passwordType: "pc" as any, salt: await CoreCrypto.random(16) } }); From 94bccd41745d7a389822b792461d7528a21ff5a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julian=20K=C3=B6nig?= <33655937+jkoenig134@users.noreply.github.com> Date: Mon, 2 Dec 2024 18:08:53 +0100 Subject: [PATCH 17/21] Simplify the PushNotificationModuleTest (#354) * chore: make native eventbus configurable * test: simplify PushNotificationModuleTest * fix: commit stuff --- packages/app-runtime/test/lib/TestUtil.ts | 2 +- .../lib/natives/FakeNativeBootstrapper.ts | 12 ++++++++---- .../test/modules/PushNotification.test.ts | 19 +++++++++---------- 3 files changed, 18 insertions(+), 15 deletions(-) diff --git a/packages/app-runtime/test/lib/TestUtil.ts b/packages/app-runtime/test/lib/TestUtil.ts index aca739d1c..2cebf2259 100644 --- a/packages/app-runtime/test/lib/TestUtil.ts +++ b/packages/app-runtime/test/lib/TestUtil.ts @@ -24,7 +24,7 @@ export class TestUtil { public static async createRuntime(configOverride?: any, uiBridge: IUIBridge = new FakeUIBridge(), eventBus?: EventBus): Promise { const config = this.createAppConfig(configOverride); - const nativeBootstrapper = new FakeNativeBootstrapper(); + const nativeBootstrapper = new FakeNativeBootstrapper(eventBus); await nativeBootstrapper.init(); const runtime = await AppRuntime.create(nativeBootstrapper, config, eventBus); runtime.registerUIBridge(uiBridge); diff --git a/packages/app-runtime/test/lib/natives/FakeNativeBootstrapper.ts b/packages/app-runtime/test/lib/natives/FakeNativeBootstrapper.ts index bf2580247..4b93b17d0 100644 --- a/packages/app-runtime/test/lib/natives/FakeNativeBootstrapper.ts +++ b/packages/app-runtime/test/lib/natives/FakeNativeBootstrapper.ts @@ -1,4 +1,4 @@ -import { EventEmitter2EventBus, Result } from "@js-soft/ts-utils"; +import { EventBus, EventEmitter2EventBus, Result } from "@js-soft/ts-utils"; import { WebLoggerFactory } from "@js-soft/web-logger"; import { INativeBootstrapper, INativeEnvironment } from "../../../src"; import { FakeNativeConfigAccess } from "./FakeNativeConfigAccess"; @@ -7,6 +7,8 @@ import { FakeNativeDeviceInfoAccess } from "./FakeNativeDeviceInfoAccess"; import { FakeNativeNotificationAccess } from "./FakeNativeNotificationAccess"; export class FakeNativeBootstrapper implements INativeBootstrapper { + public constructor(private readonly eventBus?: EventBus) {} + private _nativeEnvironment: INativeEnvironment; public get nativeEnvironment(): INativeEnvironment { return this._nativeEnvironment; @@ -24,9 +26,11 @@ export class FakeNativeBootstrapper implements INativeBootstrapper { configAccess: new FakeNativeConfigAccess(), databaseFactory: new FakeNativeDatabaseFactory(), deviceInfoAccess: new FakeNativeDeviceInfoAccess(), - eventBus: new EventEmitter2EventBus(() => { - // noop - }), + eventBus: + this.eventBus ?? + new EventEmitter2EventBus(() => { + // noop + }), loggerFactory, notificationAccess: new FakeNativeNotificationAccess(nativeLogger) }; diff --git a/packages/app-runtime/test/modules/PushNotification.test.ts b/packages/app-runtime/test/modules/PushNotification.test.ts index 329759aca..bde22218f 100644 --- a/packages/app-runtime/test/modules/PushNotification.test.ts +++ b/packages/app-runtime/test/modules/PushNotification.test.ts @@ -1,15 +1,16 @@ -import { sleep } from "@js-soft/ts-utils"; import { DatawalletSynchronizedEvent } from "@nmshd/runtime"; import { AppRuntime, ExternalEventReceivedEvent, LocalAccountSession, RemoteNotificationEvent, RemoteNotificationRegistrationEvent } from "../../src"; -import { TestUtil } from "../lib"; +import { MockEventBus, TestUtil } from "../lib"; describe("PushNotificationModuleTest", function () { + const eventBus = new MockEventBus(); + let runtime: AppRuntime; let session: LocalAccountSession; let devicePushIdentifier = "dummy value"; beforeAll(async function () { - runtime = await TestUtil.createRuntime(); + runtime = await TestUtil.createRuntime(undefined, undefined, eventBus); await runtime.start(); const accounts = await TestUtil.provideAccounts(runtime, 1); @@ -20,12 +21,12 @@ describe("PushNotificationModuleTest", function () { await runtime.stop(); }); + afterEach(() => eventBus.reset()); + test("should persist push identifier", async function () { runtime.nativeEnvironment.eventBus.publish(new RemoteNotificationRegistrationEvent("handleLongerThan10Characters")); - // wait for the registration to finish - // there is no event to wait for, so we just wait for a second - await sleep(1000); + await eventBus.waitForRunningEventHandlers(); const account = await runtime.accountServices.getAccount(session.account.id); expect(account.devicePushIdentifier).toBeDefined(); @@ -45,8 +46,7 @@ describe("PushNotificationModuleTest", function () { }) ); - const event = await TestUtil.awaitEvent(runtime, DatawalletSynchronizedEvent); - expect(event).toBeDefined(); + await expect(eventBus).toHavePublished(DatawalletSynchronizedEvent); }); test("should do a sync everything when ExternalEventCreated is received", async function () { @@ -61,7 +61,6 @@ describe("PushNotificationModuleTest", function () { }) ); - const event = await TestUtil.awaitEvent(runtime, ExternalEventReceivedEvent); - expect(event).toBeDefined(); + await expect(eventBus).toHavePublished(ExternalEventReceivedEvent); }); }); From 67182039c984be16bb3ba3915dc5b2fc6ae28455 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 3 Dec 2024 07:17:27 +0000 Subject: [PATCH 18/21] Chore(deps): bump the update-npm-dependencies group with 3 updates (#355) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Chore(deps): bump the update-npm-dependencies group with 3 updates Bumps the update-npm-dependencies group with 3 updates: [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node), [prettier](https://github.com/prettier/prettier) and [axios](https://github.com/axios/axios). Updates `@types/node` from 22.9.3 to 22.10.1 - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node) Updates `prettier` from 3.3.3 to 3.4.1 - [Release notes](https://github.com/prettier/prettier/releases) - [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md) - [Commits](https://github.com/prettier/prettier/compare/3.3.3...3.4.1) Updates `axios` from 1.7.7 to 1.7.8 - [Release notes](https://github.com/axios/axios/releases) - [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md) - [Commits](https://github.com/axios/axios/compare/v1.7.7...v1.7.8) --- updated-dependencies: - dependency-name: "@types/node" dependency-type: direct:development update-type: version-update:semver-minor dependency-group: update-npm-dependencies - dependency-name: prettier dependency-type: direct:development update-type: version-update:semver-minor dependency-group: update-npm-dependencies - dependency-name: axios dependency-type: direct:production update-type: version-update:semver-patch dependency-group: update-npm-dependencies ... Signed-off-by: dependabot[bot] * chore: prettier fmt --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Julian König --- .github/PULL_REQUEST_TEMPLATE.md | 8 +++--- README_dev.md | 48 ++++++++++++++++---------------- package-lock.json | 37 +++++++++++++----------- package.json | 4 +-- packages/transport/package.json | 2 +- 5 files changed, 51 insertions(+), 48 deletions(-) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index caac36d64..a7e29d20f 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,8 +1,8 @@ # Readiness checklist -- [ ] I added/updated tests. -- [ ] I ensured that the PR title is good enough for the changelog. -- [ ] I labeled the PR. -- [ ] I self-reviewed the PR. +- [ ] I added/updated tests. +- [ ] I ensured that the PR title is good enough for the changelog. +- [ ] I labeled the PR. +- [ ] I self-reviewed the PR. diff --git a/README_dev.md b/README_dev.md index 5db82fb89..c4097b106 100644 --- a/README_dev.md +++ b/README_dev.md @@ -8,11 +8,11 @@ This workspace provides two types of scripts: -- root level scripts (like lints): +- root level scripts (like lints): These can be run by `npm run ` from the root of the project. -- package level scripts (like tests): +- package level scripts (like tests): These can be run by `npm run ` from the package directory or by `npm run -w packages/ ` from the root of the project. @@ -20,11 +20,11 @@ This workspace provides two types of scripts: ## Linting -- TypeScript: - - Each package provides its own TypeScript linting script. You can run it by `npm run lint:tsc` from the package directory or by `npm run -w packages/ lint:tsc` from the root of the project. - - The root project also provides a script that lints all packages. You can run it by `npm run lint:tsc` from the root of the project. -- Prettier: Run `npm run lint:prettier` from the root of the project. -- ESLint: Run `npm run lint:eslint` from the root of the project. +- TypeScript: + - Each package provides its own TypeScript linting script. You can run it by `npm run lint:tsc` from the package directory or by `npm run -w packages/ lint:tsc` from the root of the project. + - The root project also provides a script that lints all packages. You can run it by `npm run lint:tsc` from the root of the project. +- Prettier: Run `npm run lint:prettier` from the root of the project. +- ESLint: Run `npm run lint:eslint` from the root of the project. ## Check for outdated dependencies @@ -36,9 +36,9 @@ To check for outdated dependencies, run `npm run outdated`. This will check the Set the following environment variables: -- `NMSHD_TEST_BASEURL` (the Backbone baseUrl to test against) -- `NMSHD_TEST_CLIENTID` (the Backbone clientId for the configured baseUrl) -- `NMSHD_TEST_CLIENTSECRET` (the Backbone clientSecret for the configured baseUrl) +- `NMSHD_TEST_BASEURL` (the Backbone baseUrl to test against) +- `NMSHD_TEST_CLIENTID` (the Backbone clientId for the configured baseUrl) +- `NMSHD_TEST_CLIENTSECRET` (the Backbone clientSecret for the configured baseUrl) > We recommend to persist these variables for example in your `.bashrc` / `.zshrc` or in the Windows environment variables. @@ -52,17 +52,17 @@ npm run start:backbone Set the following environment variables: -- `NMSHD_TEST_BASEURL` to `http://localhost:8090` -- `NMSHD_TEST_CLIENTID` to `test` -- `NMSHD_TEST_CLIENTSECRET` to `test` +- `NMSHD_TEST_BASEURL` to `http://localhost:8090` +- `NMSHD_TEST_CLIENTID` to `test` +- `NMSHD_TEST_CLIENTSECRET` to `test` > We recommend to persist these variables for example in your `.bashrc` / `.zshrc` or in the Windows environment variables. ### Running the tests -- a specific database: `npm run test:local:[mongodb|lokijs|ferretdb]` -- a specific test suite: `npm run test:local:[mongodb|lokijs|ferretdb] -- testSuiteName` -- with code coverage: `npm run test:local:[mongodb|lokijs|ferretdb] -- --coverage`, a result summary is written to the console, a detailed report is accessible via the path `coverage/lcov-report/index.html` +- a specific database: `npm run test:local:[mongodb|lokijs|ferretdb]` +- a specific test suite: `npm run test:local:[mongodb|lokijs|ferretdb] -- testSuiteName` +- with code coverage: `npm run test:local:[mongodb|lokijs|ferretdb] -- --coverage`, a result summary is written to the console, a detailed report is accessible via the path `coverage/lcov-report/index.html` ### Teardown @@ -74,15 +74,15 @@ After testing on MongoDB or FerretDB, you can run `npm run test:local:teardown` Examples: -- Doesn't build at all -- Module not found -- Something is undefined which should not be undefined (e.g. "TypeError: Cannot read property 'from_base64' of undefined") +- Doesn't build at all +- Module not found +- Something is undefined which should not be undefined (e.g. "TypeError: Cannot read property 'from_base64' of undefined") Solutions: -- Check if you have got absolute `"src/"` or `"/"` includes somewhere and change them to relative ones (`"../"`). -- Check if you have a cyclic reference somewhere (sometimes quite hard to find). In general, no class should include something from the root's `index.ts` export (looks like `import * from "../../"`). -- Check if you have `"/dist"` as suffix for includes (e.g. `"@nmshd/crypto/dist"`). This usually works fine within NodeJS, however Webpack (Browser Build) has some issues therein, resulting e.g. in the crypto lib being copied into the transport lib. It should be fixed, but you never know... +- Check if you have got absolute `"src/"` or `"/"` includes somewhere and change them to relative ones (`"../"`). +- Check if you have a cyclic reference somewhere (sometimes quite hard to find). In general, no class should include something from the root's `index.ts` export (looks like `import * from "../../"`). +- Check if you have `"/dist"` as suffix for includes (e.g. `"@nmshd/crypto/dist"`). This usually works fine within NodeJS, however Webpack (Browser Build) has some issues therein, resulting e.g. in the crypto lib being copied into the transport lib. It should be fixed, but you never know... ### Something about duplicating private properties @@ -92,5 +92,5 @@ Do not use abstract classes. Or deserialize-/fromUnknown doesn't find your class. -- Check if all (parent) classes up to Serializable(-Async) inclulde a `@schema` declaration with a type. -- You might have several different Serializable(-Async) instances up- and running. This usually happens if ts-serval/crypto/transport are not correctly imported. +- Check if all (parent) classes up to Serializable(-Async) inclulde a `@schema` declaration with a type. +- You might have several different Serializable(-Async) instances up- and running. This usually happens if ts-serval/crypto/transport are not correctly imported. diff --git a/package-lock.json b/package-lock.json index 1354fe12c..9ab45342a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,14 +18,14 @@ "@js-soft/eslint-config-ts": "^1.6.13", "@js-soft/license-check": "^1.0.9", "@types/jest": "^29.5.14", - "@types/node": "^22.9.3", + "@types/node": "^22.10.1", "enhanced-publish": "^1.1.3", "eslint": "^8.57.1", "jest": "^29.7.0", "jest-expect-message": "^1.1.3", "madge": "^8.0.0", "npm-check-updates": "^17.1.11", - "prettier": "^3.3.3", + "prettier": "^3.4.1", "ts-jest": "^29.2.5", "ts-node": "^10.9.2", "tsconfig-paths": "^4.2.0", @@ -2167,13 +2167,13 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.9.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.9.3.tgz", - "integrity": "sha512-F3u1fs/fce3FFk+DAxbxc78DF8x0cY09RRL8GnXLmkJ1jvx3TtPdWoTT5/NiYfI5ASqXBmfqJi9dZ3gxMx4lzw==", + "version": "22.10.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.1.tgz", + "integrity": "sha512-qKgsUwfHZV2WCWLAnVP1JqnpE6Im6h3Y0+fYgMTasNQ7V++CBX5OT1as0g0f+OyubbFqhf6XVNIsmN4IIhEgGQ==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~6.19.8" + "undici-types": "~6.20.0" } }, "node_modules/@types/qrcode": { @@ -2902,9 +2902,10 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.7.7", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.7.tgz", - "integrity": "sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==", + "version": "1.7.8", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.8.tgz", + "integrity": "sha512-Uu0wb7KNqK2t5K+YQyVCLM76prD5sRFjKHbJYCP1J7JFGEQ6nN7HWn9+04LAeiJ3ji54lgS/gZCH1oxyrf1SPw==", + "license": "MIT", "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.0", @@ -7562,10 +7563,11 @@ } }, "node_modules/prettier": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.3.tgz", - "integrity": "sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==", + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.4.1.tgz", + "integrity": "sha512-G+YdqtITVZmOJje6QkXQWzl3fSfMxFwm1tjTyo9exhkmWSqC4Yhd1+lug++IlR2mvRVAxEDDWYkQdeSztajqgg==", "dev": true, + "license": "MIT", "bin": { "prettier": "bin/prettier.cjs" }, @@ -9170,10 +9172,11 @@ } }, "node_modules/undici-types": { - "version": "6.19.8", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", - "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", - "dev": true + "version": "6.20.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", + "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", + "dev": true, + "license": "MIT" }, "node_modules/unique-filename": { "version": "3.0.0", @@ -9656,7 +9659,7 @@ "@js-soft/ts-utils": "^2.3.3", "@nmshd/core-types": "*", "@nmshd/crypto": "2.1.0", - "axios": "^1.7.7", + "axios": "^1.7.8", "fast-json-patch": "^3.1.1", "form-data": "^4.0.1", "https-proxy-agent": "^7.0.5", diff --git a/package.json b/package.json index 9603959c0..92d9c36d0 100644 --- a/package.json +++ b/package.json @@ -30,14 +30,14 @@ "@js-soft/eslint-config-ts": "^1.6.13", "@js-soft/license-check": "^1.0.9", "@types/jest": "^29.5.14", - "@types/node": "^22.9.3", + "@types/node": "^22.10.1", "enhanced-publish": "^1.1.3", "eslint": "^8.57.1", "jest": "^29.7.0", "jest-expect-message": "^1.1.3", "madge": "^8.0.0", "npm-check-updates": "^17.1.11", - "prettier": "^3.3.3", + "prettier": "^3.4.1", "ts-jest": "^29.2.5", "ts-node": "^10.9.2", "tsconfig-paths": "^4.2.0", diff --git a/packages/transport/package.json b/packages/transport/package.json index 19503594d..0cda027fd 100644 --- a/packages/transport/package.json +++ b/packages/transport/package.json @@ -72,7 +72,7 @@ "@js-soft/ts-utils": "^2.3.3", "@nmshd/core-types": "*", "@nmshd/crypto": "2.1.0", - "axios": "^1.7.7", + "axios": "^1.7.8", "fast-json-patch": "^3.1.1", "form-data": "^4.0.1", "https-proxy-agent": "^7.0.5", From 20398dbae8e27278decf2aa17da8cd1fd958a384 Mon Sep 17 00:00:00 2001 From: Ruth Di Giacomo <150357721+RuthDiG@users.noreply.github.com> Date: Tue, 3 Dec 2024 13:58:03 +0100 Subject: [PATCH 19/21] Block sending Messages to Identities which are in deletion or for which the Relationship is terminated (#286) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add validation to incoming- outgoing and messageController * fix: relationships.test * fix: Notification shouldn't be blocked by the Runtime * fix: change place of validation * refactor: incorporate code review * feat: provide list of failing addresses and other changes * fix: add validation regarding RelationshipTermination * fix: refactoring of MessageController etc. * feat: make error more explicitly * chore: test without enabling modules * fix: add validation in case of Request * chore: incorporate code Review * chore: change naming etc * refactor: MessageController and errorMessages in IncomingRequestsController.test * fix: change Test setting * refactor: MessageController * chore: incorporate code review * refactor: comments * refactor: use PeerDeletionStatus * feat: add new test * fix: change wrong test * refactor: error and add some tests * feat: add test for Attributssuccession * fix: add waiting for event * refactor: toBeDeleted in IsInDeletion * refactor: wording * test: use helper methods instead of re-inventing the wheel * refactor: make code readable using helper methods * refactor: check if only one recipient early if the content is a request * fix: throw error instead of result * feat: add tests * refactor: change place of testing * chore: remove comments * chore: remove other comment * feat: move tests to notifications.test * feat: change place for test and avoid enabling modules * refactor: move validation block of canDecide method of IncomingRequestsController * refactor: place related tests together * fix: failing tests due to an error thrown elsewhere * refactor: be consistent with written out error messages * fix: wrong error code in tests * refactor: make use of auxiliary functions and only necessary expect statements * refactor: remove redundant tests * refactor: move tests to appropriate file * refactor: move tests to right location * chore: reduce file diffs * chore: remove empty line * refactor: use descriptive names and change error order * refactor: change order of thrown errors * refactor: change order of error messages * refactor: simplify errors and define them in appropriate library * fix: grammatical error * fix: failing tests due to strange bracket comparison * test: sending Messages requires existing of active or terminated Relationship * fix: tests on a terminated Relationship * refactor: tests for sending Messages with recipient in deletion * test: more precise test names * refactor: emphasize key of error messages * test: remove redundant tests * test: make use of auxiliary function * fix: missing bracket * chore: remove unneccessary empty lines * fix: failing test due to special error format of Transport library * fix: prettier error * chore: add empty line * fix: failing test due to special error format of Transport library * fix: failing test due to missing sync * refactor: make variable constant * refactor: undo last commit --------- Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com> Co-authored-by: Julian König Co-authored-by: Britta Stallknecht <146106656+britsta@users.noreply.github.com> Co-authored-by: Britta Stallknecht --- .../src/consumption/ConsumptionCoreErrors.ts | 8 + .../incoming/IncomingRequestsController.ts | 14 +- .../outgoing/OutgoingRequestsController.ts | 14 +- .../IncomingRequestsController.test.ts | 52 ++- .../OutgoingRequestsController.test.ts | 80 +++- .../requests/RequestsIntegrationTest.ts | 12 + .../requests/testHelpers/TestObjectFactory.ts | 108 +++++ .../src/useCases/common/RuntimeErrors.ts | 14 + .../transport/messages/SendMessage.ts | 81 +++- packages/runtime/test/lib/testUtils.ts | 30 +- .../runtime/test/transport/messages.test.ts | 370 +++++++++++++++++- .../test/transport/relationships.test.ts | 7 +- .../transport/src/core/TransportCoreErrors.ts | 14 +- .../src/modules/messages/MessageController.ts | 49 ++- .../modules/messages/MessageContent.test.ts | 2 +- .../messages/MessageController.test.ts | 358 ++++++++++------- .../transport/test/testHelpers/TestUtil.ts | 84 ++-- 17 files changed, 1073 insertions(+), 224 deletions(-) diff --git a/packages/consumption/src/consumption/ConsumptionCoreErrors.ts b/packages/consumption/src/consumption/ConsumptionCoreErrors.ts index f838e3bd0..3a6ed3d95 100644 --- a/packages/consumption/src/consumption/ConsumptionCoreErrors.ts +++ b/packages/consumption/src/consumption/ConsumptionCoreErrors.ts @@ -316,6 +316,14 @@ class Requests { return new CoreError("error.consumption.requests.missingRelationship", message); } + public peerIsDeleted(message: string) { + return new CoreError("error.consumption.requests.peerIsDeleted", message); + } + + public peerIsInDeletion(message: string) { + return new CoreError("error.consumption.requests.peerIsInDeletion", message); + } + public inheritedFromItem(message: string) { return new ApplicationError("error.consumption.requests.validation.inheritedFromItem", message); } diff --git a/packages/consumption/src/modules/requests/incoming/IncomingRequestsController.ts b/packages/consumption/src/modules/requests/incoming/IncomingRequestsController.ts index 5d9cbb322..4a5d48f07 100644 --- a/packages/consumption/src/modules/requests/incoming/IncomingRequestsController.ts +++ b/packages/consumption/src/modules/requests/incoming/IncomingRequestsController.ts @@ -2,7 +2,7 @@ import { ServalError } from "@js-soft/ts-serval"; import { EventBus } from "@js-soft/ts-utils"; import { RequestItem, RequestItemGroup, Response, ResponseItemDerivations, ResponseItemGroup, ResponseResult } from "@nmshd/content"; import { CoreAddress, CoreDate, CoreId, ICoreAddress, ICoreId } from "@nmshd/core-types"; -import { Message, Relationship, RelationshipStatus, RelationshipTemplate, SynchronizedCollection, TransportCoreErrors } from "@nmshd/transport"; +import { Message, PeerDeletionStatus, Relationship, RelationshipStatus, RelationshipTemplate, SynchronizedCollection, TransportCoreErrors } from "@nmshd/transport"; import { ConsumptionBaseController } from "../../../consumption/ConsumptionBaseController"; import { ConsumptionController } from "../../../consumption/ConsumptionController"; import { ConsumptionControllerName } from "../../../consumption/ConsumptionControllerName"; @@ -193,6 +193,18 @@ export class IncomingRequestsController extends ConsumptionBaseController { this.assertRequestStatus(request, LocalRequestStatus.DecisionRequired, LocalRequestStatus.ManualDecisionRequired); + if (relationship?.peerDeletionInfo?.deletionStatus === PeerDeletionStatus.ToBeDeleted) { + return ValidationResult.error( + ConsumptionCoreErrors.requests.peerIsInDeletion(`You cannot decide a Request from peer '${request.peer.toString()}' since the peer is in deletion.`) + ); + } + + if (relationship?.peerDeletionInfo?.deletionStatus === PeerDeletionStatus.Deleted) { + return ValidationResult.error( + ConsumptionCoreErrors.requests.peerIsDeleted(`You cannot decide a Request from peer '${request.peer.toString()}' since the peer is deleted.`) + ); + } + const validationResult = this.decideRequestParamsValidator.validate(params, request); if (validationResult.isError()) return validationResult; diff --git a/packages/consumption/src/modules/requests/outgoing/OutgoingRequestsController.ts b/packages/consumption/src/modules/requests/outgoing/OutgoingRequestsController.ts index 816e8c64c..7789363d8 100644 --- a/packages/consumption/src/modules/requests/outgoing/OutgoingRequestsController.ts +++ b/packages/consumption/src/modules/requests/outgoing/OutgoingRequestsController.ts @@ -1,7 +1,7 @@ import { EventBus } from "@js-soft/ts-utils"; import { DeleteAttributeRequestItem, RelationshipTemplateContent, Request, RequestItem, RequestItemGroup, Response, ResponseItem, ResponseItemGroup } from "@nmshd/content"; import { CoreAddress, CoreDate, CoreId, ICoreId } from "@nmshd/core-types"; -import { Message, Relationship, RelationshipStatus, RelationshipTemplate, SynchronizedCollection, TransportCoreErrors } from "@nmshd/transport"; +import { Message, PeerDeletionStatus, Relationship, RelationshipStatus, RelationshipTemplate, SynchronizedCollection, TransportCoreErrors } from "@nmshd/transport"; import { ConsumptionBaseController } from "../../../consumption/ConsumptionBaseController"; import { ConsumptionController } from "../../../consumption/ConsumptionController"; import { ConsumptionControllerName } from "../../../consumption/ConsumptionControllerName"; @@ -62,6 +62,18 @@ export class OutgoingRequestsController extends ConsumptionBaseController { ) ); } + + if (relationship.peerDeletionInfo?.deletionStatus === PeerDeletionStatus.ToBeDeleted) { + return ValidationResult.error( + ConsumptionCoreErrors.requests.peerIsInDeletion(`You cannot create a Request to peer '${parsedParams.peer.toString()}' since the peer is in deletion.`) + ); + } + + if (relationship.peerDeletionInfo?.deletionStatus === PeerDeletionStatus.Deleted) { + return ValidationResult.error( + ConsumptionCoreErrors.requests.peerIsDeleted(`You cannot create a Request to peer '${parsedParams.peer.toString()}' since the peer is deleted.`) + ); + } } const innerResults = await this.canCreateItems(parsedParams.content, parsedParams.peer); diff --git a/packages/consumption/test/modules/requests/IncomingRequestsController.test.ts b/packages/consumption/test/modules/requests/IncomingRequestsController.test.ts index 70e33a06d..6b85713e7 100644 --- a/packages/consumption/test/modules/requests/IncomingRequestsController.test.ts +++ b/packages/consumption/test/modules/requests/IncomingRequestsController.test.ts @@ -413,7 +413,8 @@ describe("IncomingRequestsController", function () { await Given.anIncomingRequestInStatus(LocalRequestStatus.DecisionRequired); const validationResult = await When.iCallCanAccept(); expect(validationResult).errorValidationResult({ - code: "error.consumption.requests.wrongRelationshipStatus" + code: "error.consumption.requests.wrongRelationshipStatus", + message: "You cannot decide a request from 'did:e:a-domain:dids:anidentity' since the relationship is in status 'Terminated'." }); }); @@ -422,7 +423,28 @@ describe("IncomingRequestsController", function () { await Given.anIncomingRequestInStatus(LocalRequestStatus.DecisionRequired); const validationResult = await When.iCallCanAccept(); expect(validationResult).errorValidationResult({ - code: "error.consumption.requests.wrongRelationshipStatus" + code: "error.consumption.requests.wrongRelationshipStatus", + message: "You cannot decide a request from 'did:e:a-domain:dids:anidentity' since the relationship is in status 'DeletionProposed'." + }); + }); + + test("returns 'error' on relationship with peer which is in deletion", async function () { + await Given.aRelationshipToPeerInDeletion(); + await Given.anIncomingRequestInStatus(LocalRequestStatus.DecisionRequired); + const validationResult = await When.iCallCanAccept(); + expect(validationResult).errorValidationResult({ + code: "error.consumption.requests.peerIsInDeletion", + message: "You cannot decide a Request from peer 'did:e:a-domain:dids:anidentity' since the peer is in deletion." + }); + }); + + test("returns 'error' on relationship with peer which is deleted", async function () { + await Given.aRelationshipToDeletedPeer(); + await Given.anIncomingRequestInStatus(LocalRequestStatus.DecisionRequired); + const validationResult = await When.iCallCanAccept(); + expect(validationResult).errorValidationResult({ + code: "error.consumption.requests.wrongRelationshipStatus", + message: "You cannot decide a request from 'did:e:a-domain:dids:anidentity' since the relationship is in status 'DeletionProposed'." }); }); }); @@ -607,7 +629,8 @@ describe("IncomingRequestsController", function () { await Given.anIncomingRequestInStatus(LocalRequestStatus.DecisionRequired); const validationResult = await When.iCallCanReject(); expect(validationResult).errorValidationResult({ - code: "error.consumption.requests.wrongRelationshipStatus" + code: "error.consumption.requests.wrongRelationshipStatus", + message: "You cannot decide a request from 'did:e:a-domain:dids:anidentity' since the relationship is in status 'Terminated'." }); }); @@ -616,7 +639,28 @@ describe("IncomingRequestsController", function () { await Given.anIncomingRequestInStatus(LocalRequestStatus.DecisionRequired); const validationResult = await When.iCallCanReject(); expect(validationResult).errorValidationResult({ - code: "error.consumption.requests.wrongRelationshipStatus" + code: "error.consumption.requests.wrongRelationshipStatus", + message: "You cannot decide a request from 'did:e:a-domain:dids:anidentity' since the relationship is in status 'DeletionProposed'." + }); + }); + + test("returns 'error' on relationship with peer which is in deletion", async function () { + await Given.aRelationshipToPeerInDeletion(); + await Given.anIncomingRequestInStatus(LocalRequestStatus.DecisionRequired); + const validationResult = await When.iCallCanReject(); + expect(validationResult).errorValidationResult({ + code: "error.consumption.requests.peerIsInDeletion", + message: "You cannot decide a Request from peer 'did:e:a-domain:dids:anidentity' since the peer is in deletion." + }); + }); + + test("returns 'error' on relationship with peer which is deleted", async function () { + await Given.aRelationshipToDeletedPeer(); + await Given.anIncomingRequestInStatus(LocalRequestStatus.DecisionRequired); + const validationResult = await When.iCallCanReject(); + expect(validationResult).errorValidationResult({ + code: "error.consumption.requests.wrongRelationshipStatus", + message: "You cannot decide a request from 'did:e:a-domain:dids:anidentity' since the relationship is in status 'DeletionProposed'." }); }); }); diff --git a/packages/consumption/test/modules/requests/OutgoingRequestsController.test.ts b/packages/consumption/test/modules/requests/OutgoingRequestsController.test.ts index f3a5f578f..c7fc63f34 100644 --- a/packages/consumption/test/modules/requests/OutgoingRequestsController.test.ts +++ b/packages/consumption/test/modules/requests/OutgoingRequestsController.test.ts @@ -226,6 +226,67 @@ describe("OutgoingRequestsController", function () { }); }); + describe("CanCreate (on terminated relationship)", function () { + test("returns a validation result that contains an error if the relationship is terminated", async function () { + await Given.aTerminatedRelationshipToIdentity(); + const validationResult = await When.iCallCanCreateForAnOutgoingRequest({ + content: { + items: [ + TestRequestItem.from({ + mustBeAccepted: false, + shouldFailAtCanCreateOutgoingRequestItem: true + }) + ] + } + }); + + expect(validationResult).errorValidationResult({ + code: "error.consumption.requests.wrongRelationshipStatus", + message: "You cannot create a request to 'did:e:a-domain:dids:anidentity' since the relationship is in status 'Terminated'" + }); + }); + }); + + describe("CanCreate (with peer in deletion or deleted peer)", function () { + test("returns a validation result that contains an error for requests to a peer which is in deletion", async function () { + await Given.aRelationshipToPeerInDeletion(); + const validationResult = await When.iCallCanCreateForAnOutgoingRequest({ + content: { + items: [ + TestRequestItem.from({ + mustBeAccepted: false, + shouldFailAtCanCreateOutgoingRequestItem: true + }) + ] + } + }); + + expect(validationResult).errorValidationResult({ + code: "error.consumption.requests.peerIsInDeletion", + message: "You cannot create a Request to peer 'did:e:a-domain:dids:anidentity' since the peer is in deletion." + }); + }); + + test("returns a validation result that contains an error for requests to a peer which is deleted", async function () { + await Given.aRelationshipToDeletedPeer(); + const validationResult = await When.iCallCanCreateForAnOutgoingRequest({ + content: { + items: [ + TestRequestItem.from({ + mustBeAccepted: false, + shouldFailAtCanCreateOutgoingRequestItem: true + }) + ] + } + }); + + expect(validationResult).errorValidationResult({ + code: "error.consumption.requests.wrongRelationshipStatus", + message: "You cannot create a request to 'did:e:a-domain:dids:anidentity' since the relationship is in status 'DeletionProposed'." + }); + }); + }); + describe("Create (on active relationship)", function () { beforeEach(async function () { await Given.anActiveRelationshipToIdentity(); @@ -804,23 +865,4 @@ describe("OutgoingRequestsController", function () { await Then.itThrowsAnErrorWithTheErrorMessage("*Local Request has to be in status 'Draft'*"); }); }); - - describe("CanCreate (on terminated relationship)", function () { - test("returns 'error' when the relationship is terminated", async function () { - await Given.aTerminatedRelationshipToIdentity(); - const validationResult = await When.iCallCanCreateForAnOutgoingRequest({ - content: { - items: [ - TestRequestItem.from({ - mustBeAccepted: false, - shouldFailAtCanCreateOutgoingRequestItem: true - }) - ] - } - }); - expect(validationResult).errorValidationResult({ - code: "error.consumption.requests.wrongRelationshipStatus" - }); - }); - }); }); diff --git a/packages/consumption/test/modules/requests/RequestsIntegrationTest.ts b/packages/consumption/test/modules/requests/RequestsIntegrationTest.ts index 6ce638447..6d555a271 100644 --- a/packages/consumption/test/modules/requests/RequestsIntegrationTest.ts +++ b/packages/consumption/test/modules/requests/RequestsIntegrationTest.ts @@ -176,6 +176,18 @@ export class RequestsGiven { return Promise.resolve(); } + public aRelationshipToPeerInDeletion(): Promise { + this.context.relationshipToReturnFromGetRelationshipToIdentity = TestObjectFactory.createRelationshipToPeerInDeletion(); + + return Promise.resolve(); + } + + public aRelationshipToDeletedPeer(): Promise { + this.context.relationshipToReturnFromGetRelationshipToIdentity = TestObjectFactory.createRelationshipToDeletedPeer(); + + return Promise.resolve(); + } + public aDeletionProposedRelationshipToIdentity(): Promise { this.context.relationshipToReturnFromGetRelationshipToIdentity = TestObjectFactory.createDeletionProposedRelationship(); diff --git a/packages/consumption/test/modules/requests/testHelpers/TestObjectFactory.ts b/packages/consumption/test/modules/requests/testHelpers/TestObjectFactory.ts index afd099cb9..0941a8544 100644 --- a/packages/consumption/test/modules/requests/testHelpers/TestObjectFactory.ts +++ b/packages/consumption/test/modules/requests/testHelpers/TestObjectFactory.ts @@ -28,6 +28,8 @@ import { IRelationship, IRelationshipTemplate, Message, + PeerDeletionInfo, + PeerDeletionStatus, Relationship, RelationshipAuditLogEntryReason, RelationshipStatus, @@ -164,6 +166,112 @@ export class TestObjectFactory { }); } + public static createRelationshipToPeerInDeletion(properties?: Partial): Relationship { + return Relationship.from({ + id: properties?.id ?? CoreId.from("REL1"), + peer: + properties?.peer ?? + Identity.from({ + address: CoreAddress.from("did:e:a-domain:dids:anidentity"), + publicKey: CryptoSignaturePublicKey.from({ + algorithm: CryptoSignatureAlgorithm.ECDSA_ED25519, + publicKey: CoreBuffer.from("L1sPFQgS5CxgGs1ejBcWCQLCpeFXbRc1TQnSpuHQqDQ") + }) + }), + peerDeletionInfo: + properties?.peerDeletionInfo ?? + PeerDeletionInfo.from({ + deletionStatus: PeerDeletionStatus.ToBeDeleted, + deletionDate: CoreDate.from("2100-01-03T00:00:00.000Z") + }), + status: properties?.status ?? RelationshipStatus.Active, + relationshipSecretId: properties?.relationshipSecretId ?? CoreId.from("RELSEC1"), + cachedAt: properties?.cachedAt ?? CoreDate.from("2020-01-03T00:00:00.000Z"), + cache: + properties?.cache ?? + CachedRelationship.from({ + creationContent: {}, + auditLog: [ + { + createdAt: CoreDate.from("2020-01-01T00:00:00.000Z"), + createdBy: CoreAddress.from("did:e:a-domain:dids:anidentity2"), + createdByDevice: CoreId.from("DVC1"), + reason: RelationshipAuditLogEntryReason.Creation, + newStatus: RelationshipStatus.Pending + }, + + { + createdAt: CoreDate.from("2020-01-02T00:00:00.000Z"), + createdBy: CoreAddress.from("did:e:a-domain:dids:anidentity"), + createdByDevice: CoreId.from("DVC1"), + reason: RelationshipAuditLogEntryReason.AcceptanceOfCreation, + oldStatus: RelationshipStatus.Pending, + newStatus: RelationshipStatus.Active + } + ], + template: this.createIncomingRelationshipTemplate() + }) + }); + } + + public static createRelationshipToDeletedPeer(properties?: Partial): Relationship { + return Relationship.from({ + id: properties?.id ?? CoreId.from("REL1"), + peer: + properties?.peer ?? + Identity.from({ + address: CoreAddress.from("did:e:a-domain:dids:anidentity"), + publicKey: CryptoSignaturePublicKey.from({ + algorithm: CryptoSignatureAlgorithm.ECDSA_ED25519, + publicKey: CoreBuffer.from("L1sPFQgS5CxgGs1ejBcWCQLCpeFXbRc1TQnSpuHQqDQ") + }) + }), + peerDeletionInfo: + properties?.peerDeletionInfo ?? + PeerDeletionInfo.from({ + deletionStatus: PeerDeletionStatus.Deleted, + deletionDate: CoreDate.from("2022-01-03T00:00:00.000Z") + }), + status: properties?.status ?? RelationshipStatus.DeletionProposed, + relationshipSecretId: properties?.relationshipSecretId ?? CoreId.from("RELSEC1"), + cachedAt: properties?.cachedAt ?? CoreDate.from("2022-01-03T00:00:00.000Z"), + cache: + properties?.cache ?? + CachedRelationship.from({ + creationContent: {}, + auditLog: [ + { + createdAt: CoreDate.from("2020-01-01T00:00:00.000Z"), + createdBy: CoreAddress.from("did:e:a-domain:dids:anidentity2"), + createdByDevice: CoreId.from("DVC1"), + reason: RelationshipAuditLogEntryReason.Creation, + newStatus: RelationshipStatus.Pending + }, + + { + createdAt: CoreDate.from("2020-01-02T00:00:00.000Z"), + createdBy: CoreAddress.from("did:e:a-domain:dids:anidentity"), + createdByDevice: CoreId.from("DVC1"), + reason: RelationshipAuditLogEntryReason.AcceptanceOfCreation, + oldStatus: RelationshipStatus.Pending, + newStatus: RelationshipStatus.Active + }, + + { + createdAt: CoreDate.from("2022-01-03T00:00:00.000Z"), + createdBy: CoreAddress.from("did:e:a-domain:dids:anidentity"), + createdByDevice: CoreId.from("DVC1"), + // must be DecompositionDueToIdentityDeletion in the future + reason: RelationshipAuditLogEntryReason.Decomposition, + oldStatus: RelationshipStatus.Active, + newStatus: RelationshipStatus.DeletionProposed + } + ], + template: this.createIncomingRelationshipTemplate() + }) + }); + } + public static createDeletionProposedRelationship(properties?: Partial): Relationship { return Relationship.from({ id: properties?.id ?? CoreId.from("REL1"), diff --git a/packages/runtime/src/useCases/common/RuntimeErrors.ts b/packages/runtime/src/useCases/common/RuntimeErrors.ts index 08e0438f0..4f0f237e3 100644 --- a/packages/runtime/src/useCases/common/RuntimeErrors.ts +++ b/packages/runtime/src/useCases/common/RuntimeErrors.ts @@ -121,6 +121,20 @@ class Relationships { } class Messages { + public hasNoActiveRelationship(addresses: string[]) { + return new ApplicationError( + "error.runtime.messages.hasNoActiveRelationship", + `The Message cannot be sent as there is no active Relationship to the recipient(s) with the following address(es): ${addresses.map((address) => `'${address}'`).join(", ")}. However, please note that Messages whose content is a Notification can be sent on terminated Relationships as well.` + ); + } + + public peerIsInDeletion(addresses: string[]) { + return new ApplicationError( + "error.runtime.messages.peerIsInDeletion", + `The Message cannot be sent as the recipient(s) with the following address(es) being in deletion: ${addresses.map((address) => `'${address}'`).join(", ")}. However, please note that Messages whose content is a Notification can be sent to recipients in deletion.` + ); + } + public fileNotFoundInMessage(attachmentId: string) { return new ApplicationError("error.runtime.messages.fileNotFoundInMessage", `The requested File '${attachmentId}' was not found in the given Message.`); } diff --git a/packages/runtime/src/useCases/transport/messages/SendMessage.ts b/packages/runtime/src/useCases/transport/messages/SendMessage.ts index 67de35285..1d0c44689 100644 --- a/packages/runtime/src/useCases/transport/messages/SendMessage.ts +++ b/packages/runtime/src/useCases/transport/messages/SendMessage.ts @@ -1,9 +1,9 @@ import { Serializable } from "@js-soft/ts-serval"; -import { Result } from "@js-soft/ts-utils"; +import { ApplicationError, Result } from "@js-soft/ts-utils"; import { OutgoingRequestsController } from "@nmshd/consumption"; import { ArbitraryMessageContent, Mail, Notification, Request, ResponseWrapper } from "@nmshd/content"; -import { CoreAddress, CoreId } from "@nmshd/core-types"; -import { AccountController, File, FileController, MessageController } from "@nmshd/transport"; +import { CoreAddress, CoreError, CoreId } from "@nmshd/core-types"; +import { AccountController, File, FileController, MessageController, PeerDeletionStatus, RelationshipsController, RelationshipStatus, TransportCoreErrors } from "@nmshd/transport"; import { Inject } from "@nmshd/typescript-ioc"; import _ from "lodash"; import { MessageDTO } from "../../../types"; @@ -28,6 +28,7 @@ class Validator extends SchemaValidator { export class SendMessageUseCase extends UseCase { public constructor( + @Inject private readonly relationshipsController: RelationshipsController, @Inject private readonly messageController: MessageController, @Inject private readonly fileController: FileController, @Inject private readonly accountController: AccountController, @@ -38,7 +39,7 @@ export class SendMessageUseCase extends UseCase } protected async executeInternal(request: SendMessageRequest): Promise> { - const validationError = await this.validateMessageContent(request.content, request.recipients); + const validationError = await this.validateMessage(request.content, request.recipients); if (validationError) return Result.fail(validationError); const transformAttachmentsResult = await this.transformAttachments(request.attachments); @@ -57,7 +58,7 @@ export class SendMessageUseCase extends UseCase return Result.ok(MessageMapper.toMessageDTO(result)); } - private async validateMessageContent(content: any, recipients: string[]) { + private async validateMessage(content: any, recipients: string[]) { const transformedContent = Serializable.fromUnknown(content); if ( !( @@ -73,21 +74,77 @@ export class SendMessageUseCase extends UseCase ); } - if (!(transformedContent instanceof Request)) return; + const recipientsValidationError = await this.validateMessageRecipients(transformedContent, recipients); + if (recipientsValidationError) return recipientsValidationError; - if (!transformedContent.id) return RuntimeErrors.general.invalidPropertyValue("The Request must have an id."); + if (transformedContent instanceof Request) { + const requestValidationError = await this.validateRequestAsMessageContent(transformedContent, CoreAddress.from(recipients[0])); + if (requestValidationError) return requestValidationError; + } + + return; + } + + private async validateMessageRecipients(content: Serializable, recipients: string[]): Promise { + if (content instanceof Request && recipients.length !== 1) { + return RuntimeErrors.general.invalidPropertyValue("Only one recipient is allowed for sending Requests."); + } + + const peersWithNoActiveRelationship: string[] = []; + const peersWithNeitherActiveNorTerminatedRelationship: string[] = []; + const peersInDeletion: string[] = []; + const deletedPeers: string[] = []; + + for (const recipient of recipients) { + const relationship = await this.relationshipsController.getRelationshipToIdentity(CoreAddress.from(recipient)); - const localRequest = await this.outgoingRequestsController.getOutgoingRequest(transformedContent.id); + if (!relationship || relationship.status !== RelationshipStatus.Active) { + peersWithNoActiveRelationship.push(recipient); + + if (!relationship || relationship.status !== RelationshipStatus.Terminated) { + peersWithNeitherActiveNorTerminatedRelationship.push(recipient); + } + + continue; + } + + if (relationship.peerDeletionInfo?.deletionStatus === PeerDeletionStatus.ToBeDeleted) { + peersInDeletion.push(recipient); + continue; + } + + if (relationship.peerDeletionInfo?.deletionStatus === PeerDeletionStatus.Deleted) { + deletedPeers.push(recipient); + } + } + + if (!(content instanceof Notification)) { + if (peersWithNoActiveRelationship.length > 0) return RuntimeErrors.messages.hasNoActiveRelationship(peersWithNoActiveRelationship); + + if (peersInDeletion.length > 0) return RuntimeErrors.messages.peerIsInDeletion(peersInDeletion); + } + + if (peersWithNeitherActiveNorTerminatedRelationship.length > 0) { + return TransportCoreErrors.messages.hasNeitherActiveNorTerminatedRelationship(peersWithNeitherActiveNorTerminatedRelationship); + } + + if (deletedPeers.length > 0) return TransportCoreErrors.messages.peerIsDeleted(deletedPeers); + + return; + } + + private async validateRequestAsMessageContent(request: Request, recipient: CoreAddress): Promise { + if (!request.id) return RuntimeErrors.general.invalidPropertyValue("The Request must have an id."); + + const localRequest = await this.outgoingRequestsController.getOutgoingRequest(request.id); if (!localRequest) return RuntimeErrors.general.recordNotFound(Request); - if (!_.isEqual(transformedContent.toJSON(), localRequest.content.toJSON())) { + if (!_.isEqual(request.toJSON(), localRequest.content.toJSON())) { return RuntimeErrors.general.invalidPropertyValue("The sent Request must have the same content as the LocalRequest."); } - if (recipients.length > 1) return RuntimeErrors.general.invalidPropertyValue("Only one recipient is allowed for sending Requests."); - - const recipient = CoreAddress.from(recipients[0]); if (!recipient.equals(localRequest.peer)) return RuntimeErrors.general.invalidPropertyValue("The recipient does not match the Request's peer."); + return; } diff --git a/packages/runtime/test/lib/testUtils.ts b/packages/runtime/test/lib/testUtils.ts index 2f749d352..6606f8873 100644 --- a/packages/runtime/test/lib/testUtils.ts +++ b/packages/runtime/test/lib/testUtils.ts @@ -1,4 +1,4 @@ -import { Event, EventBus, sleep, SubscriptionTarget } from "@js-soft/ts-utils"; +import { Event, EventBus, Result, sleep, SubscriptionTarget } from "@js-soft/ts-utils"; import { AcceptReadAttributeRequestItemParametersWithExistingAttributeJSON, ConsumptionIds, @@ -297,7 +297,12 @@ export async function sendMessage(transportServices: TransportServices, recipien return response.value; } -export async function sendMessageToMultipleRecipients(transportServices: TransportServices, recipients: string[], content?: any, attachments?: string[]): Promise { +export async function sendMessageToMultipleRecipients( + transportServices: TransportServices, + recipients: string[], + content?: any, + attachments?: string[] +): Promise> { const response = await transportServices.messages.sendMessage({ recipients, content: content ?? { @@ -309,9 +314,8 @@ export async function sendMessageToMultipleRecipients(transportServices: Transpo }, attachments }); - expect(response).toBeSuccessful(); - return response.value; + return response; } export async function sendMessageWithRequest( @@ -528,6 +532,24 @@ export async function ensurePendingRelationship(sTransportServices: TransportSer return (await sTransportServices.relationships.getRelationships({})).value[0]; } +export async function reactivateTerminatedRelationship(sTransportServices: TransportServices, rTransportServices: TransportServices): Promise { + const rTransportServicesAddress = (await rTransportServices.account.getIdentityInfo()).value.address; + + const terminatedRelationshipsToPeer = ( + await sTransportServices.relationships.getRelationships({ query: { peer: rTransportServicesAddress, status: RelationshipStatus.Terminated } }) + ).value; + + if (terminatedRelationshipsToPeer.length !== 0) { + const terminatedRelationshipId = terminatedRelationshipsToPeer[0].id; + await rTransportServices.relationships.requestRelationshipReactivation({ relationshipId: terminatedRelationshipId }); + await syncUntilHasRelationships(sTransportServices); + await sTransportServices.relationships.acceptRelationshipReactivation({ relationshipId: terminatedRelationshipId }); + await syncUntilHasRelationships(rTransportServices); + } + + return; +} + export async function mutualDecomposeIfActiveRelationshipExists(sTransportServices: TransportServices, rTransportServices: TransportServices): Promise { const rTransportServicesAddress = (await rTransportServices.account.getIdentityInfo()).value.address; diff --git a/packages/runtime/test/transport/messages.test.ts b/packages/runtime/test/transport/messages.test.ts index 3f1d4cd77..dce7daf92 100644 --- a/packages/runtime/test/transport/messages.test.ts +++ b/packages/runtime/test/transport/messages.test.ts @@ -1,16 +1,41 @@ -import { ConsentRequestItemJSON } from "@nmshd/content"; +import { ConsumptionIds } from "@nmshd/consumption"; +import { ConsentRequestItemJSON, Notification } from "@nmshd/content"; import { CoreDate } from "@nmshd/core-types"; -import { GetMessagesQuery, MessageReceivedEvent, MessageSentEvent, MessageWasReadAtChangedEvent } from "../../src"; +import assert from "assert"; import { - QueryParamConditions, - RuntimeServiceProvider, - TestRuntimeServices, + AttributeDeletedEvent, + GetMessagesQuery, + IdentityDeletionProcessStatus, + LocalAttributeDeletionStatus, + LocalRequestDTO, + MessageReceivedEvent, + MessageSentEvent, + MessageWasReadAtChangedEvent, + OwnSharedAttributeSucceededEvent, + PeerDeletionCancelledEvent, + PeerToBeDeletedEvent, + RelationshipStatus +} from "../../src"; +import { + emptyRelationshipCreationContent, ensureActiveRelationship, establishRelationship, exchangeMessage, exchangeMessageWithAttachment, + exchangeTemplate, + executeFullCreateAndShareRepositoryAttributeFlow, + QueryParamConditions, + reactivateTerminatedRelationship, + RuntimeServiceProvider, sendMessage, + sendMessageToMultipleRecipients, + syncUntilHasEvent, syncUntilHasMessage, + syncUntilHasMessages, + syncUntilHasMessageWithNotification, + syncUntilHasRelationships, + TestNotificationItem, + TestRuntimeServices, uploadFile } from "../lib"; @@ -18,12 +43,20 @@ const serviceProvider = new RuntimeServiceProvider(); let client1: TestRuntimeServices; let client2: TestRuntimeServices; let client3: TestRuntimeServices; +let client4: TestRuntimeServices; +let client5: TestRuntimeServices; beforeAll(async () => { - const runtimeServices = await serviceProvider.launch(3); + const runtimeServices = await serviceProvider.launch(5, { + enableRequestModule: true, + enableDeciderModule: true, + enableNotificationModule: true + }); client1 = runtimeServices[0]; client2 = runtimeServices[1]; client3 = runtimeServices[2]; + client4 = runtimeServices[3]; + client5 = runtimeServices[4]; await ensureActiveRelationship(client1.transport, client2.transport); await ensureActiveRelationship(client1.transport, client3.transport); }, 30000); @@ -124,20 +157,24 @@ describe("Messaging", () => { describe("Message errors", () => { let requestItem: ConsentRequestItemJSON; + let createRequestResult: LocalRequestDTO; let requestId: string; + beforeAll(async () => { requestItem = { "@type": "ConsentRequestItem", consent: "I consent to this RequestItem", mustBeAccepted: true }; - const createRequestResult = await client1.consumption.outgoingRequests.create({ - content: { - items: [requestItem] - }, - peer: client2.address - }); - requestId = createRequestResult.value.id; + createRequestResult = ( + await client1.consumption.outgoingRequests.create({ + content: { + items: [requestItem] + }, + peer: client2.address + }) + ).value; + requestId = createRequestResult.id; }); test("should throw correct error for empty 'to' in the Message", async () => { @@ -235,6 +272,313 @@ describe("Message errors", () => { }); expect(result).toBeAnError("The recipient does not match the Request's peer.", "error.runtime.validation.invalidPropertyValue"); }); + + describe("Message errors for Relationships that are not active", () => { + test("should throw correct error for trying to send a Message if there are recipients to which no Relationship exists", async () => { + const result = await client1.transport.messages.sendMessage({ + recipients: [client4.address, client5.address], + content: { + "@type": "Mail", + body: "b", + cc: [client4.address], + subject: "a", + to: [client5.address] + } + }); + expect(result).toBeAnError(/.*/, "error.runtime.messages.hasNoActiveRelationship"); + expect(result.error.message).toBe( + `The Message cannot be sent as there is no active Relationship to the recipient(s) with the following address(es): '${client4.address.toString()}', '${client5.address.toString()}'. However, please note that Messages whose content is a Notification can be sent on terminated Relationships as well.` + ); + }); + + test("should throw correct error for trying to send a Message if there are recipients to which only a pending Relationship exists", async () => { + const templateId = (await exchangeTemplate(client1.transport, client4.transport)).id; + + await client4.transport.relationships.createRelationship({ + templateId: templateId, + creationContent: emptyRelationshipCreationContent + }); + + const relationships = await syncUntilHasRelationships(client1.transport); + expect(relationships[0].status).toBe(RelationshipStatus.Pending); + + const result = await client1.transport.messages.sendMessage({ + recipients: [client2.address, client4.address], + content: { + "@type": "Mail", + body: "b", + cc: [client2.address], + subject: "a", + to: [client4.address] + } + }); + expect(result).toBeAnError(/.*/, "error.runtime.messages.hasNoActiveRelationship"); + expect(result.error.message).toBe( + `The Message cannot be sent as there is no active Relationship to the recipient(s) with the following address(es): '${client4.address.toString()}'. However, please note that Messages whose content is a Notification can be sent on terminated Relationships as well.` + ); + }); + + test("should throw correct error for trying to send a Message whose content is not a Notification if there are recipients to which only a terminated Relationship exists", async () => { + const getRelationshipResult = (await client1.transport.relationships.getRelationshipByAddress({ address: client3.address })).value; + expect(getRelationshipResult.status).toBe(RelationshipStatus.Active); + + await client1.transport.relationships.terminateRelationship({ relationshipId: getRelationshipResult.id }); + const terminatedRelationship = (await syncUntilHasRelationships(client3.transport))[0]; + expect(terminatedRelationship.status).toBe(RelationshipStatus.Terminated); + + const result = await client1.transport.messages.sendMessage({ + recipients: [client3.address], + content: { + "@type": "Mail", + body: "b", + cc: [], + subject: "a", + to: [client3.address] + } + }); + expect(result).toBeAnError(/.*/, "error.runtime.messages.hasNoActiveRelationship"); + expect(result.error.message).toBe( + `The Message cannot be sent as there is no active Relationship to the recipient(s) with the following address(es): '${client3.address.toString()}'. However, please note that Messages whose content is a Notification can be sent on terminated Relationships as well.` + ); + }); + + test("should throw less restrictive transport error for trying to send a Message whose content is a Notification if there are recipients to which neither an active nor a terminated Relationship exists", async () => { + const notificationId = await ConsumptionIds.notification.generate(); + const notificationToBeSent = Notification.from({ id: notificationId, items: [TestNotificationItem.from({})] }); + + const result = await client1.transport.messages.sendMessage({ + recipients: [client5.address], + content: notificationToBeSent.toJSON() + }); + expect(result).toBeAnError(/.*/, "error.transport.messages.hasNeitherActiveNorTerminatedRelationship"); + expect(result.error.message).toContain( + `The Message cannot be sent as there is neither an active nor a terminated Relationship to the recipient(s) with the following address(es): '${client5.address.toString()}'.` + ); + }); + }); + + describe("Message errors for peers that are in deletion", () => { + let relationshipIdToClient2: string; + let relationshipIdToClient5: string; + + beforeAll(async () => { + relationshipIdToClient2 = (await client1.transport.relationships.getRelationshipByAddress({ address: client2.address })).value.id; + relationshipIdToClient5 = (await ensureActiveRelationship(client1.transport, client5.transport)).id; + }); + + afterEach(async () => { + for (const client of [client2, client5]) { + const activeIdentityDeletionProcess = await client.transport.identityDeletionProcesses.getActiveIdentityDeletionProcess(); + if (!activeIdentityDeletionProcess.isSuccess) { + return; + } + let abortResult; + if (activeIdentityDeletionProcess.value.status === IdentityDeletionProcessStatus.Approved) { + abortResult = await client.transport.identityDeletionProcesses.cancelIdentityDeletionProcess(); + } else if (activeIdentityDeletionProcess.value.status === IdentityDeletionProcessStatus.WaitingForApproval) { + abortResult = await client.transport.identityDeletionProcesses.rejectIdentityDeletionProcess(); + } + await syncUntilHasEvent(client1, PeerDeletionCancelledEvent); + if (abortResult?.isError) throw abortResult.error; + } + }); + + test("should throw correct error for Messages whose content is not a Notification if there are recipients in deletion", async () => { + await client2.transport.identityDeletionProcesses.initiateIdentityDeletionProcess(); + await syncUntilHasEvent(client1, PeerToBeDeletedEvent, (e) => e.data.id === relationshipIdToClient2); + await client1.eventBus.waitForRunningEventHandlers(); + + await client5.transport.identityDeletionProcesses.initiateIdentityDeletionProcess(); + await syncUntilHasEvent(client1, PeerToBeDeletedEvent, (e) => e.data.id === relationshipIdToClient5); + await client1.eventBus.waitForRunningEventHandlers(); + + const result = await sendMessageToMultipleRecipients(client1.transport, [client2.address, client5.address]); + expect(result).toBeAnError(/.*/, "error.runtime.messages.peerIsInDeletion"); + expect(result.error.message).toBe( + `The Message cannot be sent as the recipient(s) with the following address(es) being in deletion: '${client2.address.toString()}', '${client5.address.toString()}'. However, please note that Messages whose content is a Notification can be sent to recipients in deletion.` + ); + }); + + test("should throw correct error for Messages whose content is a Request if the recipient has initiated its deletion after the Request has been created", async () => { + await client2.transport.identityDeletionProcesses.initiateIdentityDeletionProcess(); + await syncUntilHasEvent(client1, PeerToBeDeletedEvent, (e) => e.data.id === relationshipIdToClient2); + await client1.eventBus.waitForRunningEventHandlers(); + + const result = await client1.transport.messages.sendMessage({ recipients: [client2.address], content: createRequestResult.content }); + expect(result).toBeAnError(/.*/, "error.runtime.messages.peerIsInDeletion"); + expect(result.error.message).toBe( + `The Message cannot be sent as the recipient(s) with the following address(es) being in deletion: '${client2.address.toString()}'. However, please note that Messages whose content is a Notification can be sent to recipients in deletion.` + ); + }); + }); +}); + +describe("Postponed Notifications via Messages", () => { + beforeEach(() => { + client1.eventBus.reset(); + client5.eventBus.reset(); + }); + + describe("Postponed Notifications for reactivated Relationships", () => { + let relationshipId: string; + + beforeAll(async () => { + relationshipId = (await ensureActiveRelationship(client5.transport, client1.transport)).id; + }); + + test("should be able to send a Notification even though the Relationship is terminated and the recipient should receive it only after the reactiviation of the Relationship", async () => { + await client1.transport.relationships.terminateRelationship({ relationshipId: relationshipId }); + const terminatedRelationship = (await syncUntilHasRelationships(client5.transport))[0]; + expect(terminatedRelationship.status).toBe(RelationshipStatus.Terminated); + + const notificationId = await ConsumptionIds.notification.generate(); + const notificationToBeSent = Notification.from({ id: notificationId, items: [TestNotificationItem.from({})] }); + const sendMessageResult = await client1.transport.messages.sendMessage({ recipients: [client5.address], content: notificationToBeSent.toJSON() }); + expect(sendMessageResult).toBeSuccessful(); + const notificationSentResult = await client1.consumption.notifications.sentNotification({ messageId: sendMessageResult.value.id }); + expect(notificationSentResult).toBeSuccessful(); + + await client5.transport.account.syncEverything(); + const getMessagesResponse = await client5.transport.messages.getMessages({}); + expect(getMessagesResponse.value).toHaveLength(0); + const getNotificationResponse = await client5.consumption.notifications.getNotification({ id: notificationId.toString() }); + expect(getNotificationResponse).toBeAnError(/.*/, "error.transport.recordNotFound"); + + await reactivateTerminatedRelationship(client5.transport, client1.transport); + + const postponedMessages = await syncUntilHasMessages(client5.transport); + expect(postponedMessages).toHaveLength(1); + const postponedNotification = await client5.consumption.notifications.getNotification({ id: notificationId.toString() }); + expect(postponedNotification).toBeSuccessful(); + }); + + test("should be able to receive Notifications sent on a terminated Relationship in the right order after the Relationship was reactivated", async () => { + const ownSharedIdentityAttribute = await executeFullCreateAndShareRepositoryAttributeFlow(client1, client5, { + content: { + value: { + "@type": "GivenName", + value: "A given name" + } + } + }); + + await client1.transport.relationships.terminateRelationship({ relationshipId: relationshipId }); + const terminatedRelationship = (await syncUntilHasRelationships(client5.transport))[0]; + expect(terminatedRelationship.status).toBe(RelationshipStatus.Terminated); + + const { successor: successorOfRepositoryAttribute } = ( + await client1.consumption.attributes.succeedRepositoryAttribute({ + predecessorId: (await client1.consumption.attributes.getRepositoryAttributes({})).value[0].id, + successorContent: { + value: { + "@type": "GivenName", + value: "A new given name" + } + } + }) + ).value; + + const notifyAboutSuccessionResult = ( + await client1.consumption.attributes.notifyPeerAboutRepositoryAttributeSuccession({ attributeId: successorOfRepositoryAttribute.id, peer: client5.address }) + ).value; + await client1.eventBus.waitForEvent(OwnSharedAttributeSucceededEvent); + await client5.transport.account.syncEverything(); + const successionNotificationNotYetReceived = await client5.consumption.notifications.getNotification({ id: notifyAboutSuccessionResult.notificationId }); + expect(successionNotificationNotYetReceived).toBeAnError(/.*/, "error.transport.recordNotFound"); + + const notifyAboutDeletionResult = (await client1.consumption.attributes.deleteOwnSharedAttributeAndNotifyPeer({ attributeId: ownSharedIdentityAttribute.id })).value; + await client1.eventBus.waitForEvent(AttributeDeletedEvent); + await client5.transport.account.syncEverything(); + const deletionNotificationNotYetReceived = await client5.consumption.notifications.getNotification({ id: notifyAboutDeletionResult.notificationId }); + expect(deletionNotificationNotYetReceived).toBeAnError(/.*/, "error.transport.recordNotFound"); + + await client1.transport.relationships.requestRelationshipReactivation({ relationshipId: relationshipId }); + await syncUntilHasRelationships(client5.transport); + const acceptReactivationResult = await client5.transport.relationships.acceptRelationshipReactivation({ relationshipId: relationshipId }); + expect(acceptReactivationResult.value.status).toBe(RelationshipStatus.Active); + const timeOfAcceptanceOfReactivation = acceptReactivationResult.value.auditLog[acceptReactivationResult.value.auditLog.length - 1].createdAt; + const reactivatedRelationship = await syncUntilHasRelationships(client1.transport); + expect(reactivatedRelationship[reactivatedRelationship.length - 1].status).toBe(RelationshipStatus.Active); + + const postponedMessages = await syncUntilHasMessages(client5.transport); + expect(postponedMessages).toHaveLength(2); + + const postponedSuccessionNotification = await client5.consumption.notifications.getNotification({ id: notifyAboutSuccessionResult.notificationId }); + expect(postponedSuccessionNotification).toBeSuccessful(); + const postponedDeletionNotification = await client5.consumption.notifications.getNotification({ id: notifyAboutDeletionResult.notificationId }); + expect(postponedDeletionNotification).toBeSuccessful(); + + const peerSharedIdentityAttribute = (await client5.consumption.attributes.getAttribute({ id: ownSharedIdentityAttribute.id })).value; + assert(peerSharedIdentityAttribute.succeededBy); + assert(peerSharedIdentityAttribute.deletionInfo?.deletionDate); + assert(peerSharedIdentityAttribute.deletionInfo.deletionStatus, LocalAttributeDeletionStatus.DeletedByOwner); + + const timeOfSuccession = (await client5.consumption.attributes.getAttribute({ id: peerSharedIdentityAttribute.succeededBy })).value.createdAt; + const timeOfDeletionByOwner = peerSharedIdentityAttribute.deletionInfo.deletionDate; + expect(CoreDate.from(timeOfAcceptanceOfReactivation).isBefore(CoreDate.from(timeOfSuccession))).toBe(true); + expect(CoreDate.from(timeOfSuccession).isBefore(CoreDate.from(timeOfDeletionByOwner))).toBe(true); + }); + }); + + describe("Postponed Notifications for cancelled Identity deletion of recipient", () => { + let relationshipId: string; + + beforeAll(async () => { + relationshipId = (await ensureActiveRelationship(client5.transport, client1.transport)).id; + }); + + afterEach(async () => { + const activeIdentityDeletionProcess = await client1.transport.identityDeletionProcesses.getActiveIdentityDeletionProcess(); + if (!activeIdentityDeletionProcess.isSuccess) { + return; + } + let abortResult; + if (activeIdentityDeletionProcess.value.status === IdentityDeletionProcessStatus.Approved) { + abortResult = await client1.transport.identityDeletionProcesses.cancelIdentityDeletionProcess(); + } else if (activeIdentityDeletionProcess.value.status === IdentityDeletionProcessStatus.WaitingForApproval) { + abortResult = await client1.transport.identityDeletionProcesses.rejectIdentityDeletionProcess(); + } + await syncUntilHasEvent(client5, PeerDeletionCancelledEvent, (e) => e.data.id === relationshipId); + if (abortResult?.isError) throw abortResult.error; + }); + + test("should be able to send a Notification even though the recipient is in deletion", async () => { + await client1.transport.identityDeletionProcesses.initiateIdentityDeletionProcess(); + await syncUntilHasEvent(client5, PeerToBeDeletedEvent, (e) => e.data.id === relationshipId); + await client5.eventBus.waitForRunningEventHandlers(); + + const updatedRelationship = (await client5.transport.relationships.getRelationship({ id: relationshipId })).value; + expect(updatedRelationship.peerDeletionInfo?.deletionStatus).toBe("ToBeDeleted"); + + const id = await ConsumptionIds.notification.generate(); + const notificationToSend = Notification.from({ id, items: [TestNotificationItem.from({})] }); + await expect(client5.transport.messages.sendMessage({ recipients: [client1.address], content: notificationToSend.toJSON() })).resolves.not.toThrow(); + }); + + test("should be able to receive a Notification sent during its recipient was in deletion after the recipient cancelled its deletion process", async () => { + await client1.transport.identityDeletionProcesses.initiateIdentityDeletionProcess(); + await syncUntilHasEvent(client5, PeerToBeDeletedEvent, (e) => e.data.id === relationshipId); + await client5.eventBus.waitForRunningEventHandlers(); + + const updatedRelationship = (await client5.transport.relationships.getRelationship({ id: relationshipId })).value; + expect(updatedRelationship.peerDeletionInfo?.deletionStatus).toBe("ToBeDeleted"); + + const id = await ConsumptionIds.notification.generate(); + const notificationToSend = Notification.from({ id, items: [TestNotificationItem.from({})] }); + + const result = await client5.transport.messages.sendMessage({ recipients: [client1.address], content: notificationToSend.toJSON() }); + expect(result).toBeSuccessful(); + + await client1.transport.identityDeletionProcesses.cancelIdentityDeletionProcess(); + await syncUntilHasEvent(client5, PeerDeletionCancelledEvent, (e) => e.data.id === relationshipId); + await client5.eventBus.waitForRunningEventHandlers(); + + const message = await syncUntilHasMessageWithNotification(client1.transport, id); + + const notification = await client1.consumption.notifications.receivedNotification({ messageId: message.id }); + expect(notification).toBeSuccessful(); + }); + }); }); describe("Mark Message as un-/read", () => { diff --git a/packages/runtime/test/transport/relationships.test.ts b/packages/runtime/test/transport/relationships.test.ts index 42d163409..d54d50287 100644 --- a/packages/runtime/test/transport/relationships.test.ts +++ b/packages/runtime/test/transport/relationships.test.ts @@ -559,6 +559,7 @@ describe("Attributes for the relationship", () => { describe("RelationshipTermination", () => { let relationshipId: string; let terminationResult: Result; + beforeAll(async () => { relationshipId = (await ensureActiveRelationship(services1.transport, services2.transport)).id; @@ -594,7 +595,7 @@ describe("RelationshipTermination", () => { ); }); - test("should not send a message", async () => { + test("should not send a message whose content is not a notification", async () => { const result = await services1.transport.messages.sendMessage({ recipients: [services2.address], content: { @@ -605,7 +606,7 @@ describe("RelationshipTermination", () => { to: [services2.address] } }); - expect(result).toBeAnError(/.*/, "error.transport.messages.missingOrInactiveRelationship"); + expect(result).toBeAnError(/.*/, "error.runtime.messages.hasNoActiveRelationship"); }); test("should not decide a request", async () => { @@ -860,7 +861,7 @@ describe("RelationshipDecomposition", () => { templateId2 = relationship2.template.id; await createRelationshipData(services1, services3); - multipleRecipientsMessageId = (await sendMessageToMultipleRecipients(services1.transport, [services2.address, services3.address])).id; + multipleRecipientsMessageId = (await sendMessageToMultipleRecipients(services1.transport, [services2.address, services3.address])).value.id; await services1.transport.relationships.terminateRelationship({ relationshipId }); await services1.transport.relationships.decomposeRelationship({ relationshipId }); diff --git a/packages/transport/src/core/TransportCoreErrors.ts b/packages/transport/src/core/TransportCoreErrors.ts index 8daba3013..fdaa4be58 100644 --- a/packages/transport/src/core/TransportCoreErrors.ts +++ b/packages/transport/src/core/TransportCoreErrors.ts @@ -86,8 +86,18 @@ class Messages { ); } - public missingOrInactiveRelationship(address: string) { - return new CoreError("error.transport.messages.missingOrInactiveRelationship", `An active Relationship with the given address '${address}' does not exist.`); + public hasNeitherActiveNorTerminatedRelationship(addresses: string[]) { + return new CoreError( + "error.transport.messages.hasNeitherActiveNorTerminatedRelationship", + `The Message cannot be sent as there is neither an active nor a terminated Relationship to the recipient(s) with the following address(es): ${addresses.map((address) => `'${address}'`).join(", ")}.` + ); + } + + public peerIsDeleted(addresses: string[]) { + return new CoreError( + "error.transport.messages.peerIsDeleted", + `The Message cannot be sent due to the deletion of the recipient(s) with the following address(es): ${addresses.map((address) => `'${address}'`).join(", ")}` + ); } } diff --git a/packages/transport/src/modules/messages/MessageController.ts b/packages/transport/src/modules/messages/MessageController.ts index bfa1596ad..965a4d769 100644 --- a/packages/transport/src/modules/messages/MessageController.ts +++ b/packages/transport/src/modules/messages/MessageController.ts @@ -13,6 +13,7 @@ import { File } from "../files/local/File"; import { FileReference } from "../files/transmission/FileReference"; import { RelationshipSecretController } from "../relationships/RelationshipSecretController"; import { RelationshipsController } from "../relationships/RelationshipsController"; +import { PeerDeletionStatus } from "../relationships/local/PeerDeletionInfo"; import { Relationship } from "../relationships/local/Relationship"; import { RelationshipStatus } from "../relationships/transmission/RelationshipStatus"; import { SynchronizedCollection } from "../sync/SynchronizedCollection"; @@ -103,9 +104,11 @@ export class MessageController extends TransportController { @log() public async getMessagesByAddress(address: CoreAddress): Promise { - const relationship = await this.parent.relationships.getActiveRelationshipToIdentity(address); - if (!relationship) { - throw TransportCoreErrors.messages.missingOrInactiveRelationship(address.toString()); + const relationship = await this.parent.relationships.getExistingRelationshipToIdentity(address); + if (!relationship || relationship.status === RelationshipStatus.Pending) { + throw new TransportError( + `Due to the non-existence of a Relationship with 'Active', 'Terminated' or 'DeletionProposed' as status, there are no Messages to the peer with address '${address.toString()}' that could be displayed.` + ); } return await this.getMessagesByRelationshipId(relationship.id); } @@ -289,14 +292,19 @@ export class MessageController extends TransportController { const parsedParams = SendMessageParameters.from(parameters); if (!parsedParams.attachments) parsedParams.attachments = []; + const validationError = await this.validateMessageRecipients(parsedParams.recipients); + if (validationError) throw validationError; + const secret = await CoreCrypto.generateSecretKey(); const serializedSecret = secret.serialize(false); const addressArray: ICoreAddress[] = []; const envelopeRecipients: MessageEnvelopeRecipient[] = []; + for (const recipient of parsedParams.recipients) { - const relationship = await this.relationships.getActiveRelationshipToIdentity(recipient); + const relationship = await this.relationships.getRelationshipToIdentity(recipient); + if (!relationship) { - throw TransportCoreErrors.messages.missingOrInactiveRelationship(recipient.toString()); + throw new TransportError(`Due to the non-existence of a Relationship to the recipient with address '${recipient.toString()}', the Message cannot be sent.`); } const cipherForRecipient = await this.secrets.encrypt(relationship.relationshipSecretId, serializedSecret); @@ -333,9 +341,10 @@ export class MessageController extends TransportController { const addressToRelationshipId: Record = {}; for (const recipient of parsedParams.recipients) { - const relationship = await this.relationships.getActiveRelationshipToIdentity(CoreAddress.from(recipient)); + const relationship = await this.relationships.getRelationshipToIdentity(CoreAddress.from(recipient)); + if (!relationship) { - throw TransportCoreErrors.messages.missingOrInactiveRelationship(recipient.toString()); + throw new TransportError(`Due to the non-existence of a Relationship to the recipient with address '${recipient.toString()}', the Message cannot be sent.`); } const signature = await this.secrets.sign(relationship.relationshipSecretId, plaintextBuffer); @@ -411,6 +420,32 @@ export class MessageController extends TransportController { return message; } + private async validateMessageRecipients(recipients: CoreAddress[]) { + const peersWithNeitherActiveNorTerminatedRelationship: string[] = []; + const deletedPeers: string[] = []; + + for (const recipient of recipients) { + const relationship = await this.relationships.getRelationshipToIdentity(recipient); + + if (!relationship || !(relationship.status === RelationshipStatus.Terminated || relationship.status === RelationshipStatus.Active)) { + peersWithNeitherActiveNorTerminatedRelationship.push(recipient.address); + continue; + } + + if (relationship.peerDeletionInfo?.deletionStatus === PeerDeletionStatus.Deleted) { + deletedPeers.push(recipient.address); + } + } + + if (peersWithNeitherActiveNorTerminatedRelationship.length > 0) { + return TransportCoreErrors.messages.hasNeitherActiveNorTerminatedRelationship(peersWithNeitherActiveNorTerminatedRelationship); + } + + if (deletedPeers.length > 0) return TransportCoreErrors.messages.peerIsDeleted(deletedPeers); + + return; + } + private async decryptOwnEnvelope(envelope: MessageEnvelope, secretKey: CryptoSecretKey): Promise { this.log.trace(`Decrypting own envelope with id ${envelope.id.toString()}...`); diff --git a/packages/transport/test/modules/messages/MessageContent.test.ts b/packages/transport/test/modules/messages/MessageContent.test.ts index 21c0c70a9..6b6732599 100644 --- a/packages/transport/test/modules/messages/MessageContent.test.ts +++ b/packages/transport/test/modules/messages/MessageContent.test.ts @@ -97,7 +97,7 @@ describe("MessageContent", function () { expect(message).toBeDefined(); }); - test("should correctly store the me4ssage (sender)", async function () { + test("should correctly store the message (sender)", async function () { const messages = await sender.messages.getMessagesByAddress(recipient1.identity.address); expect(messages).toHaveLength(2); const message = messages[1]; diff --git a/packages/transport/test/modules/messages/MessageController.test.ts b/packages/transport/test/modules/messages/MessageController.test.ts index 63a484f46..0a5298d65 100644 --- a/packages/transport/test/modules/messages/MessageController.test.ts +++ b/packages/transport/test/modules/messages/MessageController.test.ts @@ -1,6 +1,6 @@ import { IDatabaseConnection } from "@js-soft/docdb-access-abstractions"; import { CoreDate, CoreId } from "@nmshd/core-types"; -import { AccountController, Message, Relationship, Transport } from "../../../src"; +import { AccountController, IdentityDeletionProcess, IdentityDeletionProcessStatus, Message, Relationship, RelationshipStatus, Transport } from "../../../src"; import { TestUtil } from "../../testHelpers/TestUtil"; describe("MessageController", function () { @@ -11,6 +11,7 @@ describe("MessageController", function () { let sender: AccountController; let recipient: AccountController; let recipient2: AccountController; + let recipient3: AccountController; let tempId1: CoreId; let tempId2: CoreId; let tempDate: CoreDate; @@ -45,194 +46,281 @@ describe("MessageController", function () { await transport.init(); - const accounts = await TestUtil.provideAccounts(transport, 3); + const accounts = await TestUtil.provideAccounts(transport, 4); sender = accounts[0]; recipient = accounts[1]; recipient2 = accounts[2]; - relationship = (await TestUtil.addRelationship(sender, recipient)).acceptedRelationshipFromSelf; - relationship2 = (await TestUtil.addRelationship(sender, recipient2)).acceptedRelationshipFromSelf; - relationshipId = relationship.id; + recipient3 = accounts[3]; }); afterAll(async function () { await sender.close(); await recipient.close(); + await recipient2.close(); + await recipient3.close(); await connection.close(); }); - test("should send and receive a Message", async function () { - tempDate = CoreDate.utc().subtract(TestUtil.tempDateThreshold); - const sentMessage = await TestUtil.sendMessage(sender, recipient); + describe("Sending Messages requires existence of active or terminated Relationship", function () { + beforeEach(async () => { + const relationshipBetweenSenderAndRecipient3 = await sender.relationships.getRelationshipToIdentity(recipient3.identity.address); - const messages = await TestUtil.syncUntilHasMessages(recipient, 1); - const receivedMessage = messages[0]; + if (relationshipBetweenSenderAndRecipient3?.status === RelationshipStatus.Pending) { + await TestUtil.revokeRelationship(sender, recipient3); + } + }); - tempId1 = sentMessage.id; + test("cannot send Message for non-existent Relationship", async function () { + await expect(TestUtil.sendMessage(sender, recipient3)).rejects.toThrow("error.transport.messages.hasNeitherActiveNorTerminatedRelationship"); + }); - expectValidMessages(sentMessage, receivedMessage, tempDate); - }); + test("cannot send Message for rejected Relationship", async function () { + const rejectedRelationship = await TestUtil.addRejectedRelationship(sender, recipient3); + expect(rejectedRelationship.status).toBe("Rejected"); - test("should get the cached Message", async function () { - const sentMessage = await sender.messages.getMessage(tempId1); - const receivedMessage = await recipient.messages.getMessage(tempId1); - expect(sentMessage).toBeDefined(); - expect(receivedMessage).toBeDefined(); - expectValidMessages(sentMessage!, receivedMessage!, tempDate); - }); + await expect(TestUtil.sendMessage(sender, recipient3)).rejects.toThrow("error.transport.messages.hasNeitherActiveNorTerminatedRelationship"); + }); - test("should send and receive a second Message", async function () { - tempDate = CoreDate.utc().subtract(TestUtil.tempDateThreshold); - const sentMessage = await TestUtil.sendMessage(sender, recipient); + test("cannot send Message for revoked Relationship", async function () { + await TestUtil.addPendingRelationship(sender, recipient3); + const revokedRelationship = (await TestUtil.revokeRelationship(sender, recipient3)).revokedRelationshipFromSelf; + expect(revokedRelationship.status).toBe("Revoked"); - const messages = await TestUtil.syncUntilHasMessages(recipient, 1); - const receivedMessage = messages[0]; - tempId2 = sentMessage.id; + await expect(TestUtil.sendMessage(sender, recipient3)).rejects.toThrow("error.transport.messages.hasNeitherActiveNorTerminatedRelationship"); + }); - expectValidMessages(sentMessage, receivedMessage, tempDate); - }); + test("cannot send Message for pending Relationship", async function () { + const pendingRelationship = await TestUtil.addPendingRelationship(sender, recipient3); + expect(pendingRelationship.status).toBe("Pending"); - test("should send and receive a third Message", async function () { - tempDate = CoreDate.utc().subtract(TestUtil.tempDateThreshold); - const sentMessage = await TestUtil.sendMessage(sender, recipient); + await expect(TestUtil.sendMessage(sender, recipient3)).rejects.toThrow("error.transport.messages.hasNeitherActiveNorTerminatedRelationship"); + }); - const messages = await TestUtil.syncUntilHasMessages(recipient, 1); - const receivedMessage = messages[0]; + test("cannot send Message for Relationship whose deletion is proposed", async function () { + await TestUtil.addRelationship(sender, recipient3); + await TestUtil.terminateRelationship(recipient3, sender); + const deletionProposedRelationship = await TestUtil.decomposeRelationship(recipient3, sender); + expect(deletionProposedRelationship.status).toBe("DeletionProposed"); - const relationship = await recipient.relationships.getRelationshipToIdentity(receivedMessage.cache!.createdBy); - expectValidMessages(sentMessage, receivedMessage, tempDate); - expect(receivedMessage.cache!.recipients[0].relationshipId!.toString()).toStrictEqual(relationship!.id.toString()); - expect(sentMessage.cache!.recipients[0].relationshipId!.toString()).toStrictEqual(relationship!.id.toString()); - expect(receivedMessage.cache!.recipients[0].receivedByDevice?.toString()).toBe(recipient.activeDevice.id.toString()); + await expect(TestUtil.sendMessage(sender, recipient3)).rejects.toThrow("error.transport.messages.hasNeitherActiveNorTerminatedRelationship"); + }); }); - test("should get the cached messages", async function () { - const sentMessages = await sender.messages.getMessages(); - const receivedMessages = await recipient.messages.getMessages(); - expect(sentMessages).toHaveLength(3); - expect(receivedMessages).toHaveLength(3); - expect(sentMessages[0].id.toString()).toBe(tempId1.toString()); - expect(sentMessages[1].id.toString()).toBe(tempId2.toString()); - expectValidMessages(sentMessages[0], receivedMessages[0], tempDate); - expectValidMessages(sentMessages[1], receivedMessages[1], tempDate); - }); + describe("Sending Messages for active Relationships", function () { + beforeAll(async function () { + relationship = (await TestUtil.addRelationship(sender, recipient)).acceptedRelationshipFromSelf; + relationship2 = (await TestUtil.addRelationship(sender, recipient2)).acceptedRelationshipFromSelf; + relationshipId = relationship.id; + }); - test("should set and get additional metadata", async function () { - const creationTime = CoreDate.utc(); - await sender.messages.setMessageMetadata(tempId1, { myprop: true }); + test("should send and receive a Message", async function () { + tempDate = CoreDate.utc().subtract(TestUtil.tempDateThreshold); + const sentMessage = await TestUtil.sendMessage(sender, recipient); - const file = (await sender.messages.getMessage(tempId1))!; - expect(file.metadata).toBeDefined(); - expect(file.metadata["myprop"]).toBe(true); - expect(file.metadataModifiedAt).toBeDefined(); - expect(file.metadataModifiedAt!.isSameOrBefore(creationTime.add({ seconds: 1 }))).toBe(true); - expect(file.metadataModifiedAt!.isSameOrAfter(creationTime.subtract({ seconds: 5 }))).toBe(true); - }); + const messages = await TestUtil.syncUntilHasMessages(recipient, 1); + const receivedMessage = messages[0]; - test("should get the messages by address (sender)", async function () { - const messages = await sender.messages.getMessagesByAddress(recipient.identity.address); - expect(messages).toHaveLength(3); - }); + tempId1 = sentMessage.id; - test("should get the messages by relationship (sender)", async function () { - const messages = await sender.messages.getMessagesByRelationshipId(relationshipId); - expect(messages).toHaveLength(3); - }); + expectValidMessages(sentMessage, receivedMessage, tempDate); + }); - test("should get the messages by address (recipient)", async function () { - const messages = await recipient.messages.getMessagesByAddress(sender.identity.address); - expect(messages).toHaveLength(3); - }); + test("should get the cached Message", async function () { + const sentMessage = await sender.messages.getMessage(tempId1); + const receivedMessage = await recipient.messages.getMessage(tempId1); + expect(sentMessage).toBeDefined(); + expect(receivedMessage).toBeDefined(); + expectValidMessages(sentMessage!, receivedMessage!, tempDate); + }); - test("should get the messages by relationship (recipient)", async function () { - const messages = await recipient.messages.getMessagesByRelationshipId(relationshipId); - expect(messages).toHaveLength(3); - }); + test("should send and receive a second Message", async function () { + tempDate = CoreDate.utc().subtract(TestUtil.tempDateThreshold); + const sentMessage = await TestUtil.sendMessage(sender, recipient); - test("should mark an unread message as read", async function () { - await TestUtil.sendMessage(sender, recipient); - const messages = await TestUtil.syncUntilHasMessages(recipient, 1); - const message = messages[0]; + const messages = await TestUtil.syncUntilHasMessages(recipient, 1); + const receivedMessage = messages[0]; + tempId2 = sentMessage.id; - const timeBeforeRead = CoreDate.utc(); - const updatedMessage = await recipient.messages.markMessageAsRead(message.id); - const timeAfterRead = CoreDate.utc(); + expectValidMessages(sentMessage, receivedMessage, tempDate); + }); - expect(updatedMessage.wasReadAt).toBeDefined(); - expect(updatedMessage.wasReadAt!.isSameOrAfter(timeBeforeRead)).toBe(true); - expect(updatedMessage.wasReadAt!.isSameOrBefore(timeAfterRead)).toBe(true); - }); + test("should send and receive a third Message", async function () { + tempDate = CoreDate.utc().subtract(TestUtil.tempDateThreshold); + const sentMessage = await TestUtil.sendMessage(sender, recipient); - test("should not change wasReadAt of a read message", async function () { - await TestUtil.sendMessage(sender, recipient); - const messages = await TestUtil.syncUntilHasMessages(recipient, 1); - const message = messages[0]; + const messages = await TestUtil.syncUntilHasMessages(recipient, 1); + const receivedMessage = messages[0]; - const updatedMessage = await recipient.messages.markMessageAsRead(message.id); - const firstReadAt = updatedMessage.wasReadAt; + const relationship = await recipient.relationships.getRelationshipToIdentity(receivedMessage.cache!.createdBy); + expectValidMessages(sentMessage, receivedMessage, tempDate); + expect(receivedMessage.cache!.recipients[0].relationshipId!.toString()).toStrictEqual(relationship!.id.toString()); + expect(sentMessage.cache!.recipients[0].relationshipId!.toString()).toStrictEqual(relationship!.id.toString()); + expect(receivedMessage.cache!.recipients[0].receivedByDevice?.toString()).toBe(recipient.activeDevice.id.toString()); + }); - const unchangedMessage = await recipient.messages.markMessageAsRead(updatedMessage.id); - expect(unchangedMessage.wasReadAt).toBeDefined(); - expect(unchangedMessage.wasReadAt!.equals(firstReadAt!)).toBe(true); - }); + test("should get the cached messages", async function () { + const sentMessages = await sender.messages.getMessages(); + const receivedMessages = await recipient.messages.getMessages(); + expect(sentMessages).toHaveLength(3); + expect(receivedMessages).toHaveLength(3); + expect(sentMessages[0].id.toString()).toBe(tempId1.toString()); + expect(sentMessages[1].id.toString()).toBe(tempId2.toString()); + expectValidMessages(sentMessages[0], receivedMessages[0], tempDate); + expectValidMessages(sentMessages[1], receivedMessages[1], tempDate); + }); - test("should mark a read message as unread", async function () { - await TestUtil.sendMessage(sender, recipient); - const messages = await TestUtil.syncUntilHasMessages(recipient, 1); - const message = messages[0]; + test("should set and get additional metadata", async function () { + const creationTime = CoreDate.utc(); + await sender.messages.setMessageMetadata(tempId1, { myprop: true }); - const readMessage = await recipient.messages.markMessageAsRead(message.id); + const file = (await sender.messages.getMessage(tempId1))!; + expect(file.metadata).toBeDefined(); + expect(file.metadata["myprop"]).toBe(true); + expect(file.metadataModifiedAt).toBeDefined(); + expect(file.metadataModifiedAt!.isSameOrBefore(creationTime.add({ seconds: 1 }))).toBe(true); + expect(file.metadataModifiedAt!.isSameOrAfter(creationTime.subtract({ seconds: 5 }))).toBe(true); + }); - const unreadMessage = await recipient.messages.markMessageAsUnread(readMessage.id); - expect(unreadMessage.wasReadAt).toBeUndefined(); - }); + test("should get the messages by address (sender)", async function () { + const messages = await sender.messages.getMessagesByAddress(recipient.identity.address); + expect(messages).toHaveLength(3); + }); - test("should send and receive a Message (multiple recipients)", async function () { - tempDate = CoreDate.utc().subtract(TestUtil.tempDateThreshold); - const sentMessage = await TestUtil.sendMessage(sender, [recipient, recipient2]); + test("should get the messages by relationship (sender)", async function () { + const messages = await sender.messages.getMessagesByRelationshipId(relationshipId); + expect(messages).toHaveLength(3); + }); - const messages = await TestUtil.syncUntilHasMessages(recipient, 1); - const receivedMessage = messages[0]; + test("should get the messages by address (recipient)", async function () { + const messages = await recipient.messages.getMessagesByAddress(sender.identity.address); + expect(messages).toHaveLength(3); + }); - const messages2 = await TestUtil.syncUntilHasMessages(recipient2, 1); - const receivedMessage2 = messages2[0]; + test("should get the messages by relationship (recipient)", async function () { + const messages = await recipient.messages.getMessagesByRelationshipId(relationshipId); + expect(messages).toHaveLength(3); + }); - tempId1 = sentMessage.id; + test("should mark an unread message as read", async function () { + await TestUtil.sendMessage(sender, recipient); + const messages = await TestUtil.syncUntilHasMessages(recipient, 1); + const message = messages[0]; - expectValidMessages(sentMessage, receivedMessage, tempDate); - expectValidMessages(sentMessage, receivedMessage2, tempDate); - }); + const timeBeforeRead = CoreDate.utc(); + const updatedMessage = await recipient.messages.markMessageAsRead(message.id); + const timeAfterRead = CoreDate.utc(); - test("should delete / pseudonymize messages", async function () { - const multipleRecipientsMessage = await TestUtil.sendMessage(sender, [recipient, recipient2]); - const message = await TestUtil.sendMessage(sender, recipient); - await sender.messages.cleanupMessagesOfDecomposedRelationship(relationship); - - expect(await sender.messages.getMessage(message.id)).toBeUndefined(); - const pseudonymizedMessage = await sender.messages.getMessage(multipleRecipientsMessage.id); - expect(pseudonymizedMessage).toBeDefined(); - expect(pseudonymizedMessage!.cache!.recipients.map((r) => [r.address, r.relationshipId])).toStrictEqual( - expect.arrayContaining([ - [await TestUtil.generateAddressPseudonym(process.env.NMSHD_TEST_BASEURL!), undefined], - [recipient2.identity.address, relationship2.id] - ]) - ); - }); + expect(updatedMessage.wasReadAt).toBeDefined(); + expect(updatedMessage.wasReadAt!.isSameOrAfter(timeBeforeRead)).toBe(true); + expect(updatedMessage.wasReadAt!.isSameOrBefore(timeAfterRead)).toBe(true); + }); + + test("should not change wasReadAt of a read message", async function () { + await TestUtil.sendMessage(sender, recipient); + const messages = await TestUtil.syncUntilHasMessages(recipient, 1); + const message = messages[0]; + + const updatedMessage = await recipient.messages.markMessageAsRead(message.id); + const firstReadAt = updatedMessage.wasReadAt; + + const unchangedMessage = await recipient.messages.markMessageAsRead(updatedMessage.id); + expect(unchangedMessage.wasReadAt).toBeDefined(); + expect(unchangedMessage.wasReadAt!.equals(firstReadAt!)).toBe(true); + }); + + test("should mark a read message as unread", async function () { + await TestUtil.sendMessage(sender, recipient); + const messages = await TestUtil.syncUntilHasMessages(recipient, 1); + const message = messages[0]; + + const readMessage = await recipient.messages.markMessageAsRead(message.id); + + const unreadMessage = await recipient.messages.markMessageAsUnread(readMessage.id); + expect(unreadMessage.wasReadAt).toBeUndefined(); + }); + + test("should send and receive a Message (multiple recipients)", async function () { + tempDate = CoreDate.utc().subtract(TestUtil.tempDateThreshold); + const sentMessage = await TestUtil.sendMessage(sender, [recipient, recipient2]); + + const messages = await TestUtil.syncUntilHasMessages(recipient, 1); + const receivedMessage = messages[0]; + + const messages2 = await TestUtil.syncUntilHasMessages(recipient2, 1); + const receivedMessage2 = messages2[0]; - describe("Relationship Termination", function () { - let messageId: CoreId; + tempId1 = sentMessage.id; + expectValidMessages(sentMessage, receivedMessage, tempDate); + expectValidMessages(sentMessage, receivedMessage2, tempDate); + }); + + test("should delete / pseudonymize messages", async function () { + const multipleRecipientsMessage = await TestUtil.sendMessage(sender, [recipient, recipient2]); + const message = await TestUtil.sendMessage(sender, recipient); + await sender.messages.cleanupMessagesOfDecomposedRelationship(relationship); + + expect(await sender.messages.getMessage(message.id)).toBeUndefined(); + const pseudonymizedMessage = await sender.messages.getMessage(multipleRecipientsMessage.id); + expect(pseudonymizedMessage).toBeDefined(); + expect(pseudonymizedMessage!.cache!.recipients.map((r) => [r.address, r.relationshipId])).toStrictEqual( + expect.arrayContaining([ + [await TestUtil.generateAddressPseudonym(process.env.NMSHD_TEST_BASEURL!), undefined], + [recipient2.identity.address, relationship2.id] + ]) + ); + }); + }); + + describe("Sending Messages for terminated Relationships", function () { beforeAll(async function () { - messageId = (await TestUtil.sendMessage(sender, recipient)).id; await TestUtil.terminateRelationship(sender, recipient); }); - test("should not send a message on a terminated relationship", async function () { - await expect(TestUtil.sendMessage(sender, recipient)).rejects.toThrow("error.transport.messages.missingOrInactiveRelationship"); + test("should be able to send a Message on a terminated Relationship", async function () { + await expect(TestUtil.sendMessage(sender, recipient)).resolves.not.toThrow(); }); - test("should still decrypt the message", async function () { + test("should decrypt a Message on a terminated Relationship", async function () { + const messageId = (await TestUtil.sendMessage(sender, recipient)).id; await expect(sender.messages.fetchCaches([messageId])).resolves.not.toThrow(); await expect(recipient.messages.fetchCaches([messageId])).resolves.not.toThrow(); }); + + test("should be able to receive a Message sent on a terminated Relationship after the Relationship was reactivated", async function () { + const idOfSentMessageDuringTerminatedRelationship = (await TestUtil.sendMessage(sender, recipient)).id; + + await TestUtil.reactivateRelationship(sender, recipient); + + const receivedMessages = await TestUtil.syncUntilHasMessages(recipient); + const idOfReceivedMessageAfterReactivation = receivedMessages[receivedMessages.length - 1].id; + expect(idOfReceivedMessageAfterReactivation).toStrictEqual(idOfSentMessageDuringTerminatedRelationship); + }); + }); + + describe("Recipient of the Message is in deletion", function () { + let identityDeletionProcessOfRecipient: IdentityDeletionProcess; + + beforeEach(async function () { + const approvedIdentityDeletionProcess = await recipient.identityDeletionProcess.getIdentityDeletionProcessByStatus(IdentityDeletionProcessStatus.Approved); + if (!approvedIdentityDeletionProcess) { + identityDeletionProcessOfRecipient = await recipient.identityDeletionProcess.initiateIdentityDeletionProcess(); + await TestUtil.syncUntilHasRelationships(sender); + } + }); + + test("should be able to send a Message if the recipient is in deletion", async function () { + await expect(TestUtil.sendMessage(sender, recipient)).resolves.not.toThrow(); + }); + + test("should be able to receive a Message sent during its recipient was in deletion after the recipient cancelled its deletion process", async function () { + const idOfSentMessageDuringRecipientInDeletion = (await TestUtil.sendMessage(sender, recipient)).id; + + await recipient.identityDeletionProcess.cancelIdentityDeletionProcess(identityDeletionProcessOfRecipient.id.toString()); + + const receivedMessages = await TestUtil.syncUntilHasMessages(recipient); + const idOfReceivedMessageAfterCancellation = receivedMessages[receivedMessages.length - 1].id; + expect(idOfReceivedMessageAfterCancellation).toStrictEqual(idOfSentMessageDuringRecipientInDeletion); + }); }); }); diff --git a/packages/transport/test/testHelpers/TestUtil.ts b/packages/transport/test/testHelpers/TestUtil.ts index 42f09ef77..eca67b296 100644 --- a/packages/transport/test/testHelpers/TestUtil.ts +++ b/packages/transport/test/testHelpers/TestUtil.ts @@ -287,7 +287,7 @@ export class TestUtil { return accountController; } - public static async addRejectedRelationship(from: AccountController, to: AccountController): Promise { + public static async addRejectedRelationship(from: AccountController, to: AccountController): Promise { const templateFrom = await from.relationshipTemplates.sendRelationshipTemplate({ content: { mycontent: "template" @@ -315,11 +315,42 @@ export class TestUtil { const rejectedRelationshipFromSelf = await from.relationships.reject(pendingRelationship.id); expect(rejectedRelationshipFromSelf.status).toStrictEqual(RelationshipStatus.Rejected); - // Get accepted relationship + // Get rejected relationship const syncedRelationshipsPeer = await TestUtil.syncUntilHasRelationships(to); expect(syncedRelationshipsPeer).toHaveLength(1); - const acceptedRelationshipPeer = syncedRelationshipsPeer[0]; - expect(acceptedRelationshipPeer.status).toStrictEqual(RelationshipStatus.Rejected); + const rejectedRelationshipPeer = syncedRelationshipsPeer[0]; + expect(rejectedRelationshipPeer.status).toStrictEqual(RelationshipStatus.Rejected); + + return rejectedRelationshipFromSelf; + } + + public static async addPendingRelationship(from: AccountController, to: AccountController, template?: RelationshipTemplate): Promise { + const templateFrom = + template ?? + (await from.relationshipTemplates.sendRelationshipTemplate({ + content: { + mycontent: "template" + }, + expiresAt: CoreDate.utc().add({ minutes: 5 }), + maxNumberOfAllocations: 1 + })); + + const templateReference = templateFrom.toRelationshipTemplateReference().truncate(); + const templateTo = await to.relationshipTemplates.loadPeerRelationshipTemplateByTruncated(templateReference); + + await to.relationships.sendRelationship({ + template: templateTo, + creationContent: { + mycontent: "request" + } + }); + + const syncedRelationshipsFromSelf = await TestUtil.syncUntilHasRelationships(from); + expect(syncedRelationshipsFromSelf).toHaveLength(1); + const pendingRelationshipFromSelf = syncedRelationshipsFromSelf[0]; + expect(pendingRelationshipFromSelf.status).toStrictEqual(RelationshipStatus.Pending); + + return pendingRelationshipFromSelf; } public static async addRelationship( @@ -342,23 +373,9 @@ export class TestUtil { to: AccountController, template: RelationshipTemplate ): Promise<{ acceptedRelationshipFromSelf: Relationship; acceptedRelationshipPeer: Relationship }> { - const reference = template.toRelationshipTemplateReference().truncate(); - const templateTo = await to.relationshipTemplates.loadPeerRelationshipTemplateByTruncated(reference); - - const relRequest = await to.relationships.sendRelationship({ - template: templateTo, - creationContent: { - mycontent: "request" - } - }); - - // Accept relationship - const syncedRelationships = await TestUtil.syncUntilHasRelationships(from); - expect(syncedRelationships).toHaveLength(1); - const pendingRelationship = syncedRelationships[0]; - expect(pendingRelationship.status).toStrictEqual(RelationshipStatus.Pending); + const pendingRelationshipFromSelf = await TestUtil.addPendingRelationship(from, to, template); - const acceptedRelationshipFromSelf = await from.relationships.accept(pendingRelationship.id); + const acceptedRelationshipFromSelf = await from.relationships.accept(pendingRelationshipFromSelf.id); expect(acceptedRelationshipFromSelf.status).toStrictEqual(RelationshipStatus.Active); // Get accepted relationship @@ -370,12 +387,22 @@ export class TestUtil { expect(syncedRelationshipsPeer).toHaveLength(1); const acceptedRelationshipPeer = syncedRelationshipsPeer[0]; expect(acceptedRelationshipPeer.status).toStrictEqual(RelationshipStatus.Active); - expect(relRequest.id.toString()).toBe(acceptedRelationshipFromSelf.id.toString()); - expect(relRequest.id.toString()).toBe(acceptedRelationshipPeer.id.toString()); + expect(acceptedRelationshipFromSelf.id.toString()).toBe(acceptedRelationshipPeer.id.toString()); return { acceptedRelationshipFromSelf, acceptedRelationshipPeer }; } + public static async revokeRelationship( + from: AccountController, + to: AccountController + ): Promise<{ revokedRelationshipFromSelf: Relationship; revokedRelationshipPeer: Relationship }> { + const relationshipId = (await to.relationships.getRelationshipToIdentity(from.identity.address))!.id; + const revokedRelationshipPeer = await to.relationships.revoke(relationshipId); + const revokedRelationshipFromSelf = (await TestUtil.syncUntil(from, (syncResult) => syncResult.relationships.length > 0)).relationships[0]; + + return { revokedRelationshipFromSelf, revokedRelationshipPeer }; + } + public static async terminateRelationship( from: AccountController, to: AccountController @@ -387,6 +414,19 @@ export class TestUtil { return { terminatedRelationshipFromSelf, terminatedRelationshipPeer }; } + public static async reactivateRelationship( + from: AccountController, + to: AccountController + ): Promise<{ reactivatedRelationshipFromSelf: Relationship; reactivatedRelationshipPeer: Relationship }> { + const relationshipId = (await from.relationships.getRelationshipToIdentity(to.identity.address))!.id; + await from.relationships.requestReactivation(relationshipId); + await TestUtil.syncUntil(to, (syncResult) => syncResult.relationships.length > 0); + const reactivatedRelationshipFromSelf = await to.relationships.acceptReactivation(relationshipId); + const reactivatedRelationshipPeer = (await TestUtil.syncUntil(from, (syncResult) => syncResult.relationships.length > 0)).relationships[0]; + + return { reactivatedRelationshipFromSelf, reactivatedRelationshipPeer }; + } + public static async decomposeRelationship(from: AccountController, to: AccountController): Promise { const relationship = (await from.relationships.getRelationshipToIdentity(to.identity.address))!; await from.relationships.decompose(relationship.id); From c3f5ac287305dce55348801b52eaaf4a61c223a6 Mon Sep 17 00:00:00 2001 From: Britta Stallknecht <146106656+britsta@users.noreply.github.com> Date: Wed, 4 Dec 2024 09:58:20 +0100 Subject: [PATCH 20/21] Flaky queued Notifications test (#357) * fix: OwnSharedAttributeDeletedByOwnerNotificationItem not yet processed * fix: did not wait for running Message received event handler --- packages/runtime/test/transport/messages.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/runtime/test/transport/messages.test.ts b/packages/runtime/test/transport/messages.test.ts index dce7daf92..cb7da7106 100644 --- a/packages/runtime/test/transport/messages.test.ts +++ b/packages/runtime/test/transport/messages.test.ts @@ -11,6 +11,7 @@ import { MessageReceivedEvent, MessageSentEvent, MessageWasReadAtChangedEvent, + OwnSharedAttributeDeletedByOwnerEvent, OwnSharedAttributeSucceededEvent, PeerDeletionCancelledEvent, PeerToBeDeletedEvent, @@ -448,6 +449,7 @@ describe("Postponed Notifications via Messages", () => { const postponedMessages = await syncUntilHasMessages(client5.transport); expect(postponedMessages).toHaveLength(1); + await client5.eventBus.waitForRunningEventHandlers(); const postponedNotification = await client5.consumption.notifications.getNotification({ id: notificationId.toString() }); expect(postponedNotification).toBeSuccessful(); }); @@ -508,6 +510,8 @@ describe("Postponed Notifications via Messages", () => { const postponedDeletionNotification = await client5.consumption.notifications.getNotification({ id: notifyAboutDeletionResult.notificationId }); expect(postponedDeletionNotification).toBeSuccessful(); + await client5.eventBus.waitForEvent(OwnSharedAttributeDeletedByOwnerEvent); + const peerSharedIdentityAttribute = (await client5.consumption.attributes.getAttribute({ id: ownSharedIdentityAttribute.id })).value; assert(peerSharedIdentityAttribute.succeededBy); assert(peerSharedIdentityAttribute.deletionInfo?.deletionDate); From 745223ce64ec708b274484cbe47d429e2b2461f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julian=20K=C3=B6nig?= <33655937+jkoenig134@users.noreply.github.com> Date: Wed, 4 Dec 2024 12:39:17 +0100 Subject: [PATCH 21/21] Simplify identity deletion process handling on second device by republishing events (#347) * WIP: Enhance LocalAccountDTO with deletion Info * feat: try to untangle changes * feat: add getAccounts(Not)InDeletion functions * test: clean up IdentityDeletionProcessStatusChangedModule test * refactor: stuff * refactor: clean up changes * test: notes * feat: publish DatawalletSynchronizedEvent in AccountController * test: receive DatawalletSynchronizedEvent calling syncDatawallet use case * feat: use runtime event in app-runtime * feat: add DatawalletSynchronized module * chore: remove LocalAccountDeletionDateChangedModule * test: clean up IdentityDeletionProcessStatusChangedModule test * fix: DatawalletSynchronizedModule * fix: don't publish event updating LocalAccount deletionDate * fix and test: getAccounts(Not)InDeletion * test: don's skip tests * test: remove unrelated test * chore: remove dangerous getters * fix: write deletionDate to cached local account from MultiAccountController * fix: use correct apis * refactor: massively simplify tests * chore: naming * chore: more asserts * refactor: move event publish location * fix: change location of publishing * refactor: collect IDs for changed objects during datawallet sync * feat: re-publish event * refactor: update usage * chore: simplify method * refactor: remove all occurences of the DatawalletSynchronizedModule * fix: publish event * fix: for..of instead of for..in * fix: properly await events * chore: remove unused testutils * chore: use proper eventbus * chore: use for loop * refactor: allow to announce that there are changes in IdentityDeletionProcesses without having a specific * chore: simplify publishing * chore: test wording * feat: update logic * fix: tests --------- Co-authored-by: Siolto Co-authored-by: Milena Czierlinski Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com> --- packages/app-runtime/src/AppConfig.ts | 6 - packages/app-runtime/src/AppRuntime.ts | 2 - .../DatawalletSynchronizedModule.ts | 59 ------- ...ntityDeletionProcessStatusChangedModule.ts | 35 ++++- .../src/modules/runtimeEvents/index.ts | 1 - .../multiAccount/MultiAccountController.ts | 4 +- packages/app-runtime/test/lib/TestUtil.ts | 44 +----- .../DatawalletSynchronizedModule.test.ts | 83 ---------- ...DeletionProcessStatusChangedModule.test.ts | 146 +++++++++++++++--- .../test/runtime/AccountName.test.ts | 2 +- .../test/runtime/TranslationProvider.test.ts | 2 +- packages/consumption/test/core/TestUtil.ts | 2 +- packages/runtime/src/events/EventProxy.ts | 5 +- ...entityDeletionProcessStatusChangedEvent.ts | 4 +- .../transport/identityDeletionProcess.test.ts | 24 +-- ...entityDeletionProcessStatusChangedEvent.ts | 4 +- .../src/modules/accounts/AccountController.ts | 15 +- .../src/modules/sync/ChangedItems.ts | 7 +- .../sync/DatawalletModificationsProcessor.ts | 12 ++ .../src/modules/sync/SyncController.ts | 76 ++++----- .../modules/devices/DeviceOnboarding.test.ts | 4 +- 21 files changed, 244 insertions(+), 293 deletions(-) delete mode 100644 packages/app-runtime/src/modules/runtimeEvents/DatawalletSynchronizedModule.ts delete mode 100644 packages/app-runtime/test/modules/DatawalletSynchronizedModule.test.ts diff --git a/packages/app-runtime/src/AppConfig.ts b/packages/app-runtime/src/AppConfig.ts index b92b91798..2cd3b6e15 100644 --- a/packages/app-runtime/src/AppConfig.ts +++ b/packages/app-runtime/src/AppConfig.ts @@ -52,12 +52,6 @@ export function createAppConfig(...configs: AppConfigOverwrite[]): AppConfig { location: "onboardingChangeReceived", enabled: true }, - datawalletSynchronized: { - name: "datawalletSynchronized", - displayName: "Datawallet Synchronized Module", - location: "datawalletSynchronized", - enabled: true - }, identityDeletionProcessStatusChanged: { name: "identityDeletionProcessStatusChanged", displayName: "Identity Deletion Process Status Changed Module", diff --git a/packages/app-runtime/src/AppRuntime.ts b/packages/app-runtime/src/AppRuntime.ts index 62a409cd2..42bc87722 100644 --- a/packages/app-runtime/src/AppRuntime.ts +++ b/packages/app-runtime/src/AppRuntime.ts @@ -15,7 +15,6 @@ import { AppLaunchModule, AppRuntimeModuleConfiguration, AppSyncModule, - DatawalletSynchronizedModule, IAppRuntimeModuleConstructor, IdentityDeletionProcessStatusChangedModule, MailReceivedModule, @@ -264,7 +263,6 @@ export class AppRuntime extends Runtime { pushNotification: PushNotificationModule, mailReceived: MailReceivedModule, onboardingChangeReceived: OnboardingChangeReceivedModule, - datawalletSynchronized: DatawalletSynchronizedModule, identityDeletionProcessStatusChanged: IdentityDeletionProcessStatusChangedModule, messageReceived: MessageReceivedModule, relationshipChanged: RelationshipChangedModule, diff --git a/packages/app-runtime/src/modules/runtimeEvents/DatawalletSynchronizedModule.ts b/packages/app-runtime/src/modules/runtimeEvents/DatawalletSynchronizedModule.ts deleted file mode 100644 index 45e8cb76d..000000000 --- a/packages/app-runtime/src/modules/runtimeEvents/DatawalletSynchronizedModule.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { CoreDate } from "@nmshd/core-types"; -import { DatawalletSynchronizedEvent, IdentityDeletionProcessStatus } from "@nmshd/runtime"; -import { AppRuntimeError } from "../../AppRuntimeError"; -import { LocalAccountDeletionDateChangedEvent } from "../../events"; -import { LocalAccountMapper } from "../../multiAccount"; -import { AppRuntimeModule, AppRuntimeModuleConfiguration } from "../AppRuntimeModule"; - -export interface DatawalletSynchronizedModuleConfig extends AppRuntimeModuleConfiguration {} - -export class DatawalletSynchronizedModuleError extends AppRuntimeError {} - -export class DatawalletSynchronizedModule extends AppRuntimeModule { - public async init(): Promise { - // Nothing to do here - } - - public start(): Promise | void { - this.subscribeToEvent(DatawalletSynchronizedEvent, this.handleDatawalletSynchronized.bind(this)); - } - - private async handleDatawalletSynchronized(event: DatawalletSynchronizedEvent) { - const services = await this.runtime.getServices(event.eventTargetAddress); - const identityDeletionProcessResult = await services.transportServices.identityDeletionProcesses.getIdentityDeletionProcesses(); - - if (identityDeletionProcessResult.isError) { - this.logger.error(identityDeletionProcessResult); - return; - } - - if (identityDeletionProcessResult.value.length === 0) return; - - const mostRecentIdentityDeletionProcess = identityDeletionProcessResult.value.at(-1)!; - let newDeletionDate; - switch (mostRecentIdentityDeletionProcess.status) { - case IdentityDeletionProcessStatus.Approved: - newDeletionDate = CoreDate.from(mostRecentIdentityDeletionProcess.gracePeriodEndsAt!); - break; - case IdentityDeletionProcessStatus.Cancelled: - case IdentityDeletionProcessStatus.Rejected: - case IdentityDeletionProcessStatus.WaitingForApproval: - newDeletionDate = undefined; - break; - } - - const account = await this.runtime.multiAccountController.getAccountByAddress(event.eventTargetAddress); - const previousDeletionDate = account.deletionDate; - - if (previousDeletionDate === newDeletionDate) return; - - await this.runtime.multiAccountController.updateLocalAccountDeletionDate(event.eventTargetAddress, newDeletionDate); - - const updatedAccount = await this.runtime.multiAccountController.getAccountByAddress(event.eventTargetAddress); - this.runtime.eventBus.publish(new LocalAccountDeletionDateChangedEvent(event.eventTargetAddress, LocalAccountMapper.toLocalAccountDTO(updatedAccount))); - } - - public override stop(): Promise | void { - this.unsubscribeFromAllEvents(); - } -} diff --git a/packages/app-runtime/src/modules/runtimeEvents/IdentityDeletionProcessStatusChangedModule.ts b/packages/app-runtime/src/modules/runtimeEvents/IdentityDeletionProcessStatusChangedModule.ts index a8306b593..be0319832 100644 --- a/packages/app-runtime/src/modules/runtimeEvents/IdentityDeletionProcessStatusChangedModule.ts +++ b/packages/app-runtime/src/modules/runtimeEvents/IdentityDeletionProcessStatusChangedModule.ts @@ -1,6 +1,8 @@ import { CoreDate } from "@nmshd/core-types"; import { IdentityDeletionProcessStatus, IdentityDeletionProcessStatusChangedEvent } from "@nmshd/runtime"; import { AppRuntimeError } from "../../AppRuntimeError"; +import { LocalAccountDeletionDateChangedEvent } from "../../events"; +import { LocalAccountMapper } from "../../multiAccount/data/LocalAccountMapper"; import { AppRuntimeModule, AppRuntimeModuleConfiguration } from "../AppRuntimeModule"; export interface IdentityDeletionProcessStatusChangedModuleConfig extends AppRuntimeModuleConfiguration {} @@ -19,18 +21,24 @@ export class IdentityDeletionProcessStatusChangedModule extends AppRuntimeModule private async handleIdentityDeletionProcessStatusChanged(event: IdentityDeletionProcessStatusChangedEvent) { const identityDeletionProcess = event.data; + if (!identityDeletionProcess) { + const services = await this.runtime.getServices(event.eventTargetAddress); + const identityDeletionProcesses = await services.transportServices.identityDeletionProcesses.getIdentityDeletionProcesses(); + + const approvedIdentityDeletionProcess = identityDeletionProcesses.value.filter((idp) => idp.status === IdentityDeletionProcessStatus.Approved).at(0); + const deletionDate = approvedIdentityDeletionProcess?.gracePeriodEndsAt ? CoreDate.from(approvedIdentityDeletionProcess.gracePeriodEndsAt) : undefined; + + await this.updateLocalAccountDeletionDate(event.eventTargetAddress, deletionDate, true); + return; + } + switch (identityDeletionProcess.status) { case IdentityDeletionProcessStatus.Approved: - await this.runtime.multiAccountController.updateLocalAccountDeletionDate(event.eventTargetAddress, CoreDate.from(identityDeletionProcess.gracePeriodEndsAt!)); + await this.updateLocalAccountDeletionDate(event.eventTargetAddress, CoreDate.from(identityDeletionProcess.gracePeriodEndsAt!)); break; case IdentityDeletionProcessStatus.Cancelled: - const account = await this.runtime.multiAccountController.getAccountByAddress(event.eventTargetAddress); - const previousDeletionDate = account.deletionDate; - - if (!previousDeletionDate) break; - - await this.runtime.multiAccountController.updateLocalAccountDeletionDate(event.eventTargetAddress, undefined); + await this.updateLocalAccountDeletionDate(event.eventTargetAddress, undefined); break; default: @@ -38,6 +46,19 @@ export class IdentityDeletionProcessStatusChangedModule extends AppRuntimeModule } } + private async updateLocalAccountDeletionDate(eventTargetAddress: string, deletionDate: CoreDate | undefined, publishEvent = false) { + const account = await this.runtime.multiAccountController.getAccountByAddress(eventTargetAddress); + const previousDeletionDate = account.deletionDate; + + if (!deletionDate && !previousDeletionDate) return; + if (deletionDate && previousDeletionDate && deletionDate.equals(previousDeletionDate)) return; + + const localAccount = await this.runtime.multiAccountController.updateLocalAccountDeletionDate(eventTargetAddress, deletionDate); + + if (!publishEvent) return; + this.runtime.eventBus.publish(new LocalAccountDeletionDateChangedEvent(eventTargetAddress, LocalAccountMapper.toLocalAccountDTO(localAccount))); + } + public override stop(): Promise | void { this.unsubscribeFromAllEvents(); } diff --git a/packages/app-runtime/src/modules/runtimeEvents/index.ts b/packages/app-runtime/src/modules/runtimeEvents/index.ts index e37a1aa45..7a3710bda 100644 --- a/packages/app-runtime/src/modules/runtimeEvents/index.ts +++ b/packages/app-runtime/src/modules/runtimeEvents/index.ts @@ -1,4 +1,3 @@ -export * from "./DatawalletSynchronizedModule"; export * from "./IdentityDeletionProcessStatusChangedModule"; export * from "./MessageReceivedModule"; export * from "./RelationshipChangedModule"; diff --git a/packages/app-runtime/src/multiAccount/MultiAccountController.ts b/packages/app-runtime/src/multiAccount/MultiAccountController.ts index 08ce2db46..fe9e45f3f 100644 --- a/packages/app-runtime/src/multiAccount/MultiAccountController.ts +++ b/packages/app-runtime/src/multiAccount/MultiAccountController.ts @@ -251,7 +251,7 @@ export class MultiAccountController { await this._localAccounts.update(oldAccount, renamedAccount); } - public async updateLocalAccountDeletionDate(address: string, deletionDate?: CoreDate): Promise { + public async updateLocalAccountDeletionDate(address: string, deletionDate?: CoreDate): Promise { const oldAccount = await this._localAccounts.findOne({ address }); if (!oldAccount) { @@ -265,6 +265,8 @@ export class MultiAccountController { const cachedAccount = this.sessionStorage.findSession(address)?.account; if (cachedAccount) cachedAccount.deletionDate = deletionDate?.toString(); + + return account; } public async updateLastAccessedAt(accountId: string): Promise { diff --git a/packages/app-runtime/test/lib/TestUtil.ts b/packages/app-runtime/test/lib/TestUtil.ts index 2cebf2259..2190dbcf6 100644 --- a/packages/app-runtime/test/lib/TestUtil.ts +++ b/packages/app-runtime/test/lib/TestUtil.ts @@ -1,7 +1,7 @@ /* eslint-disable jest/no-standalone-expect */ import { ILoggerFactory } from "@js-soft/logging-abstractions"; import { SimpleLoggerFactory } from "@js-soft/simple-logger"; -import { EventBus, Result, sleep, SubscriptionTarget } from "@js-soft/ts-utils"; +import { EventBus, Result, sleep } from "@js-soft/ts-utils"; import { ArbitraryMessageContent, ArbitraryRelationshipCreationContent, ArbitraryRelationshipTemplateContent } from "@nmshd/content"; import { CoreDate } from "@nmshd/core-types"; import { @@ -81,46 +81,6 @@ export class TestUtil { TransportLoggerFactory.init(this.oldLogger); } - public static async awaitEvent( - runtime: AppRuntime, - subscriptionTarget: SubscriptionTarget, - timeout?: number, - assertionFunction?: (t: TEvent) => boolean - ): Promise { - const eventBus = runtime.eventBus; - let subscriptionId: number; - - const eventPromise = new Promise((resolve) => { - subscriptionId = eventBus.subscribe(subscriptionTarget, (event: TEvent) => { - if (assertionFunction && !assertionFunction(event)) return; - - resolve(event); - }); - }); - if (!timeout) { - return await eventPromise.finally(() => eventBus.unsubscribe(subscriptionId)); - } - - let timeoutId: NodeJS.Timeout; - const timeoutPromise = new Promise((_resolve, reject) => { - timeoutId = setTimeout( - () => reject(new Error(`timeout exceeded for waiting for event ${typeof subscriptionTarget === "string" ? subscriptionTarget : subscriptionTarget.name}`)), - timeout - ); - }); - - return await Promise.race([eventPromise, timeoutPromise]).finally(() => { - eventBus.unsubscribe(subscriptionId); - clearTimeout(timeoutId); - }); - } - - public static async expectEvent(runtime: AppRuntime, subscriptionTarget: SubscriptionTarget, timeoutInMS = 1000): Promise { - const eventInstance: T = await this.awaitEvent(runtime, subscriptionTarget, timeoutInMS); - expect(eventInstance, "Event received").toBeDefined(); - return eventInstance; - } - public static expectThrows(method: Function | Promise, errorMessageRegexp: RegExp | string): void { let error: Error | undefined; try { @@ -292,7 +252,7 @@ export class TestUtil { expiresAt: CoreDate.utc().add({ minutes: 5 }).toString(), filename: "Test.bin", mimetype: "application/json", - title: "Test", + title: "aFileName", content: fileContent }); return file.value; diff --git a/packages/app-runtime/test/modules/DatawalletSynchronizedModule.test.ts b/packages/app-runtime/test/modules/DatawalletSynchronizedModule.test.ts deleted file mode 100644 index 961aa0f73..000000000 --- a/packages/app-runtime/test/modules/DatawalletSynchronizedModule.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { CoreId } from "@nmshd/core-types"; -import { IdentityDeletionProcessStatus } from "@nmshd/runtime"; -import { AppRuntime, LocalAccountDeletionDateChangedEvent, LocalAccountMapper, LocalAccountSession } from "../../src"; -import { TestUtil } from "../lib"; - -describe("DatawalletSynchronized", function () { - let runtimeDevice1: AppRuntime; - let sessionDevice1: LocalAccountSession; - - let runtimeDevice2: AppRuntime; - let sessionDevice2: LocalAccountSession; - - beforeAll(async function () { - runtimeDevice1 = await TestUtil.createRuntime(); - await runtimeDevice1.start(); - - const [localAccountDevice1] = await TestUtil.provideAccounts(runtimeDevice1, 1); - sessionDevice1 = await runtimeDevice1.selectAccount(localAccountDevice1.id); - - runtimeDevice2 = await TestUtil.createRuntime(); - await runtimeDevice2.start(); - - const createDeviceResult = await sessionDevice1.transportServices.devices.createDevice({ name: "test", isAdmin: true }); - const onboardingInfoResult = await sessionDevice1.transportServices.devices.getDeviceOnboardingInfo({ id: createDeviceResult.value.id, profileName: "Test" }); - const localAccountDevice2 = await runtimeDevice2.accountServices.onboardAccount(onboardingInfoResult.value); - sessionDevice2 = await runtimeDevice2.selectAccount(localAccountDevice2.id.toString()); - - await sessionDevice1.transportServices.account.syncDatawallet(); - await sessionDevice2.transportServices.account.syncDatawallet(); - }); - - afterEach(async () => { - const activeIdentityDeletionProcess = await sessionDevice1.transportServices.identityDeletionProcesses.getActiveIdentityDeletionProcess(); - if (!activeIdentityDeletionProcess.isSuccess) return; - - if (activeIdentityDeletionProcess.value.status === IdentityDeletionProcessStatus.Approved) { - const abortResult = await sessionDevice1.transportServices.identityDeletionProcesses.cancelIdentityDeletionProcess(); - if (abortResult.isError) throw abortResult.error; - - await sessionDevice2.transportServices.account.syncDatawallet(); - await TestUtil.awaitEvent(runtimeDevice2, LocalAccountDeletionDateChangedEvent); - } - }); - - afterAll(async function () { - await runtimeDevice1.stop(); - await runtimeDevice2.stop(); - }); - - test("should set the deletionDate on the LocalAccount on a second device when an IdentityDeletionProcess is initiated", async function () { - const initiateDeletionResult = await sessionDevice1.transportServices.identityDeletionProcesses.initiateIdentityDeletionProcess(); - expect(sessionDevice2.account.deletionDate).toBeUndefined(); - - await sessionDevice2.transportServices.account.syncDatawallet(); - const event = await TestUtil.awaitEvent(runtimeDevice2, LocalAccountDeletionDateChangedEvent); - const updatedAccount = await runtimeDevice2.multiAccountController.getAccountByAddress(sessionDevice2.account.address!); - - expect(event.data).toStrictEqual(LocalAccountMapper.toLocalAccountDTO(updatedAccount)); - expect(event.data.deletionDate).toBe(initiateDeletionResult.value.gracePeriodEndsAt); - - const account = await runtimeDevice2.multiAccountController.getAccount(CoreId.from(sessionDevice2.account.id)); - expect(account.deletionDate!.toString()).toBe(initiateDeletionResult.value.gracePeriodEndsAt); - }); - - test("should unset the deletionDate on the LocalAccount on a second device when an IdentityDeletionProcess is cancelled", async function () { - await sessionDevice1.transportServices.identityDeletionProcesses.initiateIdentityDeletionProcess(); - await sessionDevice2.transportServices.account.syncDatawallet(); - await TestUtil.awaitEvent(runtimeDevice2, LocalAccountDeletionDateChangedEvent); - - await sessionDevice1.transportServices.identityDeletionProcesses.cancelIdentityDeletionProcess(); - expect(sessionDevice2.account.deletionDate).toBeDefined(); - - await sessionDevice2.transportServices.account.syncDatawallet(); - const event = await TestUtil.awaitEvent(runtimeDevice2, LocalAccountDeletionDateChangedEvent); - const updatedAccount = await runtimeDevice2.multiAccountController.getAccountByAddress(sessionDevice2.account.address!); - - expect(event.data).toStrictEqual(LocalAccountMapper.toLocalAccountDTO(updatedAccount)); - expect(event.data.deletionDate).toBeUndefined(); - - const account = await runtimeDevice2.multiAccountController.getAccount(CoreId.from(sessionDevice2.account.id)); - expect(account.deletionDate).toBeUndefined(); - }); -}); diff --git a/packages/app-runtime/test/modules/IdentityDeletionProcessStatusChangedModule.test.ts b/packages/app-runtime/test/modules/IdentityDeletionProcessStatusChangedModule.test.ts index 88a4648ec..e11cd4457 100644 --- a/packages/app-runtime/test/modules/IdentityDeletionProcessStatusChangedModule.test.ts +++ b/packages/app-runtime/test/modules/IdentityDeletionProcessStatusChangedModule.test.ts @@ -1,51 +1,151 @@ import { CoreId } from "@nmshd/core-types"; import { IdentityDeletionProcessStatus } from "@nmshd/runtime"; -import { AppRuntime, LocalAccountSession } from "../../src"; -import { TestUtil } from "../lib"; +import { AppRuntime, LocalAccountDeletionDateChangedEvent, LocalAccountDTO, LocalAccountSession } from "../../src"; +import { MockEventBus, TestUtil } from "../lib"; describe("IdentityDeletionProcessStatusChanged", function () { - let runtime: AppRuntime; - let session: LocalAccountSession; + const eventBusRuntime1 = new MockEventBus(); + let runtime1: AppRuntime; + let localAccount: LocalAccountDTO; + let session1: LocalAccountSession; + + const eventBusRuntime2 = new MockEventBus(); + let runtime2: AppRuntime | undefined; + let session2: LocalAccountSession | undefined; beforeAll(async function () { - runtime = await TestUtil.createRuntime(); - await runtime.start(); + runtime1 = await TestUtil.createRuntime(undefined, undefined, eventBusRuntime1); + await runtime1.start(); - const [localAccount] = await TestUtil.provideAccounts(runtime, 1); - session = await runtime.selectAccount(localAccount.id); + [localAccount] = await TestUtil.provideAccounts(runtime1, 1); + session1 = await runtime1.selectAccount(localAccount.id); }); afterEach(async () => { - const activeIdentityDeletionProcess = await session.transportServices.identityDeletionProcesses.getActiveIdentityDeletionProcess(); + const activeIdentityDeletionProcess = await session1.transportServices.identityDeletionProcesses.getActiveIdentityDeletionProcess(); if (!activeIdentityDeletionProcess.isSuccess) return; if (activeIdentityDeletionProcess.value.status === IdentityDeletionProcessStatus.Approved) { - const abortResult = await session.transportServices.identityDeletionProcesses.cancelIdentityDeletionProcess(); + const abortResult = await session1.transportServices.identityDeletionProcesses.cancelIdentityDeletionProcess(); if (abortResult.isError) throw abortResult.error; + + if (session2 && runtime2 && session2.account.deletionDate) { + await session2.transportServices.account.syncDatawallet(); + await eventBusRuntime2.waitForRunningEventHandlers(); + } } }); - afterAll(async () => await runtime.stop()); + afterAll(async function () { + await runtime1.stop(); + await runtime2?.stop(); + await eventBusRuntime2.close(); + }); - test("should set the deletionDate on the LocalAccount initiating an IdentityDeletionProcess", async function () { - expect(session.account.deletionDate).toBeUndefined(); + test("should set the deletionDate of the LocalAccount initiating an IdentityDeletionProcess", async function () { + expect(session1.account.deletionDate).toBeUndefined(); - const initiateDeletionResult = await session.transportServices.identityDeletionProcesses.initiateIdentityDeletionProcess(); + const initiateDeletionResult = await session1.transportServices.identityDeletionProcesses.initiateIdentityDeletionProcess(); + await eventBusRuntime1.waitForRunningEventHandlers(); - expect(session.account.deletionDate).toBe(initiateDeletionResult.value.gracePeriodEndsAt); + expect(session1.account.deletionDate).toBe(initiateDeletionResult.value.gracePeriodEndsAt); - const account = await runtime.multiAccountController.getAccount(CoreId.from(session.account.id)); - expect(account.deletionDate!.toString()).toBe(initiateDeletionResult.value.gracePeriodEndsAt); + const account = await runtime1.multiAccountController.getAccount(CoreId.from(session1.account.id)); + expect(account.deletionDate!.toString()).toBe(initiateDeletionResult.value.gracePeriodEndsAt!.toString()); }); - test("should unset the deletionDate on the LocalAccount cancelling an IdentityDeletionProcess", async function () { - await session.transportServices.identityDeletionProcesses.initiateIdentityDeletionProcess(); - expect(session.account.deletionDate).toBeDefined(); + test("should unset the deletionDate of the LocalAccount cancelling an IdentityDeletionProcess", async function () { + await session1.transportServices.identityDeletionProcesses.initiateIdentityDeletionProcess(); + await eventBusRuntime1.waitForRunningEventHandlers(); + expect(session1.account.deletionDate).toBeDefined(); - await session.transportServices.identityDeletionProcesses.cancelIdentityDeletionProcess(); - expect(session.account.deletionDate).toBeUndefined(); + await session1.transportServices.identityDeletionProcesses.cancelIdentityDeletionProcess(); + await eventBusRuntime1.waitForRunningEventHandlers(); + expect(session1.account.deletionDate).toBeUndefined(); - const account = await runtime.multiAccountController.getAccount(CoreId.from(session.account.id)); + const account = await runtime1.multiAccountController.getAccount(CoreId.from(session1.account.id)); expect(account.deletionDate).toBeUndefined(); }); + + describe("multi device", function () { + beforeAll(async function () { + runtime2 = await TestUtil.createRuntime(undefined, undefined, eventBusRuntime2); + await runtime2.start(); + + const createDeviceResult = await session1.transportServices.devices.createDevice({ name: "aName", isAdmin: true }); + const onboardingInfoResult = await session1.transportServices.devices.getDeviceOnboardingInfo({ id: createDeviceResult.value.id, profileName: "aProfileName" }); + const localAccountDevice2 = await runtime2.accountServices.onboardAccount(onboardingInfoResult.value); + session2 = await runtime2.selectAccount(localAccountDevice2.id.toString()); + + await session1.transportServices.account.syncDatawallet(); + await session2.transportServices.account.syncDatawallet(); + }); + + test("should set the deletionDate of the LocalAccount on a second device when an IdentityDeletionProcess is initiated", async function () { + const initiateDeletionResult = await session1.transportServices.identityDeletionProcesses.initiateIdentityDeletionProcess(); + expect(session2!.account.deletionDate).toBeUndefined(); + + await session2!.transportServices.account.syncDatawallet(); + + await expect(eventBusRuntime2).toHavePublished(LocalAccountDeletionDateChangedEvent, (e) => e.data.deletionDate! === initiateDeletionResult.value.gracePeriodEndsAt!); + + expect(session2!.account.deletionDate!.toString()).toStrictEqual(initiateDeletionResult.value.gracePeriodEndsAt); + + const account = await runtime2!.multiAccountController.getAccount(CoreId.from(session2!.account.id)); + expect(account.deletionDate!.toString()).toBe(initiateDeletionResult.value.gracePeriodEndsAt!.toString()); + }); + + test("should unset the deletionDate of the LocalAccount on a second device when an IdentityDeletionProcess is cancelled", async function () { + await session1.transportServices.identityDeletionProcesses.initiateIdentityDeletionProcess(); + await session2!.transportServices.account.syncDatawallet(); + await expect(eventBusRuntime2).toHavePublished(LocalAccountDeletionDateChangedEvent); + + await session1.transportServices.identityDeletionProcesses.cancelIdentityDeletionProcess(); + expect(session2!.account.deletionDate).toBeDefined(); + + await session2!.transportServices.account.syncDatawallet(); + + await expect(eventBusRuntime2).toHavePublished(LocalAccountDeletionDateChangedEvent, (e) => e.data.deletionDate === undefined); + + expect(session2!.account.deletionDate).toBeUndefined(); + + const account = await runtime2!.multiAccountController.getAccount(CoreId.from(session2!.account.id)); + expect(account.deletionDate).toBeUndefined(); + }); + + test("should handle multiple synced IdentityDeletionProcesses that happend while not syncing with the last one approved", async function () { + await session1.transportServices.identityDeletionProcesses.initiateIdentityDeletionProcess(); + await session1.transportServices.identityDeletionProcesses.cancelIdentityDeletionProcess(); + await session1.transportServices.identityDeletionProcesses.initiateIdentityDeletionProcess(); + await session1.transportServices.identityDeletionProcesses.cancelIdentityDeletionProcess(); + + const initiateDeletionResult = await session1.transportServices.identityDeletionProcesses.initiateIdentityDeletionProcess(); + expect(session2!.account.deletionDate).toBeUndefined(); + + await session2!.transportServices.account.syncDatawallet(); + + await expect(eventBusRuntime2).toHavePublished(LocalAccountDeletionDateChangedEvent, (e) => e.data.deletionDate! === initiateDeletionResult.value.gracePeriodEndsAt!); + + expect(session2!.account.deletionDate!.toString()).toStrictEqual(initiateDeletionResult.value.gracePeriodEndsAt); + + const account = await runtime2!.multiAccountController.getAccount(CoreId.from(session2!.account.id)); + expect(account.deletionDate!.toString()).toBe(initiateDeletionResult.value.gracePeriodEndsAt!.toString()); + }); + + test("should handle multiple synced IdentityDeletionProcesses that happend while not syncing with the last one cancelled", async function () { + await session1.transportServices.identityDeletionProcesses.initiateIdentityDeletionProcess(); + await session1.transportServices.identityDeletionProcesses.cancelIdentityDeletionProcess(); + await session1.transportServices.identityDeletionProcesses.initiateIdentityDeletionProcess(); + await session1.transportServices.identityDeletionProcesses.cancelIdentityDeletionProcess(); + + await session2!.transportServices.account.syncDatawallet(); + + await expect(eventBusRuntime2).toHavePublished(LocalAccountDeletionDateChangedEvent, (e) => e.data.deletionDate === undefined); + + expect(session2!.account.deletionDate).toBeUndefined(); + + const account = await runtime2!.multiAccountController.getAccount(CoreId.from(session2!.account.id)); + expect(account.deletionDate).toBeUndefined(); + }); + }); }); diff --git a/packages/app-runtime/test/runtime/AccountName.test.ts b/packages/app-runtime/test/runtime/AccountName.test.ts index 099ddbaf0..e6dd5fa14 100644 --- a/packages/app-runtime/test/runtime/AccountName.test.ts +++ b/packages/app-runtime/test/runtime/AccountName.test.ts @@ -18,7 +18,7 @@ describe("Test setting the account name", function () { }); test("should set the account name", async function () { - const accountName = "test"; + const accountName = "anAccountName"; expect(localAccount).toBeDefined(); diff --git a/packages/app-runtime/test/runtime/TranslationProvider.test.ts b/packages/app-runtime/test/runtime/TranslationProvider.test.ts index 73c45c95b..fb2c5fa70 100644 --- a/packages/app-runtime/test/runtime/TranslationProvider.test.ts +++ b/packages/app-runtime/test/runtime/TranslationProvider.test.ts @@ -37,7 +37,7 @@ describe("TranslationProvider", function () { }); test("should translate 'test' to the default message", async function () { - const translation = await runtime.translate("test"); + const translation = await runtime.translate("aKeyWithoutAvailableTranslation"); expect(translation).toBeInstanceOf(Result); expect(translation.isSuccess).toBe(true); expect(translation.value).toBe(noTranslationAvailable); diff --git a/packages/consumption/test/core/TestUtil.ts b/packages/consumption/test/core/TestUtil.ts index d5ef334f8..6d5c0ae54 100644 --- a/packages/consumption/test/core/TestUtil.ts +++ b/packages/consumption/test/core/TestUtil.ts @@ -465,7 +465,7 @@ export class TestUtil { public static async uploadFile(from: AccountController, fileContent: CoreBuffer): Promise { const params: ISendFileParameters = { buffer: fileContent, - title: "Test", + title: "aFileName", description: "Dies ist eine Beschreibung", filename: "Test.bin", filemodified: CoreDate.from("2019-09-30T00:00:00.000Z"), diff --git a/packages/runtime/src/events/EventProxy.ts b/packages/runtime/src/events/EventProxy.ts index 020e8ad17..a05977fa0 100644 --- a/packages/runtime/src/events/EventProxy.ts +++ b/packages/runtime/src/events/EventProxy.ts @@ -94,7 +94,10 @@ export class EventProxy { this.subscribeToSourceEvent(transport.IdentityDeletionProcessStatusChangedEvent, (event) => { this.targetEventBus.publish( - new IdentityDeletionProcessStatusChangedEvent(event.eventTargetAddress, IdentityDeletionProcessMapper.toIdentityDeletionProcessDTO(event.data)) + new IdentityDeletionProcessStatusChangedEvent( + event.eventTargetAddress, + event.data ? IdentityDeletionProcessMapper.toIdentityDeletionProcessDTO(event.data) : undefined + ) ); }); diff --git a/packages/runtime/src/events/transport/IdentityDeletionProcessStatusChangedEvent.ts b/packages/runtime/src/events/transport/IdentityDeletionProcessStatusChangedEvent.ts index ff15e9962..823731a72 100644 --- a/packages/runtime/src/events/transport/IdentityDeletionProcessStatusChangedEvent.ts +++ b/packages/runtime/src/events/transport/IdentityDeletionProcessStatusChangedEvent.ts @@ -1,10 +1,10 @@ import { IdentityDeletionProcessDTO } from "../../types/transport/IdentityDeletionProcessDTO"; import { DataEvent } from "../DataEvent"; -export class IdentityDeletionProcessStatusChangedEvent extends DataEvent { +export class IdentityDeletionProcessStatusChangedEvent extends DataEvent { public static readonly namespace = "transport.identityDeletionProcessStatusChanged"; - public constructor(eventTargetAddress: string, data: IdentityDeletionProcessDTO) { + public constructor(eventTargetAddress: string, data?: IdentityDeletionProcessDTO) { super(IdentityDeletionProcessStatusChangedEvent.namespace, eventTargetAddress, data); } } diff --git a/packages/runtime/test/transport/identityDeletionProcess.test.ts b/packages/runtime/test/transport/identityDeletionProcess.test.ts index f40c0d773..0832aeb3a 100644 --- a/packages/runtime/test/transport/identityDeletionProcess.test.ts +++ b/packages/runtime/test/transport/identityDeletionProcess.test.ts @@ -53,8 +53,8 @@ describe("IdentityDeletionProcess", () => { const initiatedIdentityDeletionProcess = await startIdentityDeletionProcessFromBackboneAdminApi(transportService, accountAddress); expect(initiatedIdentityDeletionProcess.status).toStrictEqual(IdentityDeletionProcessStatus.WaitingForApproval); - await expect(eventBus).toHavePublished(IdentityDeletionProcessStatusChangedEvent, (e) => e.data.id === initiatedIdentityDeletionProcess.id); - await expect(eventBus).toHavePublished(IdentityDeletionProcessStatusChangedEvent, (e) => e.data.status === IdentityDeletionProcessStatus.WaitingForApproval); + await expect(eventBus).toHavePublished(IdentityDeletionProcessStatusChangedEvent, (e) => e.data!.id === initiatedIdentityDeletionProcess.id); + await expect(eventBus).toHavePublished(IdentityDeletionProcessStatusChangedEvent, (e) => e.data!.status === IdentityDeletionProcessStatus.WaitingForApproval); await transportService.identityDeletionProcesses.approveIdentityDeletionProcess(); eventBus.reset(); @@ -64,8 +64,8 @@ describe("IdentityDeletionProcess", () => { const identityDeletionProcess = result.value; expect(identityDeletionProcess.status).toStrictEqual(IdentityDeletionProcessStatus.Cancelled); - await expect(eventBus).toHavePublished(IdentityDeletionProcessStatusChangedEvent, (e) => e.data.id === initiatedIdentityDeletionProcess.id); - await expect(eventBus).toHavePublished(IdentityDeletionProcessStatusChangedEvent, (e) => e.data.status === IdentityDeletionProcessStatus.Cancelled); + await expect(eventBus).toHavePublished(IdentityDeletionProcessStatusChangedEvent, (e) => e.data!.id === initiatedIdentityDeletionProcess.id); + await expect(eventBus).toHavePublished(IdentityDeletionProcessStatusChangedEvent, (e) => e.data!.status === IdentityDeletionProcessStatus.Cancelled); }); describe(InitiateIdentityDeletionProcessUseCase.name, () => { @@ -76,8 +76,8 @@ describe("IdentityDeletionProcess", () => { const identityDeletionProcess = result.value; expect(identityDeletionProcess.status).toBe(IdentityDeletionProcessStatus.Approved); - await expect(eventBus).toHavePublished(IdentityDeletionProcessStatusChangedEvent, (e) => e.data.id === identityDeletionProcess.id); - await expect(eventBus).toHavePublished(IdentityDeletionProcessStatusChangedEvent, (e) => e.data.status === IdentityDeletionProcessStatus.Approved); + await expect(eventBus).toHavePublished(IdentityDeletionProcessStatusChangedEvent, (e) => e.data!.id === identityDeletionProcess.id); + await expect(eventBus).toHavePublished(IdentityDeletionProcessStatusChangedEvent, (e) => e.data!.status === IdentityDeletionProcessStatus.Approved); }); test("should return an error trying to initiate an IdentityDeletionProcess if there already is one approved", async function () { @@ -186,8 +186,8 @@ describe("IdentityDeletionProcess", () => { const cancelledIdentityDeletionProcess = result.value; expect(cancelledIdentityDeletionProcess.status).toBe(IdentityDeletionProcessStatus.Cancelled); - await expect(eventBus).toHavePublished(IdentityDeletionProcessStatusChangedEvent, (e) => e.data.id === cancelledIdentityDeletionProcess.id); - await expect(eventBus).toHavePublished(IdentityDeletionProcessStatusChangedEvent, (e) => e.data.status === IdentityDeletionProcessStatus.Cancelled); + await expect(eventBus).toHavePublished(IdentityDeletionProcessStatusChangedEvent, (e) => e.data!.id === cancelledIdentityDeletionProcess.id); + await expect(eventBus).toHavePublished(IdentityDeletionProcessStatusChangedEvent, (e) => e.data!.status === IdentityDeletionProcessStatus.Cancelled); }); test("should return an error trying to cancel an IdentityDeletionProcess if there is none active", async function () { @@ -205,8 +205,8 @@ describe("IdentityDeletionProcess", () => { const approvedIdentityDeletionProcess = result.value; expect(approvedIdentityDeletionProcess.status).toBe(IdentityDeletionProcessStatus.Approved); - await expect(eventBus).toHavePublished(IdentityDeletionProcessStatusChangedEvent, (e) => e.data.id === approvedIdentityDeletionProcess.id); - await expect(eventBus).toHavePublished(IdentityDeletionProcessStatusChangedEvent, (e) => e.data.status === IdentityDeletionProcessStatus.Approved); + await expect(eventBus).toHavePublished(IdentityDeletionProcessStatusChangedEvent, (e) => e.data!.id === approvedIdentityDeletionProcess.id); + await expect(eventBus).toHavePublished(IdentityDeletionProcessStatusChangedEvent, (e) => e.data!.status === IdentityDeletionProcessStatus.Approved); }); test("should return an error trying to approve an IdentityDeletionProcess if there is none active", async function () { @@ -227,8 +227,8 @@ describe("IdentityDeletionProcess", () => { const rejectedIdentityDeletionProcess = result.value; expect(rejectedIdentityDeletionProcess.status).toBe(IdentityDeletionProcessStatus.Rejected); - await expect(eventBus).toHavePublished(IdentityDeletionProcessStatusChangedEvent, (e) => e.data.id === rejectedIdentityDeletionProcess.id); - await expect(eventBus).toHavePublished(IdentityDeletionProcessStatusChangedEvent, (e) => e.data.status === IdentityDeletionProcessStatus.Rejected); + await expect(eventBus).toHavePublished(IdentityDeletionProcessStatusChangedEvent, (e) => e.data!.id === rejectedIdentityDeletionProcess.id); + await expect(eventBus).toHavePublished(IdentityDeletionProcessStatusChangedEvent, (e) => e.data!.status === IdentityDeletionProcessStatus.Rejected); }); test("should return an error trying to reject an IdentityDeletionProcess if there is none active", async function () { diff --git a/packages/transport/src/events/IdentityDeletionProcessStatusChangedEvent.ts b/packages/transport/src/events/IdentityDeletionProcessStatusChangedEvent.ts index 33f47f9bf..032f835c8 100644 --- a/packages/transport/src/events/IdentityDeletionProcessStatusChangedEvent.ts +++ b/packages/transport/src/events/IdentityDeletionProcessStatusChangedEvent.ts @@ -1,10 +1,10 @@ import { IdentityDeletionProcess } from "../modules"; import { TransportDataEvent } from "./TransportDataEvent"; -export class IdentityDeletionProcessStatusChangedEvent extends TransportDataEvent { +export class IdentityDeletionProcessStatusChangedEvent extends TransportDataEvent { public static readonly namespace = "transport.identityDeletionProcessStatusChanged"; - public constructor(eventTargetAddress: string, data: IdentityDeletionProcess) { + public constructor(eventTargetAddress: string, data?: IdentityDeletionProcess) { super(IdentityDeletionProcessStatusChangedEvent.namespace, eventTargetAddress, data); } } diff --git a/packages/transport/src/modules/accounts/AccountController.ts b/packages/transport/src/modules/accounts/AccountController.ts index 96e761ea4..df78544ec 100644 --- a/packages/transport/src/modules/accounts/AccountController.ts +++ b/packages/transport/src/modules/accounts/AccountController.ts @@ -8,6 +8,7 @@ import { CoreCrypto } from "../../core/CoreCrypto"; import { DbCollectionName } from "../../core/DbCollectionName"; import { DependencyOverrides } from "../../core/DependencyOverrides"; import { TransportLoggerFactory } from "../../core/TransportLoggerFactory"; +import { IdentityDeletionProcessStatusChangedEvent } from "../../events/IdentityDeletionProcessStatusChangedEvent"; import { PasswordGenerator } from "../../util"; import { CertificateController } from "../certificates/CertificateController"; import { CertificateIssuer } from "../certificates/CertificateIssuer"; @@ -235,11 +236,21 @@ export class AccountController { public async syncDatawallet(force = false): Promise { if (!force && !this.autoSync) return; - await this.synchronization.sync("OnlyDatawallet"); + const changedItems = await this.synchronization.sync("OnlyDatawallet"); + this.triggerEventsForChangedItems(changedItems); } public async syncEverything(): Promise { - return await this.synchronization.sync("Everything"); + const changedItems = await this.synchronization.sync("Everything"); + this.triggerEventsForChangedItems(changedItems); + + return changedItems; + } + + private triggerEventsForChangedItems(changedItems: ChangedItems) { + if (changedItems.changedObjectIdentifiersDuringDatawalletSync.some((x) => x.startsWith("IDP"))) { + this.transport.eventBus.publish(new IdentityDeletionProcessStatusChangedEvent(this.identity.address.toString())); + } } public async getLastCompletedSyncTime(): Promise { diff --git a/packages/transport/src/modules/sync/ChangedItems.ts b/packages/transport/src/modules/sync/ChangedItems.ts index 4f9c9a01f..7ffde03e5 100644 --- a/packages/transport/src/modules/sync/ChangedItems.ts +++ b/packages/transport/src/modules/sync/ChangedItems.ts @@ -12,7 +12,8 @@ export class ChangedItems implements IChangedItems { public constructor( public readonly relationships: Relationship[] = [], public readonly messages: Message[] = [], - public readonly identityDeletionProcesses: IdentityDeletionProcess[] = [] + public readonly identityDeletionProcesses: IdentityDeletionProcess[] = [], + public readonly changedObjectIdentifiersDuringDatawalletSync: string[] = [] ) {} public addItem(item: Relationship | Message | IdentityDeletionProcess): void { @@ -24,4 +25,8 @@ export class ChangedItems implements IChangedItems { this.identityDeletionProcesses.push(item); } } + + public addChangedObjectsIdentifiersDuringDatawalletSync(identifiers: string[]): void { + this.changedObjectIdentifiersDuringDatawalletSync.push(...identifiers); + } } diff --git a/packages/transport/src/modules/sync/DatawalletModificationsProcessor.ts b/packages/transport/src/modules/sync/DatawalletModificationsProcessor.ts index 4ebc5a778..cc78fcf09 100644 --- a/packages/transport/src/modules/sync/DatawalletModificationsProcessor.ts +++ b/packages/transport/src/modules/sync/DatawalletModificationsProcessor.ts @@ -32,6 +32,11 @@ export class DatawalletModificationsProcessor { private readonly cacheChanges: DatawalletModification[]; private readonly deletedObjectIdentifiers: string[] = []; + private readonly _changedObjectIdentifiers: Set = new Set(); + public get changedObjectIdentifiers(): string[] { + return Array.from(this._changedObjectIdentifiers); + } + public get log(): ILogger { return this.logger; } @@ -64,6 +69,8 @@ export class DatawalletModificationsProcessor { const modificationsGroupedByObjectIdentifier = _.groupBy(this.modificationsWithoutCacheChanges, (m) => m.objectIdentifier); for (const objectIdentifier in modificationsGroupedByObjectIdentifier) { + this._changedObjectIdentifiers.add(objectIdentifier); + const currentModifications = modificationsGroupedByObjectIdentifier[objectIdentifier]; const targetCollectionName = currentModifications[0].collection; @@ -137,6 +144,11 @@ export class DatawalletModificationsProcessor { this.ensureAllItemsAreCacheable(); const cacheChangesWithoutDeletes = this.cacheChanges.filter((c) => !this.deletedObjectIdentifiers.some((d) => c.objectIdentifier.equals(d))); + + for (const objectIdentifier of cacheChangesWithoutDeletes.map((c) => c.objectIdentifier.toString())) { + this._changedObjectIdentifiers.add(objectIdentifier); + } + const cacheChangesGroupedByCollection = this.groupCacheChangesByCollection(cacheChangesWithoutDeletes); const caches = await this.cacheFetcher.fetchCacheFor({ diff --git a/packages/transport/src/modules/sync/SyncController.ts b/packages/transport/src/modules/sync/SyncController.ts index 7c2b01d74..e44399b53 100644 --- a/packages/transport/src/modules/sync/SyncController.ts +++ b/packages/transport/src/modules/sync/SyncController.ts @@ -68,10 +68,7 @@ export class SyncController extends TransportController { private currentSync?: LocalSyncRun; private currentSyncRun?: BackboneSyncRun; - public async sync(whatToSync: "OnlyDatawallet"): Promise; - public async sync(whatToSync: "Everything"): Promise; - public async sync(whatToSync: WhatToSync): Promise; - public async sync(whatToSync: WhatToSync = "Everything"): Promise { + public async sync(whatToSync: WhatToSync = "Everything"): Promise { if (this.currentSync?.includes(whatToSync)) { return await this.currentSync.promise; } @@ -94,32 +91,33 @@ export class SyncController extends TransportController { } private async _syncAndResyncDatawallet(whatToSync: WhatToSync = "Everything") { + const changedItems = new ChangedItems(); + try { - return await this._sync(whatToSync); + await this._sync(whatToSync, changedItems); } finally { if (this.datawalletEnabled && (await this.unpushedDatawalletModifications.exists())) { - await this.syncDatawallet().catch((e) => this.log.error(e)); + await this.syncDatawallet(changedItems).catch((e) => this.log.error(e)); } this.transport.eventBus.publish(new DatawalletSynchronizedEvent(this.parent.identity.address.toString())); } + + return changedItems; } @log() - private async _sync(whatToSync: WhatToSync): Promise { - if (whatToSync === "OnlyDatawallet") { - const value = await this.syncDatawallet(); - return value; - } + private async _sync(whatToSync: WhatToSync, changedItems: ChangedItems): Promise { + if (whatToSync === "OnlyDatawallet") return await this.syncDatawallet(changedItems); - const externalEventSyncResult = await this.syncExternalEvents(); + const externalEventSyncResult = await this.syncExternalEvents(changedItems); await this.setLastCompletedSyncTime(); - if (externalEventSyncResult.externalEventResults.some((r) => r.errorCode !== undefined)) { + if (externalEventSyncResult.some((r) => r.errorCode !== undefined)) { throw new CoreError( "error.transport.errorWhileApplyingExternalEvents", - externalEventSyncResult.externalEventResults + externalEventSyncResult .filter((r) => r.errorCode !== undefined) .map((r) => r.errorCode) .join(" | ") @@ -127,36 +125,28 @@ export class SyncController extends TransportController { } if (this.datawalletEnabled && (await this.unpushedDatawalletModifications.exists())) { - await this.syncDatawallet().catch((e) => this.log.error(e)); + await this.syncDatawallet(changedItems).catch((e) => this.log.error(e)); } - - return externalEventSyncResult.changedItems; } - private async syncExternalEvents(): Promise<{ - externalEventResults: FinalizeSyncRunRequestExternalEventResult[]; - changedItems: ChangedItems; - }> { + private async syncExternalEvents(changedItems: ChangedItems): Promise { const syncRunWasStarted = await this.startExternalEventsSyncRun(); + if (!syncRunWasStarted) { - await this.syncDatawallet(); - return { - changedItems: new ChangedItems(), - externalEventResults: [] - }; + await this.syncDatawallet(changedItems); + return []; } await this.applyIncomingDatawalletModifications(); - const result = await this.applyIncomingExternalEvents(); - await this.finalizeExternalEventsSyncRun(result.externalEventResults); + const result = await this.applyIncomingExternalEvents(changedItems); + await this.finalizeExternalEventsSyncRun(result); return result; } @log() - private async syncDatawallet() { - if (!this.datawalletEnabled) { - return; - } + private async syncDatawallet(changedItems: ChangedItems): Promise { + if (!this.datawalletEnabled) return; + const identityDatawalletVersion = await this.getIdentityDatawalletVersion(); if (this.config.supportedDatawalletVersion < identityDatawalletVersion) { @@ -168,7 +158,9 @@ export class SyncController extends TransportController { this.log.trace("Synchronization of Datawallet events started..."); try { - await this.applyIncomingDatawalletModifications(); + const changedObjectIdentifiers = await this.applyIncomingDatawalletModifications(); + changedItems.addChangedObjectsIdentifiersDuringDatawalletSync(changedObjectIdentifiers); + await this.pushLocalDatawalletModifications(); await this.setLastCompletedDatawalletSyncTime(); @@ -254,13 +246,11 @@ export class SyncController extends TransportController { } } - private async applyIncomingDatawalletModifications() { + private async applyIncomingDatawalletModifications(): Promise { const getDatawalletModificationsResult = await this.client.getDatawalletModifications({ localIndex: await this.getLocalDatawalletModificationIndex() }); const encryptedIncomingModifications = await getDatawalletModificationsResult.value.collect(); - if (encryptedIncomingModifications.length === 0) { - return; - } + if (encryptedIncomingModifications.length === 0) return []; const incomingModifications = await this.decryptDatawalletModifications(encryptedIncomingModifications); @@ -278,6 +268,8 @@ export class SyncController extends TransportController { this.log.trace(`${incomingModifications.length} incoming modifications executed`, incomingModifications); await this.updateLocalDatawalletModificationIndex(encryptedIncomingModifications.sort(descending)[0].index); + + return datawalletModificationsProcessor.changedObjectIdentifiers; } private async promiseAllWithProgess(promises: Promise[], callback: (percentage: number) => void) { @@ -385,7 +377,7 @@ export class SyncController extends TransportController { return this.currentSyncRun !== undefined; } - private async applyIncomingExternalEvents() { + private async applyIncomingExternalEvents(changedItems: ChangedItems): Promise { const getExternalEventsResult = await this.client.getExternalEventsOfSyncRun(this.currentSyncRun!.id.toString()); if (getExternalEventsResult.isError) throw getExternalEventsResult.error; @@ -393,7 +385,6 @@ export class SyncController extends TransportController { const externalEvents = await getExternalEventsResult.value.collect(); const results: FinalizeSyncRunRequestExternalEventResult[] = []; - const changedItems = new ChangedItems(); for (const externalEvent of externalEvents) { try { @@ -416,10 +407,7 @@ export class SyncController extends TransportController { } } - return { - externalEventResults: results, - changedItems: changedItems - }; + return results; } private async finalizeExternalEventsSyncRun(externalEventResults: FinalizeSyncRunRequestExternalEventResult[]): Promise { @@ -514,7 +502,7 @@ function descending(modification1: BackboneDatawalletModification, modification2 class LocalSyncRun { public constructor( - public readonly promise: Promise, + public readonly promise: Promise, public readonly whatToSync: WhatToSync ) {} diff --git a/packages/transport/test/modules/devices/DeviceOnboarding.test.ts b/packages/transport/test/modules/devices/DeviceOnboarding.test.ts index 4f835151b..3457ff6f9 100644 --- a/packages/transport/test/modules/devices/DeviceOnboarding.test.ts +++ b/packages/transport/test/modules/devices/DeviceOnboarding.test.ts @@ -35,10 +35,10 @@ describe("Device Onboarding", function () { }); test("should create correct device", async function () { - newDevice = await device1Account.devices.sendDevice({ name: "Test", isAdmin: true }); + newDevice = await device1Account.devices.sendDevice({ name: "aDeviceName", isAdmin: true }); await device1Account.syncDatawallet(); expect(newDevice).toBeInstanceOf(Device); - expect(newDevice.name).toBe("Test"); + expect(newDevice.name).toBe("aDeviceName"); expect(newDevice.publicKey).toBeUndefined(); expect(newDevice.operatingSystem).toBeUndefined(); expect(newDevice.lastLoginAt).toBeUndefined();