Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Process personalized and password protected objects in the StringProcessor #299

Merged
Merged
Show file tree
Hide file tree
Changes from 48 commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
65d4947
feat: add enterPassword function to UIBridge
jkoenig134 Oct 17, 2024
c84004b
feat: prepare tests
jkoenig134 Oct 17, 2024
e289754
Merge branch 'main' into process-personalized-and-password-protected-…
jkoenig134 Oct 18, 2024
352230e
refactor: be more precise about what's going wrong
jkoenig134 Oct 18, 2024
7c32255
test: make app-runtimes EventBus mockable
jkoenig134 Oct 18, 2024
d7e0260
fix: make UIBridge mockable
jkoenig134 Oct 18, 2024
2bf8fd6
add eslint assert function
jkoenig134 Oct 18, 2024
eb360de
chore: add test for personalized RelationshipTemplate
jkoenig134 Oct 18, 2024
056e3b5
test: add second test for no matching relationship
jkoenig134 Oct 18, 2024
f95a70d
Merge branch 'main' into process-personalized-and-password-protected-…
jkoenig134 Oct 23, 2024
183749c
Merge branch 'main' into process-personalized-and-password-protected-…
jkoenig134 Oct 30, 2024
53562b8
Merge branch 'main' into process-personalized-and-password-protected-…
jkoenig134 Nov 21, 2024
cfac0cc
Merge branch 'main' into process-personalized-and-password-protected-…
jkoenig134 Nov 26, 2024
7994c8c
refactor: make password protection typesafe
jkoenig134 Nov 26, 2024
094b599
refactor: adapt to more runtime changes
jkoenig134 Nov 26, 2024
e63822e
Merge branch 'main' into process-personalized-and-password-protected-…
mergify[bot] Nov 26, 2024
155ad26
Merge branch 'main' into process-personalized-and-password-protected-…
mergify[bot] Nov 27, 2024
8ba0477
Merge branch 'main' into process-personalized-and-password-protected-…
mergify[bot] Nov 27, 2024
d7e7686
Merge branch 'main' into process-personalized-and-password-protected-…
mergify[bot] Nov 27, 2024
3a91f97
Merge branch 'main' into process-personalized-and-password-protected-…
mergify[bot] Nov 28, 2024
810a175
Merge branch 'main' into process-personalized-and-password-protected-…
mergify[bot] Nov 28, 2024
a4e3378
chore: use any casts for testing
jkoenig134 Nov 28, 2024
52ee1bc
fix: eslint
jkoenig134 Nov 28, 2024
d0c2960
fix: add substring
jkoenig134 Nov 28, 2024
041012d
Merge branch 'main' into process-personalized-and-password-protected-…
mergify[bot] Nov 29, 2024
4da985f
Merge branch 'main' into process-personalized-and-password-protected-…
mergify[bot] Nov 29, 2024
e4ae14c
Merge branch 'main' into process-personalized-and-password-protected-…
mergify[bot] Nov 29, 2024
6766cd3
Merge branch 'main' into process-personalized-and-password-protected-…
mergify[bot] Nov 29, 2024
ab47e05
Merge branch 'main' into process-personalized-and-password-protected-…
jkoenig134 Nov 29, 2024
b0380df
feat: use the provided password to load objects
jkoenig134 Nov 29, 2024
b9ea226
feat: proper eventbus
jkoenig134 Nov 29, 2024
3872c1d
fix: properly await the UIBridge
jkoenig134 Nov 29, 2024
6f204f3
fix: proper mock event bus usage
jkoenig134 Nov 29, 2024
a61dcb8
fix: proper mock event bus usage
jkoenig134 Nov 29, 2024
c5d9f77
chore: add MockUIBridge
jkoenig134 Nov 29, 2024
7c711c3
refactor: simplify tests
jkoenig134 Nov 29, 2024
16237d4
feat: add password protection tests
jkoenig134 Nov 29, 2024
6e847f3
Merge branch 'process-personalized-and-password-protected-objects-in-…
jkoenig134 Nov 29, 2024
dcbd727
chore: remove forIdentity
jkoenig134 Nov 29, 2024
c59a631
chore: add combinated test
jkoenig134 Nov 29, 2024
5c94b99
chore: re-simplify uiBridge calls
jkoenig134 Nov 29, 2024
52ace05
chore: wording
jkoenig134 Dec 2, 2024
825cbb6
feat: add passwordProtection to CreateDeviceOnboardingTokenRequest
jkoenig134 Dec 2, 2024
a14daad
test: test and assert more stuff
jkoenig134 Dec 2, 2024
a4a8745
chore: remove todos
jkoenig134 Dec 2, 2024
4146ee2
fix: make fully mockable
jkoenig134 Dec 2, 2024
7928394
refactor: migrate to custom matchers
jkoenig134 Dec 2, 2024
3ce05ce
chore: move enterPassword to private method
jkoenig134 Dec 2, 2024
6866435
chore: PR comments
jkoenig134 Dec 2, 2024
dca28d5
refactor: Thomas' PR comments
jkoenig134 Dec 2, 2024
c02b8dd
fix: bulletproof pin parsing
jkoenig134 Dec 2, 2024
9e4df5a
chore: messages
jkoenig134 Dec 2, 2024
294ded3
chore: PR comments
jkoenig134 Dec 2, 2024
ebd5a4c
chore: wording
jkoenig134 Dec 2, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
38 changes: 27 additions & 11 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 Down Expand Up @@ -33,9 +33,10 @@ 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 @@ -189,12 +191,26 @@ export class AppRuntime extends Runtime<AppConfig> {

public async requestAccountSelection(
title = "i18n://uibridge.accountSelection.title",
description = "i18n://uibridge.accountSelection.description"
description = "i18n://uibridge.accountSelection.description",
forIdentityTruncated?: string
): Promise<UserfriendlyResult<LocalAccountDTO | undefined>> {
const accounts = await this.accountServices.getAccounts();

const bridge = await this.uiBridge();
const accountSelectionResult = await bridge.requestAccountSelection(accounts, title, description);
if (!forIdentityTruncated) return await this.selectAccountViaBridge(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.selectAccountViaBridge(accountsWithPostfix, title, description);
}

private async selectAccountViaBridge(accounts: LocalAccountDTO[], title: string, description: string): Promise<UserfriendlyResult<LocalAccountDTO | undefined>> {
const uiBridge = await this.uiBridge();

const accountSelectionResult = await uiBridge.requestAccountSelection(accounts, title, description);
if (accountSelectionResult.isError) {
return UserfriendlyResult.fail(AppRuntimeErrors.general.noAccountAvailable(accountSelectionResult.error));
}
Expand All @@ -217,7 +233,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 +266,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
70 changes: 49 additions & 21 deletions packages/app-runtime/src/AppStringProcessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ 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";
Expand All @@ -17,8 +17,8 @@ export class AppStringProcessor {
public constructor(
protected readonly runtime: {
get anonymousServices(): AnonymousServices;
requestAccountSelection(title?: string, description?: string): Promise<UserfriendlyResult<LocalAccountDTO | undefined>>;
uiBridge(): Promise<IUIBridge>;
requestAccountSelection(title?: string, description?: string, forIdentityTruncated?: string): Promise<UserfriendlyResult<LocalAccountDTO | undefined>>;
uiBridge(): Promise<IUIBridge> | IUIBridge;
getServices(accountReference: string | ICoreAddress): Promise<AppRuntimeServices>;
translate(key: string, ...values: any[]): Promise<Result<string>>;
get eventBus(): EventBus;
Expand All @@ -40,11 +40,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.runtime.requestAccountSelection(undefined, undefined, reference.forIdentityTruncated);
if (result.isError) {
this.logger.error("Could not query account", result.error);
return UserfriendlyResult.fail(result.error);
Expand All @@ -55,19 +64,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,7 +95,6 @@ export class AppStringProcessor {
}

if (tokenContent instanceof TokenContentDeviceSharedSecret) {
const uiBridge = await this.runtime.uiBridge();
await uiBridge.showDeviceOnboarding(DeviceMapper.toDeviceOnboardingInfoDTO(tokenContent.sharedSecret));
return UserfriendlyResult.ok(undefined);
}
Expand All @@ -92,26 +110,26 @@ export class AppStringProcessor {
return UserfriendlyResult.ok(undefined);
}

return await this._handleTruncatedReference(truncatedReference, selectedAccount);
return await this._handleReference(reference, selectedAccount);
}

private async _handleTruncatedReference(truncatedReference: string, account: LocalAccountDTO): Promise<UserfriendlyResult<void>> {
private async _handleReference(reference: Reference, account: LocalAccountDTO): 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;
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"));
}

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 +162,14 @@ 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 === "pw" ? undefined : parseInt(passwordProtection.passwordType.substring(3))
);

return passwordResult;
}
}
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
80 changes: 80 additions & 0 deletions packages/app-runtime/test/customMatchers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
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