Skip to content

Commit

Permalink
feat(device): implement deleteAppData logic in live-common device
Browse files Browse the repository at this point in the history
  • Loading branch information
valpinkman committed Aug 29, 2024
1 parent 3b9c1cf commit d4af368
Show file tree
Hide file tree
Showing 9 changed files with 494 additions and 4 deletions.
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions libs/device-core/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Original file line number Diff line number Diff line change
@@ -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<AppStorageType>;

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<DeleteAppDataEvent> = 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<DeleteAppDataEvent> = 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<DeleteAppDataEvent> = 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<DeleteAppDataEvent> = 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<DeleteAppDataEvent> = deleteAppData(
appName,
deviceModelId,
storageProvider,
);

getItem.mockRejectedValue(null);

lastValueFrom(deleteObservable).catch(e => {
expect(e).toBeInstanceOf(Error);
expect(e.message).toBe("Unknown error");
});
});
});
});
Original file line number Diff line number Diff line change
@@ -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<AppStorageType>,
): Observable<DeleteAppDataEvent> {
const obs = new Observable<DeleteAppDataEvent>(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;
}
Loading

0 comments on commit d4af368

Please sign in to comment.