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] 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) } });