Skip to content

Commit

Permalink
feat(NOTIFY-1128): add batch PUT endpoint support (#4723)
Browse files Browse the repository at this point in the history
## Explanation

This PR adds support for the new batch PUT endpoint.

## References


[NOTIFY-1128](https://consensyssoftware.atlassian.net/jira/software/projects/NOTIFY/boards/616?assignee=712020%3A5843b7e2-a7fe-4c45-9fbd-e1f2b2eb58c2&selectedIssue=NOTIFY-1128)

## Changelog

<!--
If you're making any consumer-facing changes, list those changes here as
if you were updating a changelog, using the template below as a guide.

(CATEGORY is one of BREAKING, ADDED, CHANGED, DEPRECATED, REMOVED, or
FIXED. For security-related issues, follow the Security Advisory
process.)

Please take care to name the exact pieces of the API you've added or
changed (e.g. types, interfaces, functions, or methods).

If there are any breaking changes, make sure to offer a solution for
consumers to follow once they upgrade to the changes.

Finally, if you're only making changes to development scripts or tests,
you may replace the template below with "None".
-->

### `@metamask/profile-sync-controller`

- **ADDED**: batch PUT endpoint support for both SDK and controllers


## Checklist

- [x] I've updated the test suite for new or updated code as appropriate
- [x] I've updated documentation (JSDoc, Markdown, etc.) for new or
updated code as appropriate
- [x] I've highlighted breaking changes using the "BREAKING" category
above as appropriate
- [x] I've prepared draft pull requests for clients and consumer
packages to resolve any breaking changes


[NOTIFY-1128]:
https://consensyssoftware.atlassian.net/browse/NOTIFY-1128?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ
  • Loading branch information
mathieuartu authored Sep 23, 2024
1 parent 8c6b6fc commit ea44c16
Show file tree
Hide file tree
Showing 9 changed files with 335 additions and 10 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { createSHA256Hash } from '../../shared/encryption';
import type {
UserStoragePathWithFeatureAndKey,
UserStoragePathWithFeatureOnly,
UserStoragePathWithKeyOnly,
} from '../../shared/storage-schema';
import type { NativeScrypt } from '../../shared/types/encryption';
import { createSnapSignMessageRequest } from '../authentication/auth-snap-requests';
Expand All @@ -42,6 +43,7 @@ import {
} from './accounts/user-storage';
import { startNetworkSyncing } from './network-syncing/controller-integration';
import {
batchUpsertUserStorage,
getUserStorage,
getUserStorageAllFeatureEntries,
upsertUserStorage,
Expand Down Expand Up @@ -635,6 +637,32 @@ export default class UserStorageController extends BaseController<
});
}

/**
* Allows storage of multiple user data entries for one specific feature. Data stored must be string formatted.
* Developers can extend the entry path through the `schema.ts` file.
*
* @param path - string in the form of `${feature}` that matches schema
* @param values - data to store, in the form of an array of `[entryKey, entryValue]` pairs
* @returns nothing. NOTE that an error is thrown if fails to store data.
*/

public async performBatchSetStorage(
path: UserStoragePathWithFeatureOnly,
values: [UserStoragePathWithKeyOnly, string][],
): Promise<void> {
this.#assertProfileSyncingEnabled();

const { bearerToken, storageKey } =
await this.#getStorageKeyAndBearerToken();

await batchUpsertUserStorage(values, {
path,
bearerToken,
storageKey,
nativeScryptCrypto: this.#nativeScryptCrypto,
});
}

/**
* Retrieves the storage key, for internal use only!
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,3 +107,13 @@ export const getMockUserStoragePutResponse = (
response: null,
} satisfies MockResponse;
};

export const getMockUserStorageBatchPutResponse = (
path: UserStoragePathWithFeatureOnly = 'notifications',
) => {
return {
url: getMockUserStorageEndpoint(path),
requestMethod: 'PUT',
response: null,
} satisfies MockResponse;
};
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
getMockUserStorageGetResponse,
getMockUserStoragePutResponse,
getMockUserStorageAllFeatureEntriesResponse,
getMockUserStorageBatchPutResponse,
} from './mockResponses';

type MockReply = {
Expand Down Expand Up @@ -52,10 +53,27 @@ export const mockEndpointGetUserStorage = async (
export const mockEndpointUpsertUserStorage = (
path: UserStoragePathWithFeatureAndKey = 'notifications.notification_settings',
mockReply?: Pick<MockReply, 'status'>,
expectCallback?: (requestBody: nock.Body) => Promise<void>,
) => {
const mockResponse = getMockUserStoragePutResponse(path);
const mockEndpoint = nock(mockResponse.url)
.put('')
.reply(mockReply?.status ?? 204);
.reply(mockReply?.status ?? 204, async (_, requestBody) => {
await expectCallback?.(requestBody);
});
return mockEndpoint;
};

export const mockEndpointBatchUpsertUserStorage = (
path: UserStoragePathWithFeatureOnly = 'notifications',
mockReply?: Pick<MockReply, 'status'>,
callback?: (uri: string, requestBody: nock.Body) => Promise<void>,
) => {
const mockResponse = getMockUserStorageBatchPutResponse(path);
const mockEndpoint = nock(mockResponse.url)
.put('')
.reply(mockReply?.status ?? 204, async (uri, requestBody) => {
return await callback?.(uri, requestBody);
});
return mockEndpoint;
};
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
import type { UserStoragePathWithKeyOnly } from 'src/shared/storage-schema';

import encryption, { createSHA256Hash } from '../../shared/encryption';
import {
mockEndpointGetUserStorage,
mockEndpointUpsertUserStorage,
mockEndpointGetUserStorageAllFeatureEntries,
mockEndpointBatchUpsertUserStorage,
} from './__fixtures__/mockServices';
import {
MOCK_ENCRYPTED_STORAGE_DATA,
MOCK_STORAGE_DATA,
MOCK_STORAGE_KEY,
} from './__fixtures__/mockStorage';
import type { GetUserStorageResponse } from './services';
import {
batchUpsertUserStorage,
getUserStorage,
getUserStorageAllFeatureEntries,
upsertUserStorage,
Expand Down Expand Up @@ -132,16 +136,31 @@ describe('user-storage/services.ts - getUserStorageAllFeatureEntries() tests', (

describe('user-storage/services.ts - upsertUserStorage() tests', () => {
const actCallUpsertUserStorage = async () => {
const encryptedData = await MOCK_ENCRYPTED_STORAGE_DATA();
return await upsertUserStorage(encryptedData, {
return await upsertUserStorage(MOCK_STORAGE_DATA, {
bearerToken: 'MOCK_BEARER_TOKEN',
path: 'notifications.notification_settings',
storageKey: MOCK_STORAGE_KEY,
});
};

it('invokes upsert endpoint with no errors', async () => {
const mockUpsertUserStorage = mockEndpointUpsertUserStorage();
const mockUpsertUserStorage = mockEndpointUpsertUserStorage(
'notifications.notification_settings',
undefined,
async (requestBody) => {
if (typeof requestBody === 'string') {
return;
}

const decryptedBody = await encryption.decryptString(
requestBody.data,
MOCK_STORAGE_KEY,
);

expect(decryptedBody).toBe(MOCK_STORAGE_DATA);
},
);

await actCallUpsertUserStorage();

expect(mockUpsertUserStorage.isDone()).toBe(true);
Expand All @@ -159,3 +178,66 @@ describe('user-storage/services.ts - upsertUserStorage() tests', () => {
mockUpsertUserStorage.done();
});
});

describe('user-storage/services.ts - batchUpsertUserStorage() tests', () => {
const dataToStore: [UserStoragePathWithKeyOnly, string][] = [
['0x123', MOCK_STORAGE_DATA],
['0x456', MOCK_STORAGE_DATA],
];

const actCallBatchUpsertUserStorage = async () => {
return await batchUpsertUserStorage(dataToStore, {
bearerToken: 'MOCK_BEARER_TOKEN',
path: 'accounts',
storageKey: MOCK_STORAGE_KEY,
});
};

it('invokes upsert endpoint with no errors', async () => {
const mockUpsertUserStorage = mockEndpointBatchUpsertUserStorage(
'accounts',
undefined,
async (_uri, requestBody) => {
if (typeof requestBody === 'string') {
return;
}

const decryptedBody = await Promise.all(
Object.entries<string>(requestBody.data).map(
async ([entryKey, entryValue]) => {
return [
entryKey,
await encryption.decryptString(entryValue, MOCK_STORAGE_KEY),
];
},
),
);

const expectedBody = dataToStore.map(([entryKey, entryValue]) => [
createSHA256Hash(String(entryKey) + MOCK_STORAGE_KEY),
entryValue,
]);

expect(decryptedBody).toStrictEqual(expectedBody);
},
);

await actCallBatchUpsertUserStorage();

expect(mockUpsertUserStorage.isDone()).toBe(true);
});

it('throws error if unable to upsert user storage', async () => {
const mockUpsertUserStorage = mockEndpointBatchUpsertUserStorage(
'accounts',
{
status: 500,
},
);

await expect(actCallBatchUpsertUserStorage()).rejects.toThrow(
expect.any(Error),
);
mockUpsertUserStorage.done();
});
});
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import log from 'loglevel';

import encryption from '../../shared/encryption';
import encryption, { createSHA256Hash } from '../../shared/encryption';
import { Env, getEnvUrls } from '../../shared/env';
import type {
UserStoragePathWithFeatureAndKey,
UserStoragePathWithFeatureOnly,
UserStoragePathWithKeyOnly,
} from '../../shared/storage-schema';
import { createEntryPath } from '../../shared/storage-schema';
import type { NativeScrypt } from '../../shared/types/encryption';
Expand Down Expand Up @@ -45,6 +46,8 @@ export type UserStorageAllFeatureEntriesOptions = UserStorageBaseOptions & {
path: UserStoragePathWithFeatureOnly;
};

export type UserStorageBatchUpsertOptions = UserStorageAllFeatureEntriesOptions;

/**
* User Storage Service - Get Storage Entry.
*
Expand Down Expand Up @@ -187,3 +190,47 @@ export async function upsertUserStorage(
throw new Error('user-storage - unable to upsert data');
}
}

/**
* User Storage Service - Set multiple storage entries for one specific feature.
* You cannot use this method to set multiple features at once.
*
* @param data - data to store, in the form of an array of [entryKey, entryValue] pairs
* @param opts - storage options
*/
export async function batchUpsertUserStorage(
data: [UserStoragePathWithKeyOnly, string][],
opts: UserStorageBatchUpsertOptions,
): Promise<void> {
const { bearerToken, path, storageKey, nativeScryptCrypto } = opts;

const encryptedData = await Promise.all(
data.map(async (d) => {
return [
createSHA256Hash(d[0] + storageKey),
await encryption.encryptString(
d[1],
opts.storageKey,
nativeScryptCrypto,
),
];
}),
);

const url = new URL(`${USER_STORAGE_ENDPOINT}/${path}`);

const formattedData = Object.fromEntries(encryptedData);

const res = await fetch(url.toString(), {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${bearerToken}`,
},
body: JSON.stringify({ data: formattedData }),
});

if (!res.ok) {
throw new Error('user-storage - unable to batch upsert data');
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -62,12 +62,17 @@ export const handleMockUserStorageGetAllFeatureEntries = async (
return mockEndpoint;
};

export const handleMockUserStoragePut = (mockReply?: MockReply) => {
export const handleMockUserStoragePut = (
mockReply?: MockReply,
callback?: (uri: string, requestBody: nock.Body) => Promise<void>,
) => {
const reply = mockReply ?? { status: 204 };
const mockEndpoint = nock(MOCK_STORAGE_URL)
.persist()
.put(/.*/u)
.reply(reply.status);
.reply(reply.status, async (uri, requestBody) => {
return await callback?.(uri, requestBody);
});

return mockEndpoint;
};
Loading

0 comments on commit ea44c16

Please sign in to comment.