From d4af36864a6d05e772664b4effc7cb55c7ef911c Mon Sep 17 00:00:00 2001 From: "Valentin D. Pinkman" Date: Wed, 28 Aug 2024 16:52:33 +0200 Subject: [PATCH] feat(device): implement deleteAppData logic in live-common device --- .../app-backup/restoreAppStorageInit.ts | 11 +- libs/device-core/src/errors.ts | 2 + .../appDataBackup/deleteAppData.test.ts | 177 +++++++++++++++++ .../use-cases/appDataBackup/deleteAppData.ts | 54 ++++++ .../deleteAppDataUseCase.test.ts | 179 ++++++++++++++++++ .../appDataBackup/deleteAppDataUseCase.ts | 22 +++ .../appDataBackup/deleteAppDataUseCaseDI.ts | 15 ++ .../use-cases/appDataBackup/restoreAppData.ts | 10 +- .../device/use-cases/appDataBackup/types.ts | 28 +++ 9 files changed, 494 insertions(+), 4 deletions(-) create mode 100644 libs/ledger-live-common/src/device/use-cases/appDataBackup/deleteAppData.test.ts create mode 100644 libs/ledger-live-common/src/device/use-cases/appDataBackup/deleteAppData.ts create mode 100644 libs/ledger-live-common/src/device/use-cases/appDataBackup/deleteAppDataUseCase.test.ts create mode 100644 libs/ledger-live-common/src/device/use-cases/appDataBackup/deleteAppDataUseCase.ts create mode 100644 libs/ledger-live-common/src/device/use-cases/appDataBackup/deleteAppDataUseCaseDI.ts diff --git a/libs/device-core/src/commands/use-cases/app-backup/restoreAppStorageInit.ts b/libs/device-core/src/commands/use-cases/app-backup/restoreAppStorageInit.ts index 69c54c7f3796..15a94fe11b65 100644 --- a/libs/device-core/src/commands/use-cases/app-backup/restoreAppStorageInit.ts +++ b/libs/device-core/src/commands/use-cases/app-backup/restoreAppStorageInit.ts @@ -1,7 +1,13 @@ import Transport, { StatusCodes, TransportStatusError } from "@ledgerhq/hw-transport"; import { LocalTracer } from "@ledgerhq/logs"; import type { APDU } from "../../entities/APDU"; -import { AppNotFound, InvalidAppNameLength, InvalidBackupLength, PinNotSet } from "../../../errors"; +import { + AppNotFound, + InvalidAppNameLength, + InvalidBackupLength, + PinNotSet, + UserRefusedOnDevice, +} from "../../../errors"; /** * Name in documentation: INS_APP_STORAGE_RESTORE_INIT @@ -81,8 +87,9 @@ export function parseResponse(data: Buffer): void { case StatusCodes.APP_NOT_FOUND_OR_INVALID_CONTEXT: throw new AppNotFound("Application not found."); case StatusCodes.DEVICE_IN_RECOVERY_MODE: - case StatusCodes.USER_REFUSED_ON_DEVICE: break; + case StatusCodes.USER_REFUSED_ON_DEVICE: + throw new UserRefusedOnDevice("User refused on device."); case StatusCodes.PIN_NOT_SET: throw new PinNotSet("Invalid consent, PIN is not set."); case StatusCodes.INVALID_APP_NAME_LENGTH: diff --git a/libs/device-core/src/errors.ts b/libs/device-core/src/errors.ts index 1a1749482fa7..1338488baad5 100644 --- a/libs/device-core/src/errors.ts +++ b/libs/device-core/src/errors.ts @@ -27,3 +27,5 @@ export const InvalidBackupState = createCustomErrorClass("InvalidBackupState"); export const InvalidRestoreState = createCustomErrorClass("InvalidRestoreState"); // 0x6734 export const InvalidChunkLength = createCustomErrorClass("InvalidChunkLength"); +// 0x5501 +export const UserRefusedOnDevice = createCustomErrorClass("UserRefusedOnDevice"); diff --git a/libs/ledger-live-common/src/device/use-cases/appDataBackup/deleteAppData.test.ts b/libs/ledger-live-common/src/device/use-cases/appDataBackup/deleteAppData.test.ts new file mode 100644 index 000000000000..0a4f627a6e4d --- /dev/null +++ b/libs/ledger-live-common/src/device/use-cases/appDataBackup/deleteAppData.test.ts @@ -0,0 +1,177 @@ +import { Observable, firstValueFrom, lastValueFrom } from "rxjs"; +import { deleteAppData } from "./deleteAppData"; +import { + AppStorageType, + DeleteAppDataError, + DeleteAppDataEvent, + DeleteAppDataEventType, + StorageProvider, +} from "./types"; +import { DeviceModelId } from "@ledgerhq/devices"; + +jest.mock("@ledgerhq/hw-transport"); + +describe("deleteAppData", () => { + let appName: string; + let deviceModelId: DeviceModelId; + let storageProvider: StorageProvider; + + const setItem = jest.fn(); + const getItem = jest.fn(); + const removeItem = jest.fn(); + + beforeEach(() => { + appName = "MyApp"; + deviceModelId = DeviceModelId.stax; + storageProvider = { + getItem, + setItem, + removeItem, + }; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("success case", () => { + it("should delete the app data by emitting relative events sequentially", async () => { + const deleteObservable: Observable = deleteAppData( + appName, + deviceModelId, + storageProvider, + ); + const events: DeleteAppDataEvent[] = []; + + getItem.mockResolvedValue({ + appDataInfo: { + size: 6, + dataVersion: "test", + hasSettings: true, + hasData: true, + hash: "test", + }, + appData: "bGVkZ2Vy", // base64 encoded "ledger" + }); + + removeItem.mockResolvedValue(undefined); + + // Subscribe to the observable to receive the delete events + deleteObservable.subscribe((event: DeleteAppDataEvent) => { + events.push(event); + }); + + const firstValue: DeleteAppDataEvent = await firstValueFrom(deleteObservable); + expect(firstValue).toEqual({ + type: DeleteAppDataEventType.AppDataDeleteStarted, + }); + + const lastValue: DeleteAppDataEvent = await lastValueFrom(deleteObservable); + expect(lastValue).toEqual({ + type: DeleteAppDataEventType.AppDataDeleted, + }); + + expect(events).toHaveLength(2); + }); + + it("should emit specific event when there is no app data to delete", async () => { + const deleteObservable: Observable = deleteAppData( + appName, + deviceModelId, + storageProvider, + ); + const events: DeleteAppDataEvent[] = []; + + getItem.mockResolvedValue(null); + + // Subscribe to the observable to receive the delete events + deleteObservable.subscribe((event: DeleteAppDataEvent) => { + events.push(event); + }); + + const firstValue: DeleteAppDataEvent = await firstValueFrom(deleteObservable); + expect(firstValue).toEqual({ + type: DeleteAppDataEventType.AppDataDeleteStarted, + }); + + const lastValue: DeleteAppDataEvent = await lastValueFrom(deleteObservable); + expect(lastValue).toEqual({ + type: DeleteAppDataEventType.NoAppDataToDelete, + }); + + expect(events).toHaveLength(2); + }); + }); + + describe("error case", () => { + it("should emit an error event when there is an error during the deletion process", async () => { + const deleteObservable: Observable = deleteAppData( + appName, + deviceModelId, + storageProvider, + ); + + const events: DeleteAppDataEvent[] = []; + + getItem.mockResolvedValue({ + appDataInfo: { + size: 6, + dataVersion: "test", + hasSettings: true, + hasData: true, + hash: "test", + }, + appData: "bGVkZ2Vy", // base64 encoded "ledger" + }); + + removeItem.mockRejectedValue(new Error("Failed to delete app data")); + + // Subscribe to the observable to receive the delete events + deleteObservable.subscribe((event: DeleteAppDataEvent) => { + events.push(event); + }); + + const firstValue: DeleteAppDataEvent = await firstValueFrom(deleteObservable); + expect(firstValue).toEqual({ + type: DeleteAppDataEventType.AppDataDeleteStarted, + }); + + lastValueFrom(deleteObservable).catch(e => { + expect(e).toBeInstanceOf(DeleteAppDataError); + expect(e.message).toBe("Failed to delete app data"); + }); + + expect(events).toHaveLength(1); + }); + + it("should emit an error event when there is an error getting the app data from storage", async () => { + const deleteObservable: Observable = deleteAppData( + appName, + deviceModelId, + storageProvider, + ); + + getItem.mockRejectedValue(new Error("Error fetching app data")); + + lastValueFrom(deleteObservable).catch(e => { + expect(e).toBeInstanceOf(Error); + expect(e.message).toBe("Error fetching app data"); + }); + }); + + it("should emit an error event when there is an unkown error getting the app data from storage", async () => { + const deleteObservable: Observable = deleteAppData( + appName, + deviceModelId, + storageProvider, + ); + + getItem.mockRejectedValue(null); + + lastValueFrom(deleteObservable).catch(e => { + expect(e).toBeInstanceOf(Error); + expect(e.message).toBe("Unknown error"); + }); + }); + }); +}); diff --git a/libs/ledger-live-common/src/device/use-cases/appDataBackup/deleteAppData.ts b/libs/ledger-live-common/src/device/use-cases/appDataBackup/deleteAppData.ts new file mode 100644 index 000000000000..019f343eb85f --- /dev/null +++ b/libs/ledger-live-common/src/device/use-cases/appDataBackup/deleteAppData.ts @@ -0,0 +1,54 @@ +import { catchError, from, Observable, of, switchMap, throwError } from "rxjs"; +import { DeviceModelId } from "@ledgerhq/devices"; +import { + AppName, + AppStorageType, + DeleteAppDataError, + DeleteAppDataEvent, + DeleteAppDataEventType, + StorageProvider, +} from "./types"; + +export function deleteAppData( + appName: AppName, + deviceModelId: DeviceModelId, + storageProvider: StorageProvider, +): Observable { + const obs = new Observable(subscriber => { + subscriber.next({ type: DeleteAppDataEventType.AppDataDeleteStarted }); + const sub = from(storageProvider.getItem(`${deviceModelId}-${appName}`)) + .pipe( + catchError(e => { + if (e instanceof Error) { + subscriber.error(e); + } else { + subscriber.error(new Error("Unknown error")); + } + return of(null); + }), + switchMap(async item => { + if (item) { + try { + await storageProvider.removeItem(`${deviceModelId}-${appName}`); + subscriber.next({ type: DeleteAppDataEventType.AppDataDeleted }); + subscriber.complete(); + } catch (e: unknown) { + const message = e instanceof Error ? e.message : "Error deleting app data"; + throwError(() => new DeleteAppDataError(message)); + } + } else { + subscriber.next({ type: DeleteAppDataEventType.NoAppDataToDelete }); + subscriber.complete(); + } + }), + ) + .subscribe(); + + return () => { + subscriber.complete(); + sub.unsubscribe(); + }; + }); + + return obs; +} diff --git a/libs/ledger-live-common/src/device/use-cases/appDataBackup/deleteAppDataUseCase.test.ts b/libs/ledger-live-common/src/device/use-cases/appDataBackup/deleteAppDataUseCase.test.ts new file mode 100644 index 000000000000..5368bf5b966c --- /dev/null +++ b/libs/ledger-live-common/src/device/use-cases/appDataBackup/deleteAppDataUseCase.test.ts @@ -0,0 +1,179 @@ +import { concat, firstValueFrom, lastValueFrom, Observable, of, throwError } from "rxjs"; +import { DeviceModelId } from "@ledgerhq/devices"; +import { + AppStorageType, + DeleteAppDataError, + DeleteAppDataEvent, + DeleteAppDataEventType, + StorageProvider, +} from "./types"; +import { deleteAppDataUseCase } from "./deleteAppDataUseCase"; + +describe("deleteAppDataUseCase", () => { + let appName: string; + let deviceModelId: DeviceModelId; + let storageProvider: StorageProvider; + + const setItem = jest.fn(); + const getItem = jest.fn(); + const removeItem = jest.fn(); + const deleteAppDataFn = jest.fn(); + + beforeEach(() => { + appName = "MyApp"; + deviceModelId = DeviceModelId.stax; + storageProvider = { + setItem, + getItem, + removeItem, + }; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("success cases", () => { + it("should emit the correct events when the app data is deleted", async () => { + deleteAppDataFn.mockReturnValue( + concat( + of({ + type: DeleteAppDataEventType.AppDataDeleteStarted, + }), + of({ + type: DeleteAppDataEventType.AppDataDeleted, + }), + ), + ); + + const deleteAppDataUseCaseObservable: Observable = deleteAppDataUseCase( + appName, + deviceModelId, + storageProvider, + deleteAppDataFn, + ); + + const firstValue = await firstValueFrom(deleteAppDataUseCaseObservable); + expect(firstValue).toEqual({ + type: DeleteAppDataEventType.AppDataDeleteStarted, + }); + + const secondValue = await lastValueFrom(deleteAppDataUseCaseObservable); + expect(secondValue).toEqual({ type: DeleteAppDataEventType.AppDataDeleted }); + }); + + it("should emit the correct events when the app data is not found", async () => { + deleteAppDataFn.mockReturnValue( + concat( + of({ + type: DeleteAppDataEventType.AppDataDeleteStarted, + }), + of({ + type: DeleteAppDataEventType.NoAppDataToDelete, + }), + ), + ); + + const deleteAppDataUseCaseObservable: Observable = deleteAppDataUseCase( + appName, + deviceModelId, + storageProvider, + deleteAppDataFn, + ); + + const firstValue = await firstValueFrom(deleteAppDataUseCaseObservable); + expect(firstValue).toEqual({ + type: DeleteAppDataEventType.AppDataDeleteStarted, + }); + + const secondValue = await lastValueFrom(deleteAppDataUseCaseObservable); + expect(secondValue).toEqual({ type: DeleteAppDataEventType.NoAppDataToDelete }); + }); + }); + + describe("error cases", () => { + it("should emit the correct events when there is an error deleting the app data", async () => { + deleteAppDataFn.mockReturnValue( + concat( + of({ + type: DeleteAppDataEventType.AppDataDeleteStarted, + }), + throwError(() => new DeleteAppDataError("Error deleting app data")), + ), + ); + + const deleteAppDataUseCaseObservable: Observable = deleteAppDataUseCase( + appName, + deviceModelId, + storageProvider, + deleteAppDataFn, + ); + + const firstValue = await firstValueFrom(deleteAppDataUseCaseObservable); + expect(firstValue).toEqual({ + type: DeleteAppDataEventType.AppDataDeleteStarted, + }); + + lastValueFrom(deleteAppDataUseCaseObservable).catch(error => { + expect(error).toBeInstanceOf(DeleteAppDataError); + expect(error.message).toBe("Error deleting app data"); + }); + }); + + it("should emit the correct events when there is an error getting the app data", async () => { + deleteAppDataFn.mockReturnValue( + concat( + of({ + type: DeleteAppDataEventType.AppDataDeleteStarted, + }), + throwError(() => new DeleteAppDataError("Error getting app data")), + ), + ); + + const deleteAppDataUseCaseObservable: Observable = deleteAppDataUseCase( + appName, + deviceModelId, + storageProvider, + deleteAppDataFn, + ); + + const firstValue = await firstValueFrom(deleteAppDataUseCaseObservable); + expect(firstValue).toEqual({ + type: DeleteAppDataEventType.AppDataDeleteStarted, + }); + + lastValueFrom(deleteAppDataUseCaseObservable).catch(error => { + expect(error).toBeInstanceOf(DeleteAppDataError); + expect(error.message).toBe("Error getting app data"); + }); + }); + + it("should emit the correct events when there is an unknown error", async () => { + deleteAppDataFn.mockReturnValue( + concat( + of({ + type: DeleteAppDataEventType.AppDataDeleteStarted, + }), + throwError(() => new Error("Unknown error")), + ), + ); + + const deleteAppDataUseCaseObservable: Observable = deleteAppDataUseCase( + appName, + deviceModelId, + storageProvider, + deleteAppDataFn, + ); + + const firstValue = await firstValueFrom(deleteAppDataUseCaseObservable); + expect(firstValue).toEqual({ + type: DeleteAppDataEventType.AppDataDeleteStarted, + }); + + lastValueFrom(deleteAppDataUseCaseObservable).catch(error => { + expect(error).toBeInstanceOf(Error); + expect(error.message).toBe("Unknown error"); + }); + }); + }); +}); diff --git a/libs/ledger-live-common/src/device/use-cases/appDataBackup/deleteAppDataUseCase.ts b/libs/ledger-live-common/src/device/use-cases/appDataBackup/deleteAppDataUseCase.ts new file mode 100644 index 000000000000..2abbf7ab3e09 --- /dev/null +++ b/libs/ledger-live-common/src/device/use-cases/appDataBackup/deleteAppDataUseCase.ts @@ -0,0 +1,22 @@ +import { from, Observable } from "rxjs"; +import { AppName, AppStorageType, DeleteAppDataEvent, StorageProvider } from "./types"; +import { DeviceModelId } from "@ledgerhq/devices"; + +/** + * Delete the local app data for a specific app on a Ledger device. + * + * @param appName name of the app to delete data for + * @param deviceModelId model id of the device + * @param storageProvider storage provider object used for storing the backup data + * @param deleteAppDataFn function that returns observable for the delete process + * @returns Observable + */ +export function deleteAppDataUseCase( + appName: AppName, + deviceModelId: DeviceModelId, + storageProvider: StorageProvider, + deleteAppDataFn: () => Observable, +) { + const obs: Observable = from(deleteAppDataFn()); + return obs; +} diff --git a/libs/ledger-live-common/src/device/use-cases/appDataBackup/deleteAppDataUseCaseDI.ts b/libs/ledger-live-common/src/device/use-cases/appDataBackup/deleteAppDataUseCaseDI.ts new file mode 100644 index 000000000000..96344422b54f --- /dev/null +++ b/libs/ledger-live-common/src/device/use-cases/appDataBackup/deleteAppDataUseCaseDI.ts @@ -0,0 +1,15 @@ +import { Observable } from "rxjs"; +import { AppName, AppStorageType, DeleteAppDataEvent, StorageProvider } from "./types"; +import { DeviceModelId } from "@ledgerhq/devices"; +import { deleteAppData } from "./deleteAppData"; +import { deleteAppDataUseCase } from "./deleteAppDataUseCase"; + +export function deleteAppDataUseCaseDI( + appName: AppName, + deviceModelId: DeviceModelId, + storageProvider: StorageProvider, +): Observable { + return deleteAppDataUseCase(appName, deviceModelId, storageProvider, () => + deleteAppData(appName, deviceModelId, storageProvider), + ); +} diff --git a/libs/ledger-live-common/src/device/use-cases/appDataBackup/restoreAppData.ts b/libs/ledger-live-common/src/device/use-cases/appDataBackup/restoreAppData.ts index 29398b4365fd..d19e7c102feb 100644 --- a/libs/ledger-live-common/src/device/use-cases/appDataBackup/restoreAppData.ts +++ b/libs/ledger-live-common/src/device/use-cases/appDataBackup/restoreAppData.ts @@ -1,10 +1,11 @@ import { AppNotFound, + UserRefusedOnDevice, restoreAppStorage, restoreAppStorageCommit, restoreAppStorageInit, } from "@ledgerhq/device-core"; -import Transport, { TransportStatusError } from "@ledgerhq/hw-transport"; +import Transport from "@ledgerhq/hw-transport"; import { Observable, catchError, from, of, switchMap } from "rxjs"; import { AppName, RestoreAppDataEvent, RestoreAppDataEventType } from "./types"; @@ -50,6 +51,8 @@ export function restoreAppData( // Commit the restore process, last step await restoreAppStorageCommit(transport); + // NOTE: DELETE DATA + subscriber.next({ type: RestoreAppDataEventType.AppDataRestored, }); @@ -65,10 +68,13 @@ export function restoreAppData( } // User refused on device - if (e instanceof TransportStatusError && e.statusCode === 0x5501) { + if (e instanceof UserRefusedOnDevice) { + // NOTE: Display a message to the user to retry the restore process + // If he does not, we should delete the app data (in another flow) subscriber.next({ type: RestoreAppDataEventType.UserRefused, }); + subscriber.complete(); return of(null); } diff --git a/libs/ledger-live-common/src/device/use-cases/appDataBackup/types.ts b/libs/ledger-live-common/src/device/use-cases/appDataBackup/types.ts index 6df0268901e2..3f49bcfd3ec0 100644 --- a/libs/ledger-live-common/src/device/use-cases/appDataBackup/types.ts +++ b/libs/ledger-live-common/src/device/use-cases/appDataBackup/types.ts @@ -142,3 +142,31 @@ export type RestoreAppDataEvent = * An error that occurs during the restore process, the error message should be descriptive when thrown. */ export const RestoreAppDataError = createCustomErrorClass("RestoreAppDataError"); + +export enum DeleteAppDataEventType { + AppDataDeleteStarted = "appDataDeleteStarted", + /** + * The application data has been deleted. + */ + AppDataDeleted = "appDataDeleted", + /** + * There is no application data to delete. + */ + NoAppDataToDelete = "noAppDataToDelete", +} + +export type DeleteAppDataEvent = + | { + type: DeleteAppDataEventType.AppDataDeleteStarted; + } + | { + type: DeleteAppDataEventType.AppDataDeleted; + } + | { + type: DeleteAppDataEventType.NoAppDataToDelete; + }; + +/** + * An error that occurs during the delete process (local data), the error message should be descriptive when thrown. + */ +export const DeleteAppDataError = createCustomErrorClass("DeleteAppDataError");