From 9c8beb072a42e3a04f335b1bb9db2cc03a5aefc6 Mon Sep 17 00:00:00 2001 From: Mathieu Artu Date: Fri, 4 Oct 2024 11:44:27 +0200 Subject: [PATCH] feat(NOTIFY-1192): add DELETE endpoint support (#4758) ## Explanation This PR adds profile-sync SDK and `UserStorageController` support for the DELETE endpoint. This exposes new methods that permit deleting all user entries for a specific feature. ## References https://consensyssoftware.atlassian.net/browse/NOTIFY-1192 ## Changelog ### `@metamask/profile-sync-controller` - **ADDED**: `UserStorageController` and profile-sync SDK support for the `DELETE` all feature entries endpoint ## 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 --- .../UserStorageController.test.ts | 187 ++++++++++++++++++ .../user-storage/UserStorageController.ts | 23 +++ .../__fixtures__/mockResponses.ts | 12 +- .../user-storage/__fixtures__/mockServices.ts | 15 ++ .../controllers/user-storage/services.test.ts | 57 ++++++ .../src/controllers/user-storage/services.ts | 29 +++ .../src/sdk/__fixtures__/mock-userstorage.ts | 12 ++ .../src/sdk/user-storage.test.ts | 28 +++ .../src/sdk/user-storage.ts | 47 +++++ 9 files changed, 409 insertions(+), 1 deletion(-) diff --git a/packages/profile-sync-controller/src/controllers/user-storage/UserStorageController.test.ts b/packages/profile-sync-controller/src/controllers/user-storage/UserStorageController.test.ts index 97357da011..0a265267ab 100644 --- a/packages/profile-sync-controller/src/controllers/user-storage/UserStorageController.test.ts +++ b/packages/profile-sync-controller/src/controllers/user-storage/UserStorageController.test.ts @@ -18,6 +18,7 @@ import { mockEndpointGetUserStorage, mockEndpointGetUserStorageAllFeatureEntries, mockEndpointUpsertUserStorage, + mockEndpointDeleteUserStorageAllFeatureEntries, } from './__fixtures__/mockServices'; import { MOCK_STORAGE_DATA, @@ -308,6 +309,192 @@ describe('user-storage/user-storage-controller - performSetStorage() tests', () }); }); +describe('user-storage/user-storage-controller - performBatchSetStorage() tests', () => { + const arrangeMocks = (mockResponseStatus?: number) => { + return { + messengerMocks: mockUserStorageMessenger(), + mockAPI: mockEndpointBatchUpsertUserStorage( + 'notifications', + mockResponseStatus ? { status: mockResponseStatus } : undefined, + ), + }; + }; + + it('batch saves to user storage', async () => { + const { messengerMocks, mockAPI } = arrangeMocks(); + const controller = new UserStorageController({ + messenger: messengerMocks.messenger, + getMetaMetricsState: () => true, + }); + + await controller.performBatchSetStorage('notifications', [ + ['notifications.notification_settings', 'new data'], + ]); + expect(mockAPI.isDone()).toBe(true); + }); + + it('rejects if UserStorage is not enabled', async () => { + const { messengerMocks } = arrangeMocks(); + const controller = new UserStorageController({ + messenger: messengerMocks.messenger, + getMetaMetricsState: () => true, + state: { + isProfileSyncingEnabled: false, + isProfileSyncingUpdateLoading: false, + }, + }); + + await expect( + controller.performBatchSetStorage('notifications', [ + ['notifications.notification_settings', 'new data'], + ]), + ).rejects.toThrow(expect.any(Error)); + }); + + it.each([ + [ + 'fails when no bearer token is found (auth errors)', + (messengerMocks: ReturnType) => + messengerMocks.mockAuthGetBearerToken.mockRejectedValue( + new Error('MOCK FAILURE'), + ), + ], + [ + 'fails when no session identifier is found (auth errors)', + (messengerMocks: ReturnType) => + messengerMocks.mockAuthGetSessionProfile.mockRejectedValue( + new Error('MOCK FAILURE'), + ), + ], + ])( + 'rejects on auth failure - %s', + async ( + _: string, + arrangeFailureCase: ( + messengerMocks: ReturnType, + ) => void, + ) => { + const { messengerMocks } = arrangeMocks(); + arrangeFailureCase(messengerMocks); + const controller = new UserStorageController({ + messenger: messengerMocks.messenger, + getMetaMetricsState: () => true, + }); + + await expect( + controller.performBatchSetStorage('notifications', [ + ['notifications.notification_settings', 'new data'], + ]), + ).rejects.toThrow(expect.any(Error)); + }, + ); + + it('rejects if api call fails', async () => { + const { messengerMocks, mockAPI } = arrangeMocks(500); + const controller = new UserStorageController({ + messenger: messengerMocks.messenger, + getMetaMetricsState: () => true, + }); + + await expect( + controller.performBatchSetStorage('notifications', [ + ['notifications.notification_settings', 'new data'], + ]), + ).rejects.toThrow(expect.any(Error)); + mockAPI.done(); + }); +}); + +describe('user-storage/user-storage-controller - performDeleteStorageAllFeatureEntries() tests', () => { + const arrangeMocks = async (mockResponseStatus?: number) => { + return { + messengerMocks: mockUserStorageMessenger(), + mockAPI: mockEndpointDeleteUserStorageAllFeatureEntries( + 'notifications', + mockResponseStatus ? { status: mockResponseStatus } : undefined, + ), + }; + }; + + it('deletes all user storage entries for a feature', async () => { + const { messengerMocks, mockAPI } = await arrangeMocks(); + const controller = new UserStorageController({ + messenger: messengerMocks.messenger, + getMetaMetricsState: () => true, + }); + + await controller.performDeleteStorageAllFeatureEntries('notifications'); + mockAPI.done(); + + expect(mockAPI.isDone()).toBe(true); + }); + + it('rejects if UserStorage is not enabled', async () => { + const { messengerMocks } = await arrangeMocks(); + const controller = new UserStorageController({ + messenger: messengerMocks.messenger, + getMetaMetricsState: () => true, + state: { + isProfileSyncingEnabled: false, + isProfileSyncingUpdateLoading: false, + }, + }); + + await expect( + controller.performDeleteStorageAllFeatureEntries('notifications'), + ).rejects.toThrow(expect.any(Error)); + }); + + it.each([ + [ + 'fails when no bearer token is found (auth errors)', + (messengerMocks: ReturnType) => + messengerMocks.mockAuthGetBearerToken.mockRejectedValue( + new Error('MOCK FAILURE'), + ), + ], + [ + 'fails when no session identifier is found (auth errors)', + (messengerMocks: ReturnType) => + messengerMocks.mockAuthGetSessionProfile.mockRejectedValue( + new Error('MOCK FAILURE'), + ), + ], + ])( + 'rejects on auth failure - %s', + async ( + _: string, + arrangeFailureCase: ( + messengerMocks: ReturnType, + ) => void, + ) => { + const { messengerMocks } = await arrangeMocks(); + arrangeFailureCase(messengerMocks); + const controller = new UserStorageController({ + messenger: messengerMocks.messenger, + getMetaMetricsState: () => true, + }); + + await expect( + controller.performDeleteStorageAllFeatureEntries('notifications'), + ).rejects.toThrow(expect.any(Error)); + }, + ); + + it('rejects if api call fails', async () => { + const { messengerMocks, mockAPI } = await arrangeMocks(500); + const controller = new UserStorageController({ + messenger: messengerMocks.messenger, + getMetaMetricsState: () => true, + }); + + await expect( + controller.performDeleteStorageAllFeatureEntries('notifications'), + ).rejects.toThrow(expect.any(Error)); + mockAPI.done(); + }); +}); + describe('user-storage/user-storage-controller - getStorageKey() tests', () => { const arrangeMocks = async () => { return { diff --git a/packages/profile-sync-controller/src/controllers/user-storage/UserStorageController.ts b/packages/profile-sync-controller/src/controllers/user-storage/UserStorageController.ts index 67d85ec281..5a4deb1a5a 100644 --- a/packages/profile-sync-controller/src/controllers/user-storage/UserStorageController.ts +++ b/packages/profile-sync-controller/src/controllers/user-storage/UserStorageController.ts @@ -45,6 +45,7 @@ import { import { startNetworkSyncing } from './network-syncing/controller-integration'; import { batchUpsertUserStorage, + deleteUserStorageAllFeatureEntries, getUserStorage, getUserStorageAllFeatureEntries, upsertUserStorage, @@ -677,6 +678,28 @@ export default class UserStorageController extends BaseController< }); } + /** + * Allows deletion of all user data entries for a specific feature. + * Developers can extend the entry path through the `schema.ts` file. + * + * @param path - string in the form of `${feature}` that matches schema + * @returns nothing. NOTE that an error is thrown if fails to delete data. + */ + public async performDeleteStorageAllFeatureEntries( + path: UserStoragePathWithFeatureOnly, + ): Promise { + this.#assertProfileSyncingEnabled(); + + const { bearerToken, storageKey } = + await this.#getStorageKeyAndBearerToken(); + + await deleteUserStorageAllFeatureEntries({ + path, + bearerToken, + storageKey, + }); + } + /** * Retrieves the storage key, for internal use only! * diff --git a/packages/profile-sync-controller/src/controllers/user-storage/__fixtures__/mockResponses.ts b/packages/profile-sync-controller/src/controllers/user-storage/__fixtures__/mockResponses.ts index 48957cff7c..e2de8f7132 100644 --- a/packages/profile-sync-controller/src/controllers/user-storage/__fixtures__/mockResponses.ts +++ b/packages/profile-sync-controller/src/controllers/user-storage/__fixtures__/mockResponses.ts @@ -16,7 +16,7 @@ import { type MockResponse = { url: string; - requestMethod: 'GET' | 'POST' | 'PUT'; + requestMethod: 'GET' | 'POST' | 'PUT' | 'DELETE'; response: unknown; }; @@ -117,3 +117,13 @@ export const getMockUserStorageBatchPutResponse = ( response: null, } satisfies MockResponse; }; + +export const deleteMockUserStorageAllFeatureEntriesResponse = ( + path: UserStoragePathWithFeatureOnly = 'notifications', +) => { + return { + url: getMockUserStorageEndpoint(path), + requestMethod: 'DELETE', + response: null, + } satisfies MockResponse; +}; diff --git a/packages/profile-sync-controller/src/controllers/user-storage/__fixtures__/mockServices.ts b/packages/profile-sync-controller/src/controllers/user-storage/__fixtures__/mockServices.ts index b798ade0b8..1613868760 100644 --- a/packages/profile-sync-controller/src/controllers/user-storage/__fixtures__/mockServices.ts +++ b/packages/profile-sync-controller/src/controllers/user-storage/__fixtures__/mockServices.ts @@ -9,6 +9,7 @@ import { getMockUserStoragePutResponse, getMockUserStorageAllFeatureEntriesResponse, getMockUserStorageBatchPutResponse, + deleteMockUserStorageAllFeatureEntriesResponse, } from './mockResponses'; type MockReply = { @@ -77,3 +78,17 @@ export const mockEndpointBatchUpsertUserStorage = ( }); return mockEndpoint; }; + +export const mockEndpointDeleteUserStorageAllFeatureEntries = ( + path: UserStoragePathWithFeatureOnly = 'notifications', + mockReply?: MockReply, +) => { + const mockResponse = deleteMockUserStorageAllFeatureEntriesResponse(path); + const reply = mockReply ?? { + status: 200, + }; + + const mockEndpoint = nock(mockResponse.url).delete('').reply(reply.status); + + return mockEndpoint; +}; diff --git a/packages/profile-sync-controller/src/controllers/user-storage/services.test.ts b/packages/profile-sync-controller/src/controllers/user-storage/services.test.ts index 7cc8e87e91..c545fcf825 100644 --- a/packages/profile-sync-controller/src/controllers/user-storage/services.test.ts +++ b/packages/profile-sync-controller/src/controllers/user-storage/services.test.ts @@ -6,6 +6,7 @@ import { mockEndpointUpsertUserStorage, mockEndpointGetUserStorageAllFeatureEntries, mockEndpointBatchUpsertUserStorage, + mockEndpointDeleteUserStorageAllFeatureEntries, } from './__fixtures__/mockServices'; import { MOCK_STORAGE_DATA, @@ -17,6 +18,7 @@ import { getUserStorage, getUserStorageAllFeatureEntries, upsertUserStorage, + deleteUserStorageAllFeatureEntries, } from './services'; describe('user-storage/services.ts - getUserStorage() tests', () => { @@ -241,3 +243,58 @@ describe('user-storage/services.ts - batchUpsertUserStorage() tests', () => { mockUpsertUserStorage.done(); }); }); + +describe('user-storage/services.ts - deleteUserStorageAllFeatureEntries() tests', () => { + const actCallDeleteUserStorageAllFeatureEntries = async () => { + return await deleteUserStorageAllFeatureEntries({ + bearerToken: 'MOCK_BEARER_TOKEN', + path: 'accounts', + storageKey: MOCK_STORAGE_KEY, + }); + }; + + it('invokes delete endpoint with no errors', async () => { + const mockDeleteUserStorage = + mockEndpointDeleteUserStorageAllFeatureEntries('accounts', undefined); + + await actCallDeleteUserStorageAllFeatureEntries(); + + expect(mockDeleteUserStorage.isDone()).toBe(true); + }); + + it('throws error if unable to delete user storage', async () => { + const mockDeleteUserStorage = + mockEndpointDeleteUserStorageAllFeatureEntries('accounts', { + status: 500, + }); + + await expect(actCallDeleteUserStorageAllFeatureEntries()).rejects.toThrow( + expect.any(Error), + ); + mockDeleteUserStorage.done(); + }); + + it('throws error if feature not found', async () => { + const mockDeleteUserStorage = + mockEndpointDeleteUserStorageAllFeatureEntries('accounts', { + status: 404, + }); + + await expect(actCallDeleteUserStorageAllFeatureEntries()).rejects.toThrow( + 'user-storage - feature not found', + ); + mockDeleteUserStorage.done(); + }); + + it('throws error if unable to get user storage', async () => { + const mockDeleteUserStorage = + mockEndpointDeleteUserStorageAllFeatureEntries('accounts', { + status: 400, + }); + + await expect(actCallDeleteUserStorageAllFeatureEntries()).rejects.toThrow( + 'user-storage - unable to delete data', + ); + mockDeleteUserStorage.done(); + }); +}); diff --git a/packages/profile-sync-controller/src/controllers/user-storage/services.ts b/packages/profile-sync-controller/src/controllers/user-storage/services.ts index 123a585db1..9794824e5e 100644 --- a/packages/profile-sync-controller/src/controllers/user-storage/services.ts +++ b/packages/profile-sync-controller/src/controllers/user-storage/services.ts @@ -239,3 +239,32 @@ export async function batchUpsertUserStorage( throw new Error('user-storage - unable to batch upsert data'); } } + +/** + * User Storage Service - Delete all storage entries for a specific feature. + * + * @param opts - User Storage Options + */ +export async function deleteUserStorageAllFeatureEntries( + opts: UserStorageAllFeatureEntriesOptions, +): Promise { + const { bearerToken, path } = opts; + const url = new URL(`${USER_STORAGE_ENDPOINT}/${path}`); + + const userStorageResponse = await fetch(url.toString(), { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${bearerToken}`, + }, + }); + + // Acceptable error - since indicates feature does not exist. + if (userStorageResponse.status === 404) { + throw new Error('user-storage - feature not found'); + } + + if (userStorageResponse.status !== 200 || !userStorageResponse.ok) { + throw new Error('user-storage - unable to delete data'); + } +} diff --git a/packages/profile-sync-controller/src/sdk/__fixtures__/mock-userstorage.ts b/packages/profile-sync-controller/src/sdk/__fixtures__/mock-userstorage.ts index 80b11706ed..30da94b89c 100644 --- a/packages/profile-sync-controller/src/sdk/__fixtures__/mock-userstorage.ts +++ b/packages/profile-sync-controller/src/sdk/__fixtures__/mock-userstorage.ts @@ -76,3 +76,15 @@ export const handleMockUserStoragePut = ( return mockEndpoint; }; + +export const handleMockUserStorageDeleteAllFeatureEntries = async ( + mockReply?: MockReply, +) => { + const reply = mockReply ?? { status: 204 }; + const mockEndpoint = nock(MOCK_STORAGE_URL_ALL_FEATURE_ENTRIES) + .persist() + .delete('') + .reply(reply.status); + + return mockEndpoint; +}; diff --git a/packages/profile-sync-controller/src/sdk/user-storage.test.ts b/packages/profile-sync-controller/src/sdk/user-storage.test.ts index 39777a226e..b308fe868e 100644 --- a/packages/profile-sync-controller/src/sdk/user-storage.test.ts +++ b/packages/profile-sync-controller/src/sdk/user-storage.test.ts @@ -9,6 +9,7 @@ import { handleMockUserStorageGet, handleMockUserStoragePut, handleMockUserStorageGetAllFeatureEntries, + handleMockUserStorageDeleteAllFeatureEntries, } from './__fixtures__/mock-userstorage'; import { arrangeAuth, typedMockFn } from './__fixtures__/test-utils'; import type { IBaseAuth } from './authentication-jwt-bearer/types'; @@ -132,6 +133,33 @@ describe('User Storage', () => { expect(mockPut.isDone()).toBe(true); }); + it('user storage: delete all feature entries', async () => { + const { auth } = arrangeAuth('SRP', MOCK_SRP); + const { userStorage } = arrangeUserStorage(auth); + + const mockDelete = await handleMockUserStorageDeleteAllFeatureEntries(); + + await userStorage.deleteAllFeatureItems('notifications'); + expect(mockDelete.isDone()).toBe(true); + }); + + it('user storage: failed to delete all feature entries', async () => { + const { auth } = arrangeAuth('SRP', MOCK_SRP); + const { userStorage } = arrangeUserStorage(auth); + + await handleMockUserStorageDeleteAllFeatureEntries({ + status: 401, + body: { + message: 'failed to delete all feature entries', + error: 'generic-error', + }, + }); + + await expect( + userStorage.deleteAllFeatureItems('notifications'), + ).rejects.toThrow(UserStorageError); + }); + it('user storage: failed to set key', async () => { const { auth } = arrangeAuth('SRP', MOCK_SRP); const { userStorage } = arrangeUserStorage(auth); diff --git a/packages/profile-sync-controller/src/sdk/user-storage.ts b/packages/profile-sync-controller/src/sdk/user-storage.ts index 838842be1e..83c4f50eff 100644 --- a/packages/profile-sync-controller/src/sdk/user-storage.ts +++ b/packages/profile-sync-controller/src/sdk/user-storage.ts @@ -76,6 +76,12 @@ export class UserStorage { return this.#getUserStorageAllFeatureEntries(path); } + async deleteAllFeatureItems( + path: UserStoragePathWithFeatureOnly, + ): Promise { + return this.#deleteUserStorageAllFeatureEntries(path); + } + async getStorageKey(): Promise { const storageKey = await this.options.storage?.getStorageKey(); if (storageKey) { @@ -289,6 +295,47 @@ export class UserStorage { } } + async #deleteUserStorageAllFeatureEntries( + path: UserStoragePathWithFeatureOnly, + ): Promise { + try { + const headers = await this.#getAuthorizationHeader(); + + const url = new URL(STORAGE_URL(this.env, path)); + + const response = await fetch(url.toString(), { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + ...headers, + }, + }); + + if (response.status === 404) { + throw new NotFoundError(`feature not found for path '${path}'.`); + } + + if (!response.ok) { + const responseBody = (await response.json()) as ErrorMessage; + throw new Error( + `HTTP error message: ${responseBody.message}, error: ${responseBody.error}`, + ); + } + } catch (e) { + if (e instanceof NotFoundError) { + throw e; + } + + /* istanbul ignore next */ + const errorMessage = + e instanceof Error ? e.message : JSON.stringify(e ?? ''); + + throw new UserStorageError( + `failed to delete user storage for path '${path}'. ${errorMessage}`, + ); + } + } + #createEntryKey(key: string, storageKey: string): string { const hashedKey = createSHA256Hash(key + storageKey); return hashedKey;