Skip to content

Commit

Permalink
🧪 [Support] Unit tests Trustchain SDK (#7433)
Browse files Browse the repository at this point in the history
* Test the Trustchain creation flow

* Update unit test

* Test remove member

* Reuse the same msw instance between tests

* Move the simpleEncryption test into the sdk test suite

* Reuse `StreamTree.deserialize` in `fetchTrustchain`

* Add more expectations to the tests

* Clarify close block expectation

* Remove "foo" from unit tests

* Clarify that `api.putCommands` is not called during the truschain creation

---------

Co-authored-by: Theophile Sandoz <Theophile Sandoz>
  • Loading branch information
thesan committed Aug 9, 2024
1 parent 1353e7a commit 5009a3c
Show file tree
Hide file tree
Showing 5 changed files with 270 additions and 35 deletions.
34 changes: 32 additions & 2 deletions libs/hw-trustchain/src/StreamTree.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { CommandStream, Device } from ".";
import { DerivationPath } from "./Crypto";
import { CommandStream, CommandStreamDecoder, CommandStreamEncoder, Device } from ".";
import { crypto, DerivationPath } from "./Crypto";
import { IndexedTree } from "./IndexedTree";

/**
Expand Down Expand Up @@ -149,6 +149,36 @@ export class StreamTree {
return new StreamTree(newTree);
}

public serialize(): Record<string, string> {
const streamEntries = serializeTree(this.tree, []);
const entries = streamEntries.flatMap(([path, stream]) =>
stream ? [[path, crypto.to_hex(CommandStreamEncoder.encode(stream.blocks))]] : [],
);
return Object.fromEntries(entries);

function serializeTree(
tree: IndexedTree<CommandStream>,
path: number[],
): [string, CommandStream | null][] {
const stream = tree.getValue();
const childrens = tree.getChildren();

return [
[DerivationPath.toString(path), stream],
...Array.from(childrens.entries()).flatMap(([index, child]) =>
serializeTree(child, [...path, index]),
),
];
}
}

static deserialize(data: Record<string, string>): StreamTree {
const streams = Object.values(data).map(
data => new CommandStream(CommandStreamDecoder.decode(crypto.from_hex(data))),
);
return StreamTree.from(...streams);
}

static async createNewTree(owner: Device, opts: StreamTreeCreateOpts = {}): Promise<StreamTree> {
let stream = new CommandStream();
const streamToIssue = stream.edit().seed(opts.topic);
Expand Down
236 changes: 236 additions & 0 deletions libs/trustchain/src/__tests__/unit/sdk.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
import { DefaultBodyType, http, HttpResponse, PathParams, StrictRequest } from "msw";
import { setupServer } from "msw/node";
import {
CommandBlock,
crypto,
Device,
Permissions,
SoftwareDevice,
StreamTree,
} from "@ledgerhq/hw-trustchain";
import { getEnv } from "@ledgerhq/live-env";
import { PutCommandsRequest } from "../../api";
import { HWDeviceProvider } from "../../HWDeviceProvider";
import { convertLiveCredentialsToKeyPair, SDK } from "../../sdk";
import { TrustchainResultType } from "../../types";

describe("Trustchain SDK", () => {
// Setup API calls mocks
const apiMocks = {
getTrustchainsMock: jest.fn(),
getTrustchainByIdMock: jest.fn(),
getChalenge: jest.fn(),
postAuthenticate: jest.fn(),
postSeed: jest.fn<object, [StrictRequest<CommandBlock[]>]>(),
postDerivation: jest.fn<object, [StrictRequest<CommandBlock[]>]>(),
putCommands: jest.fn<object, [StrictRequest<PutCommandsRequest>]>(),
};
const mswServer = setupServer(
http.get("*/v1/trustchains", () => HttpResponse.json(apiMocks.getTrustchainsMock())),
http.get("*/v1/trustchain/ROOTID", () => HttpResponse.json(apiMocks.getTrustchainByIdMock())),
http.get("*/v1/challenge", () => HttpResponse.json(apiMocks.getChalenge())),
http.post("*/v1/authenticate", () => HttpResponse.json(apiMocks.postAuthenticate())),
http.post<PathParams, CommandBlock[]>("*/v1/seed", ({ request }) =>
HttpResponse.json(apiMocks.postSeed(request)),
),
http.post<PathParams, CommandBlock[]>("*/v1/trustchain/ROOTID/derivation", ({ request }) =>
HttpResponse.json(apiMocks.postDerivation(request)),
),
http.put<PathParams, PutCommandsRequest>("*/v1/trustchain/ROOTID/commands", ({ request }) =>
HttpResponse.json(apiMocks.putCommands(request)),
),
http.all("*", () => HttpResponse.json({})),
);
mswServer.listen();

// Setup APDU device interactions mocks
const HWDeviceProviderMethodsMocks = {
withJwt: jest.fn(),
withHw: jest.fn(),
refreshJwt: jest.fn(),
clearJwt: jest.fn(),
} satisfies Partial<HWDeviceProvider>;
const hwDeviceProviderMock = HWDeviceProviderMethodsMocks as unknown as HWDeviceProvider;

afterAll(() => {
mswServer.close();
});

const apiBaseUrl = getEnv("TRUSTCHAIN_API_STAGING");
const sdkContext = { applicationId: 16, name: "alice", apiBaseUrl };

beforeEach(() => {
Object.values(apiMocks).forEach(mock => mock.mockClear());
Object.values(HWDeviceProviderMethodsMocks).forEach(mock => mock.mockClear());
});

it("encryptUserData + decryptUserData", async () => {
const sdk = new SDK(sdkContext, hwDeviceProviderMock);
const obj = new Uint8Array([1, 2, 3, 4, 5]);
const keypair = await crypto.randomKeypair();
const trustchain = {
rootId: "",
walletSyncEncryptionKey: crypto.to_hex(keypair.privateKey),
applicationPath: "m/0'/16'/0'",
};
const encrypted = await sdk.encryptUserData(trustchain, obj);
const decrypted = await sdk.decryptUserData(trustchain, encrypted);
expect(decrypted).toEqual(obj);
});

it("should create Trustchain", async () => {
const { alice } = MOCK_DATA.members;

// Mock trustchain states:
const device = new SoftwareDevice(convertLiveCredentialsToKeyPair(alice));
const initialTree = await createTrustChain(device);
const oneMemberTree = await addMember(device, "m/0'/16'/0'", "alice")(initialTree);

// Mock API calls:
apiMocks.getTrustchainsMock.mockReturnValueOnce({});
apiMocks.getTrustchainsMock.mockReturnValueOnce({ ROOTID: initialTree.serialize() });
apiMocks.getTrustchainByIdMock.mockReturnValue(initialTree.serialize());

// Mock APDU device interactions:
HWDeviceProviderMethodsMocks.withJwt.mockImplementation(async (deviceId, job) =>
job({ accessToken: "ACCESS TOKEN" }),
);
HWDeviceProviderMethodsMocks.withHw.mockResolvedValueOnce(initialTree);
HWDeviceProviderMethodsMocks.withHw.mockResolvedValueOnce(oneMemberTree);

// Run the test:
const sdk = new SDK(sdkContext, hwDeviceProviderMock);
const { type, trustchain } = await sdk.getOrCreateTrustchain("", alice);

// Check expectations:
expect(type).toBe(TrustchainResultType.created);
expect(trustchain).toEqual({
applicationPath: "m/0'/16'/0'",
rootId: "ROOTID",
walletSyncEncryptionKey: expect.stringMatching(/^[0-9a-f]{64}$/),
});

expect(await jsonRequestContent(apiMocks.postSeed)).toEqual([oneMemberTree.serialize()["m/"]]);
expect(await jsonRequestContent(apiMocks.postDerivation)).toEqual([
oneMemberTree.serialize()["m/0'/16'/0'"],
]);
expect(apiMocks.putCommands).not.toHaveBeenCalled();

expect(HWDeviceProviderMethodsMocks.withJwt).toHaveBeenCalled();
expect(HWDeviceProviderMethodsMocks.withHw).toHaveBeenCalledTimes(2);
});

it("should remove a member from the Trustchain", async () => {
const { alice, bob, charlie } = MOCK_DATA.members;

// Mock trustchain states:
const device = new SoftwareDevice(convertLiveCredentialsToKeyPair(alice));
const closedStreamTree = await createTrustChain(device)
.then(addMember(device, "m/0'/16'/0'", "alice"))
.then(addMember(device, "m/0'/16'/0'", "bob"))
.then(addMember(device, "m/0'/16'/0'", "charlie"))
.then(tree => tree.close("m/0'/16'/0'", device));
const rmMembersTree = await addMember(device, "m/0'/16'/1'", "alice")(closedStreamTree);

// Mock API calls:
apiMocks.getTrustchainByIdMock.mockReturnValue(closedStreamTree.serialize());
apiMocks.getChalenge.mockReturnValue({ json: {}, tlv: MOCK_DATA.challengeTlv });
apiMocks.postAuthenticate.mockReturnValue({
accessToken: "BACKEND JWT",
permissions: { ROOTID: { "m/0'/16'/1'": ["owner"] } },
});

// Mock APDU device interactions:
HWDeviceProviderMethodsMocks.withJwt.mockImplementation(async (deviceId, job) =>
job({ accessToken: "ACCESS TOKEN" }),
);
HWDeviceProviderMethodsMocks.withHw.mockResolvedValueOnce(closedStreamTree);
HWDeviceProviderMethodsMocks.withHw.mockResolvedValueOnce(rmMembersTree);

// Mock the lifecycle callback:
const afterRotation = jest.fn();
const onTrustchainRotation = jest.fn();
onTrustchainRotation.mockResolvedValueOnce(afterRotation);

// Run the test:
const sdk = new SDK(sdkContext, hwDeviceProviderMock, { onTrustchainRotation });

const trustchain = {
applicationPath: "m/0'/16'/0'",
rootId: "ROOTID",
walletSyncEncryptionKey: "",
};
const memberToRemove = {
name: "bob",
id: bob.pubkey,
permissions: Permissions.OWNER,
};
const newTrustchain = await sdk.removeMember("", trustchain, alice, memberToRemove);

// Check expectations:
expect(newTrustchain).toEqual({
applicationPath: "m/0'/16'/1'",
rootId: "ROOTID",
walletSyncEncryptionKey: expect.stringMatching(/^[0-9a-f]{64}$/),
});

expect(await jsonRequestContent(apiMocks.postDerivation)).toEqual([
rmMembersTree.serialize()["m/0'/16'/1'"],
]);

const putCommands = await jsonRequestContent(apiMocks.putCommands);
const closeBlock = putCommands.find(_ => _.path === "m/0'/16'/0'")?.blocks[0] ?? "";
const pushMemberBlock = putCommands.find(_ => _.path === "m/0'/16'/1'")?.blocks[0] ?? "";
expect(putCommands).toEqual([
{ path: "m/0'/16'/1'", blocks: [pushMemberBlock] },
{ path: "m/0'/16'/0'", blocks: [closeBlock] }, // The closed stream command is sent last
]);
expect(closeBlock).toBe(closedStreamTree.serialize()["m/0'/16'/0'"].slice(-closeBlock.length));
expect(pushMemberBlock).not.toContain(bob.pubkey);
expect(pushMemberBlock).toContain(charlie.pubkey);

expect(HWDeviceProviderMethodsMocks.withJwt).toHaveBeenCalled();
expect(HWDeviceProviderMethodsMocks.withHw).toHaveBeenCalledTimes(2);
expect(HWDeviceProviderMethodsMocks.refreshJwt).toHaveBeenCalledTimes(1);

expect(onTrustchainRotation).toHaveBeenCalledWith(sdk, trustchain, alice);
expect(afterRotation).toHaveBeenCalledWith(newTrustchain);
});
});

const MOCK_DATA = {
members: {
alice: {
pubkey: "02e3311a12c450604725f02d1a775ef5cdb4a1b832eb41ac6b1302adbe92a612fc",
privatekey: "873f500bd20783224f7e78d4f8cce3d2bf69eb8008fbd697d20bbea31a721a03",
},
bob: {
pubkey: "034ac6813695b0d5e033a2a19061c83951e2241aad62ec8fa347a944831c07ea82",
},
charlie: {
pubkey: "03b4165cddf39e58f3a89682fdf1ccba213167084fecdbb3b86669d40d201df1cf",
},
},

challengeTlv:
"010107020100121053801a35c2e24b627d6e4925ce318980140101154630440220319b42a416512437e48d9c9bf204daea7da03d452c50a8caa4c2d152407ffd0c02201f121b0e99df1d30f4757b6a00b8d974d70996771893ac49c4a245c147cc1d8f160466a90248202b7472757374636861696e2d6261636b656e642e6170692e6177732e7374672e6c64672d746563682e636f6d320121332103cb7628e7248ddf9c07da54b979f16bf081fb3d173aac0992ad2a44ef6a388ae2600401000000",
};

function createTrustChain(device: Device): Promise<StreamTree> {
return StreamTree.createNewTree(device);
}

function addMember(
device: Device,
path: string,
name: keyof typeof MOCK_DATA.members,
): (tree: StreamTree) => Promise<StreamTree> {
const memberId = crypto.from_hex(MOCK_DATA.members[name].pubkey);
return (tree: StreamTree) => tree.share(path, device, memberId, name, Permissions.OWNER);
}

function jsonRequestContent<T extends DefaultBodyType>(
mock: jest.Mock<object, [StrictRequest<T>]>,
): Promise<T[]> {
return Promise.all(mock.mock.calls.map(([request]) => request.json()));
}
2 changes: 1 addition & 1 deletion libs/trustchain/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ export type TrustchainResponse = {
[key: string]: string;
};

type PutCommandsRequest = {
export type PutCommandsRequest = {
path: string;
blocks: string[];
};
Expand Down
7 changes: 1 addition & 6 deletions libs/trustchain/src/sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,7 @@ import {
import {
crypto,
Challenge,
CommandStream,
CommandStreamEncoder,
CommandStreamDecoder,
StreamTree,
Permissions,
DerivationPath,
Expand Down Expand Up @@ -337,10 +335,7 @@ export class SDK implements TrustchainSDK {

private async fetchTrustchain(jwt: JWT, trustchainId: string) {
const trustchainData = await this.api.getTrustchain(jwt, trustchainId);
const streams = Object.values(trustchainData).map(
data => new CommandStream(CommandStreamDecoder.decode(crypto.from_hex(data))),
);
const streamTree = StreamTree.from(...streams);
const streamTree = StreamTree.deserialize(trustchainData);
return { streamTree };
}

Expand Down
26 changes: 0 additions & 26 deletions libs/trustchain/src/simpleEncryption.sdk.test.ts

This file was deleted.

0 comments on commit 5009a3c

Please sign in to comment.