Skip to content

Commit

Permalink
Process personalized and password protected objects in the StringProc…
Browse files Browse the repository at this point in the history
…essor (#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>
  • Loading branch information
jkoenig134 and mergify[bot] authored Dec 2, 2024
1 parent 2241e42 commit 95dc437
Show file tree
Hide file tree
Showing 26 changed files with 703 additions and 79 deletions.
1 change: 1 addition & 0 deletions .eslintrc
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"*.expectThrows*",
"Then.*",
"*.expectPublishedEvents",
"*.expectLastPublishedEvent",
"*.executeTests",
"expectThrows*"
]
Expand Down
1 change: 1 addition & 0 deletions packages/app-runtime/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
"maxWorkers": 5,
"preset": "ts-jest",
"setupFilesAfterEnv": [
"./test/customMatchers.ts",
"jest-expect-message"
],
"testEnvironment": "node",
Expand Down
36 changes: 11 additions & 25 deletions packages/app-runtime/src/AppRuntime.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -25,17 +25,18 @@ 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";

export class AppRuntime extends Runtime<AppConfig> {
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);
}
Expand All @@ -47,16 +48,17 @@ export class AppRuntime extends Runtime<AppConfig> {
private _uiBridge: IUIBridge | undefined;
private _uiBridgeResolver?: { promise: Promise<IUIBridge>; resolve(uiBridge: IUIBridge): void };

public async uiBridge(): Promise<IUIBridge> {
public uiBridge(): Promise<IUIBridge> | 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<IUIBridge>((r) => (resolve = r));
this._uiBridgeResolver = { promise, resolve };

try {
return await this._uiBridgeResolver.promise;
return this._uiBridgeResolver.promise;
} finally {
this._uiBridgeResolver = undefined;
}
Expand Down Expand Up @@ -187,22 +189,6 @@ export class AppRuntime extends Runtime<AppConfig> {
return session;
}

public async requestAccountSelection(
title = "i18n://uibridge.accountSelection.title",
description = "i18n://uibridge.accountSelection.description"
): Promise<UserfriendlyResult<LocalAccountDTO | undefined>> {
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<RuntimeHealth> {
const health = {
isHealthy: true,
Expand All @@ -217,7 +203,7 @@ export class AppRuntime extends Runtime<AppConfig> {
this._accountServices = new AccountServices(this._multiAccountController);
}

public static async create(nativeBootstrapper: INativeBootstrapper, appConfig?: AppConfigOverwrite): Promise<AppRuntime> {
public static async create(nativeBootstrapper: INativeBootstrapper, appConfig?: AppConfigOverwrite, eventBus?: EventBus): Promise<AppRuntime> {
// TODO: JSSNMSHDD-2524 (validate app config)

if (!nativeBootstrapper.isInitialized) {
Expand Down Expand Up @@ -250,7 +236,7 @@ export class AppRuntime extends Runtime<AppConfig> {
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");

Expand Down
8 changes: 8 additions & 0 deletions packages/app-runtime/src/AppRuntimeErrors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
102 changes: 79 additions & 23 deletions packages/app-runtime/src/AppStringProcessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -17,11 +17,12 @@ export class AppStringProcessor {
public constructor(
protected readonly runtime: {
get anonymousServices(): AnonymousServices;
requestAccountSelection(title?: string, description?: string): Promise<UserfriendlyResult<LocalAccountDTO | undefined>>;
uiBridge(): Promise<IUIBridge>;
get accountServices(): AccountServices;
uiBridge(): Promise<IUIBridge> | IUIBridge;
getServices(accountReference: string | ICoreAddress): Promise<AppRuntimeServices>;
translate(key: string, ...values: any[]): Promise<Result<string>>;
get eventBus(): EventBus;
selectAccount(accountReference: string): Promise<LocalAccountSession>;
},
loggerFactory: ILoggerFactory
) {
Expand All @@ -40,11 +41,20 @@ export class AppStringProcessor {
}

public async processTruncatedReference(truncatedReference: string, account?: LocalAccountDTO): Promise<UserfriendlyResult<void>> {
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);
Expand All @@ -55,19 +65,29 @@ 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)) {
const error = AppRuntimeErrors.startup.wrongCode();
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) {
Expand All @@ -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);
}
Expand All @@ -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<UserfriendlyResult<void>> {
private async _handleReference(reference: Reference, account: LocalAccountDTO, existingPassword?: string): Promise<UserfriendlyResult<void>> {
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);
Expand Down Expand Up @@ -144,4 +163,41 @@ export class AppStringProcessor {
return undefined;
}
}

private async enterPassword(passwordProtection: SharedPasswordProtection): Promise<Result<string>> {
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<UserfriendlyResult<LocalAccountDTO | undefined>> {
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<UserfriendlyResult<LocalAccountDTO | undefined>> {
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);
}
}
1 change: 1 addition & 0 deletions packages/app-runtime/src/extensibility/ui/IUIBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,5 @@ export interface IUIBridge {
showRequest(account: LocalAccountDTO, request: LocalRequestDVO): Promise<Result<void>>;
showError(error: UserfriendlyApplicationError, account?: LocalAccountDTO): Promise<Result<void>>;
requestAccountSelection(possibleAccounts: LocalAccountDTO[], title?: string, description?: string): Promise<Result<LocalAccountDTO | undefined>>;
enterPassword(passwordType: "pw" | "pin", pinLength?: number): Promise<Result<string>>;
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ export class MailReceivedModule extends AppRuntimeModule<MailReceivedModuleConfi

await this.runtime.nativeEnvironment.notificationAccess.schedule(mail.name, mail.createdBy.name, {
callback: async () => {
await (await this.runtime.uiBridge()).showMessage(session.account, sender, mail);
const uiBridge = await this.runtime.uiBridge();
await uiBridge.showMessage(session.account, sender, mail);
}
});
}
Expand Down
65 changes: 65 additions & 0 deletions packages/app-runtime/test/customMatchers.ts
Original file line number Diff line number Diff line change
@@ -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<unknown, ApplicationError>) {
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<unknown, ApplicationError>, 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<TEvent>(eventBus: unknown, eventConstructor: EventConstructor<TEvent>, 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<R> {
toBeSuccessful(): R;
toBeAnError(expectedMessage: string | RegExp, expectedCode: string | RegExp): R;
toHavePublished<TEvent>(eventConstructor: EventConstructor<TEvent>, eventConditions?: (event: TEvent) => boolean): Promise<R>;
}
}
}
4 changes: 4 additions & 0 deletions packages/app-runtime/test/lib/FakeUIBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,8 @@ export class FakeUIBridge implements IUIBridge {
public requestAccountSelection(): Promise<Result<LocalAccountDTO | undefined, ApplicationError>> {
throw new Error("Method not implemented.");
}

public enterPassword(_passwordType: "pw" | "pin", _pinLength?: number): Promise<Result<string>> {
throw new Error("Method not implemented.");
}
}
Loading

0 comments on commit 95dc437

Please sign in to comment.