From 5a685b03d58f79f189a0964e73970fdbcd3c7b2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lloren=C3=A7=20Muntaner?= Date: Tue, 2 Jul 2024 11:28:16 +0200 Subject: [PATCH] Identity Metadata Repository setup (#2520) * AnchorMetadataRepository * Improvements * Integrate in AuthenticatedConnection * Add tests for anchor metadata repository and AuthenticatedConnection * Rename IdentityMetadataRepository * CR changes * Rename * Change return * await metadata * Set metadata if empty * Update waits for metadata and clean tests --- src/frontend/src/flows/authorize/index.ts | 1 + src/frontend/src/flows/manage/index.ts | 1 + .../src/repositories/identityMetadata.test.ts | 199 ++++++++++++++++++ .../src/repositories/identityMetadata.ts | 195 +++++++++++++++++ src/frontend/src/utils/iiConnection.test.ts | 90 ++++++++ src/frontend/src/utils/iiConnection.ts | 55 +++++ 6 files changed, 541 insertions(+) create mode 100644 src/frontend/src/repositories/identityMetadata.test.ts create mode 100644 src/frontend/src/repositories/identityMetadata.ts create mode 100644 src/frontend/src/utils/iiConnection.test.ts diff --git a/src/frontend/src/flows/authorize/index.ts b/src/frontend/src/flows/authorize/index.ts index 469ac659ab..12586bf47d 100644 --- a/src/frontend/src/flows/authorize/index.ts +++ b/src/frontend/src/flows/authorize/index.ts @@ -212,6 +212,7 @@ const authenticate = async ( const derivationOrigin = authContext.authRequest.derivationOrigin ?? authContext.requestOrigin; + // TODO: Commit state const result = await withLoader(() => fetchDelegation({ connection: authSuccess.connection, diff --git a/src/frontend/src/flows/manage/index.ts b/src/frontend/src/flows/manage/index.ts index ff1223ee62..c1f2ef3c44 100644 --- a/src/frontend/src/flows/manage/index.ts +++ b/src/frontend/src/flows/manage/index.ts @@ -250,6 +250,7 @@ export const renderManage = async ({ for (;;) { let anchorInfo: IdentityAnchorInfo; try { + // TODO: commit state anchorInfo = await withLoader(() => connection.getAnchorInfo()); } catch (error: unknown) { await displayFailedToListDevices( diff --git a/src/frontend/src/repositories/identityMetadata.test.ts b/src/frontend/src/repositories/identityMetadata.test.ts new file mode 100644 index 0000000000..18940fbe23 --- /dev/null +++ b/src/frontend/src/repositories/identityMetadata.test.ts @@ -0,0 +1,199 @@ +import { MetadataMapV2 } from "$generated/internet_identity_types"; +import { + IdentityMetadata, + IdentityMetadataRepository, + RECOVERY_PAGE_SHOW_TIMESTAMP_MILLIS, +} from "./identityMetadata"; + +const recoveryPageShownTimestampMillis = 1234567890; +const mockRawMetadata: MetadataMapV2 = [ + [ + RECOVERY_PAGE_SHOW_TIMESTAMP_MILLIS, + { String: String(recoveryPageShownTimestampMillis) }, + ], +]; +const mockIdentityMetadata: IdentityMetadata = { + recoveryPageShownTimestampMillis, +}; + +const getterMockSuccess = vi.fn().mockResolvedValue(mockRawMetadata); +const getterMockError = vi.fn().mockImplementation(() => { + throw new Error("test error"); +}); +const setterMockSuccess = vi.fn(); +const setterMockError = vi.fn().mockRejectedValue("test error"); + +beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, "warn").mockImplementation(() => {}); +}); + +test("IdentityMetadataRepository loads data on init in the background", async () => { + const instance = IdentityMetadataRepository.init({ + getter: getterMockSuccess, + setter: setterMockSuccess, + }); + + expect(getterMockSuccess).toHaveBeenCalledTimes(1); + expect(await instance.getMetadata()).toEqual(mockIdentityMetadata); +}); + +test("getMetadata waits until metadata is loaded", async () => { + const slowGetter = vi.fn().mockImplementation(async () => { + await new Promise((resolve) => setTimeout(resolve, 1_000)); + return mockRawMetadata; + }); + const instance = IdentityMetadataRepository.init({ + getter: slowGetter, + setter: setterMockSuccess, + }); + + expect(await instance.getMetadata()).toEqual(mockIdentityMetadata); +}); + +test("IdentityMetadataRepository returns undefined without raising an error if fetching fails", async () => { + const instance = IdentityMetadataRepository.init({ + getter: getterMockError, + setter: setterMockSuccess, + }); + + // Error is not thrown, but warnings is logged. + expect(console.warn).toHaveBeenCalledTimes(1); + expect(getterMockError).toHaveBeenCalledTimes(1); + + expect(await instance.getMetadata()).toEqual(undefined); + expect(getterMockError).toHaveBeenCalledTimes(1); + expect(console.warn).toHaveBeenCalledTimes(1); +}); + +test("IdentityMetadataRepository changes data in memory", async () => { + const instance = IdentityMetadataRepository.init({ + getter: getterMockSuccess, + setter: setterMockSuccess, + }); + + const newRecoveryPageShownTimestampMillis = 9876543210; + await instance.updateMetadata({ + recoveryPageShownTimestampMillis: newRecoveryPageShownTimestampMillis, + }); + + expect(await instance.getMetadata()).toEqual({ + recoveryPageShownTimestampMillis: newRecoveryPageShownTimestampMillis, + }); +}); + +test("IdentityMetadataRepository sets data in memory", async () => { + const noMetadata: MetadataMapV2 = []; + const instance = IdentityMetadataRepository.init({ + getter: vi.fn().mockResolvedValue(noMetadata), + setter: setterMockSuccess, + }); + + const newRecoveryPageShownTimestampMillis = 9876543210; + await instance.updateMetadata({ + recoveryPageShownTimestampMillis: newRecoveryPageShownTimestampMillis, + }); + + expect(await instance.getMetadata()).toEqual({ + recoveryPageShownTimestampMillis: newRecoveryPageShownTimestampMillis, + }); +}); + +test("IdentityMetadataRepository commits updated metadata to canister", async () => { + const instance = IdentityMetadataRepository.init({ + getter: getterMockSuccess, + setter: setterMockSuccess, + }); + + const newRecoveryPageShownTimestampMillis = 9876543210; + await instance.updateMetadata({ + recoveryPageShownTimestampMillis: newRecoveryPageShownTimestampMillis, + }); + + expect(setterMockSuccess).not.toHaveBeenCalled(); + await instance.commitMetadata(); + + expect(setterMockSuccess).toHaveBeenCalledTimes(1); + expect(setterMockSuccess).toHaveBeenCalledWith([ + [ + RECOVERY_PAGE_SHOW_TIMESTAMP_MILLIS, + { String: String(newRecoveryPageShownTimestampMillis) }, + ], + ]); +}); + +test("IdentityMetadataRepository doesn't commit to canister without changes", async () => { + const instance = IdentityMetadataRepository.init({ + getter: getterMockSuccess, + setter: setterMockSuccess, + }); + + expect(setterMockSuccess).not.toHaveBeenCalled(); + await instance.commitMetadata(); + + expect(setterMockSuccess).not.toHaveBeenCalled(); +}); + +test("IdentityMetadataRepository doesn't raise an error if committing fails", async () => { + const instance = IdentityMetadataRepository.init({ + getter: getterMockSuccess, + setter: setterMockError, + }); + + const newRecoveryPageShownTimestampMillis = 9876543210; + const newMetadata = { + recoveryPageShownTimestampMillis: newRecoveryPageShownTimestampMillis, + }; + await instance.updateMetadata(newMetadata); + + expect(setterMockError).not.toHaveBeenCalled(); + const committed = await instance.commitMetadata(); + + expect(committed).toBe(false); + expect(setterMockError).toHaveBeenCalledTimes(1); + expect(setterMockError).toHaveBeenCalledWith([ + [ + RECOVERY_PAGE_SHOW_TIMESTAMP_MILLIS, + { String: String(newRecoveryPageShownTimestampMillis) }, + ], + ]); + + // But the value in memory is not lost. + expect(await instance.getMetadata()).toEqual(newMetadata); +}); + +test("IdentityMetadataRepository commits additional metadata to canister after update", async () => { + const anotherMetadataEntry: [string, { String: string }] = [ + "otherKey", + { String: "otherValue" }, + ]; + const mockMoreRawMetadata: MetadataMapV2 = [ + [ + RECOVERY_PAGE_SHOW_TIMESTAMP_MILLIS, + { String: String(recoveryPageShownTimestampMillis) }, + ], + anotherMetadataEntry, + ]; + const getterMock = vi.fn().mockResolvedValue(mockMoreRawMetadata); + const instance = IdentityMetadataRepository.init({ + getter: getterMock, + setter: setterMockSuccess, + }); + + const newRecoveryPageShownTimestampMillis = 9876543210; + await instance.updateMetadata({ + recoveryPageShownTimestampMillis: newRecoveryPageShownTimestampMillis, + }); + + expect(setterMockSuccess).not.toHaveBeenCalled(); + await instance.commitMetadata(); + + expect(setterMockSuccess).toHaveBeenCalledTimes(1); + expect(setterMockSuccess).toHaveBeenCalledWith([ + anotherMetadataEntry, + [ + RECOVERY_PAGE_SHOW_TIMESTAMP_MILLIS, + { String: String(newRecoveryPageShownTimestampMillis) }, + ], + ]); +}); diff --git a/src/frontend/src/repositories/identityMetadata.ts b/src/frontend/src/repositories/identityMetadata.ts new file mode 100644 index 0000000000..f538edb55f --- /dev/null +++ b/src/frontend/src/repositories/identityMetadata.ts @@ -0,0 +1,195 @@ +import { MetadataMapV2 } from "$generated/internet_identity_types"; + +export type IdentityMetadata = { + recoveryPageShownTimestampMillis?: number; +}; + +export const RECOVERY_PAGE_SHOW_TIMESTAMP_MILLIS = + "recoveryPageShownTimestampMillis"; + +const convertMetadata = (rawMetadata: MetadataMapV2): IdentityMetadata => { + const recoveryPageEntry = rawMetadata.find( + ([key]) => key === RECOVERY_PAGE_SHOW_TIMESTAMP_MILLIS + ); + if (recoveryPageEntry === undefined) { + return {}; + } + const stringValue = recoveryPageEntry[1]; + if ("String" in stringValue) { + const recoveryPageShownTimestampMillis = Number(stringValue.String); + if (isNaN(recoveryPageShownTimestampMillis)) { + return {}; + } + return { + recoveryPageShownTimestampMillis, + }; + } + return {}; +}; + +type MetadataGetter = () => Promise; +type MetadataSetter = (metadata: MetadataMapV2) => Promise; +type RawMetadataState = MetadataMapV2 | "loading" | "error" | "not-loaded"; + +/** + * Class to manage the metadata of the identity and interact with the canister. + * + * We decided to not throw any errors because this is non-critical for the application + * and we don't want to disrupt user flows if there is an error with the metadata. + * + * This class loads the metadata in the background when it's created. + * It can then be read and updated in memory. + * The metadata needs to be committed to the canister with the `commitMetadata` method to persist the changes. + */ +export class IdentityMetadataRepository { + // The nice IdentityMetadata is exposed to the outside world, while the raw metadata is kept private. + // We keep all the raw data to maintain other metadata fields. + private rawMetadata: RawMetadataState; + // Flag to keep track whether we need to commit the metadata to the canister. + private updatedMetadata: boolean; + private readonly getter: MetadataGetter; + private readonly setter: MetadataSetter; + + static init = ({ + getter, + setter, + }: { + getter: MetadataGetter; + setter: MetadataSetter; + }) => { + const instance = new IdentityMetadataRepository({ getter, setter }); + // Load the metadata in the background. + void instance.loadMetadata(); + return instance; + }; + + private constructor({ + getter, + setter, + }: { + getter: MetadataGetter; + setter: MetadataSetter; + }) { + this.getter = getter; + this.setter = setter; + this.rawMetadata = "not-loaded"; + this.updatedMetadata = false; + } + + /** + * Load the metadata in the instance variable `rawMetadata`. + * + * This method won't throw an error if the metadata can't be loaded. + * Instead, it will set the instance variable `rawMetadata` to "error". + * + * @returns {Promise} In case a client wants to wait for the metadata to be loaded. + */ + loadMetadata = async (): Promise => { + this.rawMetadata = "loading"; + try { + this.updatedMetadata = false; + this.rawMetadata = await this.getter(); + } catch (error) { + // Do not throw the error because this is not critical for the application. + this.rawMetadata = "error"; + console.warn("Error loading metadata", error); + return; + } + }; + + private metadataIsLoaded = ( + metadata: RawMetadataState + ): metadata is MetadataMapV2 => { + return ( + this.rawMetadata !== "loading" && + this.rawMetadata !== "error" && + this.rawMetadata !== "not-loaded" + ); + }; + + /** + * Waits a maximum of 10 seconds for the metadata to be loaded. + * + * It doesn't throw an error if the metadata is not loaded. + */ + private waitUntilMetadataIsLoaded = async (): Promise => { + let currentWait = 0; + const MAX_WAIT_MILLIS = 10_000; + const ONE_WAIT_MILLIS = 1_000; + while (this.rawMetadata === "loading" || currentWait < MAX_WAIT_MILLIS) { + await new Promise((resolve) => setTimeout(resolve, 100)); + currentWait += ONE_WAIT_MILLIS; + } + }; + + /** + * Returns the metadata transformed to `IdentityMetadata`. + * + * It returns `undefined` if the metadata is not loaded. + * + * @returns {IdentityMetadata | undefined} + */ + getMetadata = async (): Promise => { + await this.waitUntilMetadataIsLoaded(); + if (this.metadataIsLoaded(this.rawMetadata)) { + return convertMetadata(this.rawMetadata); + } + return undefined; + }; + + /** + * Changes the metadata in memory but doesn't commit it to the canister. + * + * At the moment, this function only supports changing the `recoveryPageShownTimestampMillis` field. + * + * The metadata passed will be merged with the existing metadata. Same keys will be overwritten. + * + * We consider the metadata to not be crucial for the application. + * Therefore, we don't want to disrupt user flows if there is an error with the metadata. + * + * @param {Partial} partialMetadata + * @returns {Promise} To indicate that the metadata has been set. + */ + updateMetadata = async ( + partialMetadata: Partial + ): Promise => { + await this.waitUntilMetadataIsLoaded(); + if (this.metadataIsLoaded(this.rawMetadata)) { + let updatedMetadata: MetadataMapV2 = [...this.rawMetadata]; + this.updatedMetadata = true; + updatedMetadata = updatedMetadata + .filter(([key]) => key !== RECOVERY_PAGE_SHOW_TIMESTAMP_MILLIS) + .concat([ + [ + RECOVERY_PAGE_SHOW_TIMESTAMP_MILLIS, + { + String: String(partialMetadata.recoveryPageShownTimestampMillis), + }, + ], + ]); + this.rawMetadata = updatedMetadata; + } + // Do nothing if the metadata is not loaded. + }; + + /** + * Commits the metadata to the canister if needed. + * + * @returns {boolean} Whether the metadata was committed or there was nothing to commit. + * `true` if the metadata was committed or there was nothing to commit. + * `false` if there was an error committing the metadata. + */ + commitMetadata = async (): Promise => { + if (this.metadataIsLoaded(this.rawMetadata) && this.updatedMetadata) { + try { + await this.setter(this.rawMetadata); + return true; + } catch (error) { + console.warn("Error committing metadata", error); + return false; + } + } + // If there was nothing to commit, we return true. + return true; + }; +} diff --git a/src/frontend/src/utils/iiConnection.test.ts b/src/frontend/src/utils/iiConnection.test.ts new file mode 100644 index 0000000000..4f08dfddd4 --- /dev/null +++ b/src/frontend/src/utils/iiConnection.test.ts @@ -0,0 +1,90 @@ +import { MetadataMapV2, _SERVICE } from "$generated/internet_identity_types"; +import { + IdentityMetadata, + RECOVERY_PAGE_SHOW_TIMESTAMP_MILLIS, +} from "$src/repositories/identityMetadata"; +import { ActorSubclass } from "@dfinity/agent"; +import { DelegationIdentity } from "@dfinity/identity"; +import { AuthenticatedConnection } from "./iiConnection"; +import { MultiWebAuthnIdentity } from "./multiWebAuthnIdentity"; + +const mockDelegationIdentity = { + getDelegation() { + return { + delegations: [], + }; + }, +} as unknown as DelegationIdentity; + +const recoveryPageShownTimestampMillis = 1234567890; +const mockRawMetadata: MetadataMapV2 = [ + [ + RECOVERY_PAGE_SHOW_TIMESTAMP_MILLIS, + { String: String(recoveryPageShownTimestampMillis) }, + ], +]; +const mockIdentityMetadata: IdentityMetadata = { + recoveryPageShownTimestampMillis, +}; + +// Used to await that the getter has resolved. +let infoResponse: MetadataMapV2 | null | undefined = null; + +const mockActor = { + identity_info: vi.fn().mockImplementation(async () => { + // The `await` is necessary to make sure that the `getterResponse` is set before the test continues. + infoResponse = await mockRawMetadata; + return { Ok: { metadata: mockRawMetadata } }; + }), + identity_metadata_replace: vi.fn().mockResolvedValue({ Ok: null }), +} as unknown as ActorSubclass<_SERVICE>; + +beforeEach(() => { + infoResponse = undefined; + vi.clearAllMocks(); +}); + +test("initializes identity metadata repository", async () => { + const connection = new AuthenticatedConnection( + "12345", + MultiWebAuthnIdentity.fromCredentials([]), + mockDelegationIdentity, + BigInt(1234), + mockActor + ); + + await vi.waitFor(() => expect(infoResponse).toEqual(mockRawMetadata)); + + expect(await connection.getIdentityMetadata()).toEqual(mockIdentityMetadata); +}); + +test("commits changes on identity metadata", async () => { + const userNumber = BigInt(1234); + const connection = new AuthenticatedConnection( + "12345", + MultiWebAuthnIdentity.fromCredentials([]), + mockDelegationIdentity, + userNumber, + mockActor + ); + + await vi.waitFor(() => expect(infoResponse).toEqual(mockRawMetadata)); + + expect(await connection.getIdentityMetadata()).toEqual(mockIdentityMetadata); + + const newRecoveryPageShownTimestampMillis = 9876543210; + await connection.updateIdentityMetadata({ + recoveryPageShownTimestampMillis: newRecoveryPageShownTimestampMillis, + }); + + expect(mockActor.identity_metadata_replace).not.toHaveBeenCalled(); + await connection.commitMetadata(); + + expect(mockActor.identity_metadata_replace).toHaveBeenCalledTimes(1); + expect(mockActor.identity_metadata_replace).toHaveBeenCalledWith(userNumber, [ + [ + RECOVERY_PAGE_SHOW_TIMESTAMP_MILLIS, + { String: String(newRecoveryPageShownTimestampMillis) }, + ], + ]); +}); diff --git a/src/frontend/src/utils/iiConnection.ts b/src/frontend/src/utils/iiConnection.ts index f2082f26d5..237aefe46f 100644 --- a/src/frontend/src/utils/iiConnection.ts +++ b/src/frontend/src/utils/iiConnection.ts @@ -14,7 +14,11 @@ import { GetDelegationResponse, IdAliasCredentials, IdentityAnchorInfo, + IdentityInfo, + IdentityInfoError, + IdentityMetadataReplaceError, KeyType, + MetadataMapV2, PreparedIdAlias, PublicKey, Purpose, @@ -28,6 +32,10 @@ import { } from "$generated/internet_identity_types"; import { fromMnemonicWithoutValidation } from "$src/crypto/ed25519"; import { features } from "$src/features"; +import { + IdentityMetadata, + IdentityMetadataRepository, +} from "$src/repositories/identityMetadata"; import { diagnosticInfo, unknownToString } from "$src/utils/utils"; import { Actor, @@ -411,6 +419,7 @@ export class Connection { } export class AuthenticatedConnection extends Connection { + private metadataRepository: IdentityMetadataRepository; public constructor( public canisterId: string, public identity: SignIdentity, @@ -419,6 +428,24 @@ export class AuthenticatedConnection extends Connection { public actor?: ActorSubclass<_SERVICE> ) { super(canisterId); + const metadataGetter = async () => { + const response = await this.getIdentityInfo(); + if ("Ok" in response) { + return response.Ok.metadata; + } + throw new Error("Error fetching metadata"); + }; + const metadataSetter = async (metadata: MetadataMapV2) => { + const response = await this.setIdentityMetadata(metadata); + if ("Ok" in response) { + return; + } + throw new Error("Error updating metadata"); + }; + this.metadataRepository = IdentityMetadataRepository.init({ + getter: metadataGetter, + setter: metadataSetter, + }); } async getActor(): Promise> { @@ -511,6 +538,34 @@ export class AuthenticatedConnection extends Connection { await actor.remove(this.userNumber, publicKey); }; + private getIdentityInfo = async (): Promise< + { Ok: IdentityInfo } | { Err: IdentityInfoError } + > => { + const actor = await this.getActor(); + return await actor.identity_info(this.userNumber); + }; + + private setIdentityMetadata = async ( + metadata: MetadataMapV2 + ): Promise<{ Ok: null } | { Err: IdentityMetadataReplaceError }> => { + const actor = await this.getActor(); + return await actor.identity_metadata_replace(this.userNumber, metadata); + }; + + getIdentityMetadata = (): Promise => { + return this.metadataRepository.getMetadata(); + }; + + updateIdentityMetadata = ( + partialMetadata: Partial + ): Promise => { + return this.metadataRepository.updateMetadata(partialMetadata); + }; + + commitMetadata = async (): Promise => { + return await this.metadataRepository.commitMetadata(); + }; + prepareDelegation = async ( origin_: FrontendHostname, sessionKey: SessionKey,