diff --git a/.metamaskrc.dist b/.metamaskrc.dist index 745c51528c3e..601105e2af44 100644 --- a/.metamaskrc.dist +++ b/.metamaskrc.dist @@ -7,6 +7,8 @@ INFURA_PROJECT_ID=00000000000 ;PASSWORD=METAMASK PASSWORD ;SEGMENT_WRITE_KEY= ;BRIDGE_USE_DEV_APIS= +;ANALYTICS_DATA_DELETION_SOURCE_ID= +;ANALYTICS_DATA_DELETION_ENDPOINT= ;SWAPS_USE_DEV_APIS= ;PORTFOLIO_URL= ;TRANSACTION_SECURITY_PROVIDER= diff --git a/app/scripts/constants/sentry-state.ts b/app/scripts/constants/sentry-state.ts index db78f948c31d..10539fc7a5e4 100644 --- a/app/scripts/constants/sentry-state.ts +++ b/app/scripts/constants/sentry-state.ts @@ -159,6 +159,11 @@ export const SENTRY_BACKGROUND_STATE = { traits: false, dataCollectionForMarketing: false, marketingCampaignCookieId: true, + latestNonAnonymousEventTimestamp: true, + }, + MetaMetricsDataDeletionController: { + metaMetricsDataDeletionId: true, + metaMetricsDataDeletionTimestamp: true, }, NameController: { names: false, diff --git a/app/scripts/controllers/metametrics-data-deletion/metametrics-data-deletion.test.ts b/app/scripts/controllers/metametrics-data-deletion/metametrics-data-deletion.test.ts new file mode 100644 index 000000000000..be22cbdf77c0 --- /dev/null +++ b/app/scripts/controllers/metametrics-data-deletion/metametrics-data-deletion.test.ts @@ -0,0 +1,181 @@ +import { ControllerMessenger } from '@metamask/base-controller'; +import { + MetaMetricsDataDeletionController, + type MetaMetricsDataDeletionControllerMessengerActions, +} from './metametrics-data-deletion'; + +describe('MetaMetricsDataDeletionController', () => { + describe('createMetaMetricsDataDeletionTask', () => { + it('creates a data deletion task and stores ID when user is participating in metrics tracking', async () => { + const mockMetaMetricsId = 'mockId'; + const mockTaskId = 'mockTaskId'; + const { controller, dataDeletionService } = setupController({ + options: { + getMetaMetricsId: jest.fn().mockReturnValue(mockMetaMetricsId), + dataDeletionService: { + createDataDeletionRegulationTask: jest + .fn() + .mockResolvedValue(mockTaskId), + fetchDeletionRegulationStatus: jest + .fn() + .mockResolvedValue('UNKNOWN'), + }, + }, + }); + + await controller.createMetaMetricsDataDeletionTask(); + expect( + dataDeletionService.createDataDeletionRegulationTask, + ).toHaveBeenCalledWith(mockMetaMetricsId); + expect( + dataDeletionService.createDataDeletionRegulationTask, + ).toHaveBeenCalledTimes(1); + expect( + dataDeletionService.fetchDeletionRegulationStatus, + ).toHaveBeenCalledTimes(1); + expect(controller.state).toStrictEqual({ + metaMetricsDataDeletionId: mockTaskId, + metaMetricsDataDeletionTimestamp: expect.any(Number), + metaMetricsDataDeletionStatus: 'UNKNOWN', + }); + }); + it('creates a data deletion task and stores ID when user is not currently participating in metrics tracking', async () => { + const mockMetaMetricsId = 'mockId'; + const mockTaskId = 'mockTaskId'; + const { controller, dataDeletionService } = setupController({ + options: { + getMetaMetricsId: jest.fn().mockReturnValue(mockMetaMetricsId), + dataDeletionService: { + createDataDeletionRegulationTask: jest + .fn() + .mockResolvedValue(mockTaskId), + fetchDeletionRegulationStatus: jest + .fn() + .mockResolvedValue('UNKNOWN'), + }, + }, + }); + + await controller.createMetaMetricsDataDeletionTask(); + + expect( + dataDeletionService.createDataDeletionRegulationTask, + ).toHaveBeenCalledTimes(1); + expect( + dataDeletionService.fetchDeletionRegulationStatus, + ).toHaveBeenCalledTimes(1); + expect( + dataDeletionService.createDataDeletionRegulationTask, + ).toHaveBeenCalledWith(mockMetaMetricsId); + expect(controller.state).toStrictEqual({ + metaMetricsDataDeletionId: mockTaskId, + metaMetricsDataDeletionTimestamp: expect.any(Number), + metaMetricsDataDeletionStatus: 'UNKNOWN', + }); + }); + + it('fails to creates a data deletion task when user has never participating in metrics tracking', async () => { + const { controller } = setupController({ + options: { + getMetaMetricsId: jest.fn().mockReturnValue(null), + }, + }); + await expect( + controller.createMetaMetricsDataDeletionTask(), + ).rejects.toThrow(); + expect(controller.state).toStrictEqual({ + metaMetricsDataDeletionId: null, + metaMetricsDataDeletionTimestamp: expect.any(Number), + }); + }); + }); + describe('updateDataDeletionTaskStatus', () => { + it('fetches and stores status of the delete regulation using delete regulation ID', async () => { + const mockMetaMetricsId = 'mockId'; + const mockTaskId = 'mockTaskId'; + const { controller, dataDeletionService } = setupController({ + options: { + getMetaMetricsId: jest.fn().mockReturnValue(mockMetaMetricsId), + dataDeletionService: { + createDataDeletionRegulationTask: jest + .fn() + .mockResolvedValue(mockTaskId), + fetchDeletionRegulationStatus: jest + .fn() + .mockResolvedValue('UNKNOWN'), + }, + }, + }); + await controller.createMetaMetricsDataDeletionTask(); + await controller.updateDataDeletionTaskStatus(); + expect( + dataDeletionService.fetchDeletionRegulationStatus, + ).toHaveBeenCalledTimes(2); + expect( + dataDeletionService.fetchDeletionRegulationStatus, + ).toHaveBeenCalledWith(mockTaskId); + expect(controller.state).toStrictEqual({ + metaMetricsDataDeletionId: mockTaskId, + metaMetricsDataDeletionTimestamp: expect.any(Number), + metaMetricsDataDeletionStatus: 'UNKNOWN', + }); + }); + }); +}); + +/** + * Setup a test controller instance. + * + * @param options - Setup options. + * @param options.options - Controller constructor options. + * @returns The test controller, a messenger instance, and related mocks. + */ +function setupController({ + options, +}: { + options?: Partial< + ConstructorParameters[0] + >; +} = {}): { + controller: MetaMetricsDataDeletionController; + dataDeletionService: ConstructorParameters< + typeof MetaMetricsDataDeletionController + >[0]['dataDeletionService']; + messenger: ControllerMessenger< + MetaMetricsDataDeletionControllerMessengerActions, + never + >; +} { + const messenger = new ControllerMessenger< + MetaMetricsDataDeletionControllerMessengerActions, + never + >(); + const mockCreateDataDeletionRegulationTaskResponse = 'mockRegulateId'; + const mockFetchDeletionRegulationStatusResponse = 'UNKNOWN'; + const mockDataDeletionService = { + createDataDeletionRegulationTask: jest + .fn() + .mockResolvedValue(mockCreateDataDeletionRegulationTaskResponse), + fetchDeletionRegulationStatus: jest + .fn() + .mockResolvedValue(mockFetchDeletionRegulationStatusResponse), + ...options?.dataDeletionService, + }; + const constructorOptions = { + dataDeletionService: mockDataDeletionService, + getMetaMetricsId: jest.fn().mockReturnValue('mockMetaMetricsId'), + messenger: messenger.getRestricted({ + name: 'MetaMetricsDataDeletionController', + allowedActions: [], + allowedEvents: [], + }), + ...options, + }; + const controller = new MetaMetricsDataDeletionController(constructorOptions); + + return { + controller, + dataDeletionService: constructorOptions.dataDeletionService, + messenger, + }; +} diff --git a/app/scripts/controllers/metametrics-data-deletion/metametrics-data-deletion.ts b/app/scripts/controllers/metametrics-data-deletion/metametrics-data-deletion.ts new file mode 100644 index 000000000000..8aad81c3721d --- /dev/null +++ b/app/scripts/controllers/metametrics-data-deletion/metametrics-data-deletion.ts @@ -0,0 +1,183 @@ +import { + BaseController, + RestrictedControllerMessenger, +} from '@metamask/base-controller'; +import { PublicInterface } from '@metamask/utils'; +import type { DataDeletionService } from '../../services/data-deletion-service'; +import { DeleteRegulationStatus } from '../../../../shared/constants/metametrics'; + +// Unique name for the controller +const controllerName = 'MetaMetricsDataDeletionController'; + +/** + * Timestamp at which regulation response is returned. + */ +export type DataDeleteTimestamp = number; +/** + * Regulation Id retuned while creating a delete regulation. + */ +export type DataDeleteRegulationId = string | null; + +/** + * MetaMetricsDataDeletionController controller state + * metaMetricsDataDeletionId - Regulation Id retuned while creating a delete regulation. + * metaMetricsDataDeletionTimestamp - Timestamp at which the most recent regulation is created/requested for. + * metaMetricsDataDeletionStatus - Status of the current delete regulation. + */ +export type MetaMetricsDataDeletionState = { + metaMetricsDataDeletionId: DataDeleteRegulationId; + metaMetricsDataDeletionTimestamp: DataDeleteTimestamp; + metaMetricsDataDeletionStatus?: DeleteRegulationStatus; +}; + +const getDefaultState = (): MetaMetricsDataDeletionState => { + return { + metaMetricsDataDeletionId: null, + metaMetricsDataDeletionTimestamp: 0, + }; +}; + +// Metadata for the controller state +const metadata = { + metaMetricsDataDeletionId: { + persist: true, + anonymous: true, + }, + metaMetricsDataDeletionTimestamp: { + persist: true, + anonymous: true, + }, + metaMetricsDataDeletionStatus: { + persist: true, + anonymous: true, + }, +}; + +// Describes the action creating the delete regulation task +export type CreateMetaMetricsDataDeletionTaskAction = { + type: `${typeof controllerName}:createMetaMetricsDataDeletionTask`; + handler: MetaMetricsDataDeletionController['createMetaMetricsDataDeletionTask']; +}; + +// Describes the action to check the existing regulation status +export type UpdateDataDeletionTaskStatusAction = { + type: `${typeof controllerName}:updateDataDeletionTaskStatus`; + handler: MetaMetricsDataDeletionController['updateDataDeletionTaskStatus']; +}; + +// Union of all possible actions for the messenger +export type MetaMetricsDataDeletionControllerMessengerActions = + | CreateMetaMetricsDataDeletionTaskAction + | UpdateDataDeletionTaskStatusAction; + +// Type for the messenger of MetaMetricsDataDeletionController +export type MetaMetricsDataDeletionControllerMessenger = + RestrictedControllerMessenger< + typeof controllerName, + MetaMetricsDataDeletionControllerMessengerActions, + never, + never, + never + >; + +/** + * Controller responsible for maintaining + * state related to Metametrics data deletion + */ +export class MetaMetricsDataDeletionController extends BaseController< + typeof controllerName, + MetaMetricsDataDeletionState, + MetaMetricsDataDeletionControllerMessenger +> { + #dataDeletionService: PublicInterface; + + #getMetaMetricsId: () => string | null; + + /** + * Creates a MetaMetricsDataDeletionController instance. + * + * @param args - The arguments to this function. + * @param args.dataDeletionService - The service used for deleting data. + * @param args.messenger - Messenger used to communicate with BaseV2 controller. + * @param args.state - Initial state to set on this controller. + * @param args.getMetaMetricsId - A function that returns the current MetaMetrics ID. + */ + constructor({ + dataDeletionService, + messenger, + state, + getMetaMetricsId, + }: { + dataDeletionService: PublicInterface; + messenger: MetaMetricsDataDeletionControllerMessenger; + state?: Partial; + getMetaMetricsId: () => string | null; + }) { + // Call the constructor of BaseControllerV2 + super({ + messenger, + metadata, + name: controllerName, + state: { ...getDefaultState(), ...state }, + }); + this.#getMetaMetricsId = getMetaMetricsId; + this.#dataDeletionService = dataDeletionService; + this.#registerMessageHandlers(); + } + + /** + * Constructor helper for registering this controller's messaging system + * actions. + */ + #registerMessageHandlers(): void { + this.messagingSystem.registerActionHandler( + `${controllerName}:createMetaMetricsDataDeletionTask`, + this.createMetaMetricsDataDeletionTask.bind(this), + ); + + this.messagingSystem.registerActionHandler( + `${controllerName}:updateDataDeletionTaskStatus`, + this.updateDataDeletionTaskStatus.bind(this), + ); + } + + /** + * Creating the delete regulation using source regulation + * + */ + async createMetaMetricsDataDeletionTask(): Promise { + const metaMetricsId = this.#getMetaMetricsId(); + if (!metaMetricsId) { + throw new Error('MetaMetrics ID not found'); + } + + const deleteRegulateId = + await this.#dataDeletionService.createDataDeletionRegulationTask( + metaMetricsId, + ); + this.update((state) => { + state.metaMetricsDataDeletionId = deleteRegulateId ?? null; + state.metaMetricsDataDeletionTimestamp = Date.now(); + }); + await this.updateDataDeletionTaskStatus(); + } + + /** + * To check the status of the current delete regulation. + */ + async updateDataDeletionTaskStatus(): Promise { + const deleteRegulationId = this.state.metaMetricsDataDeletionId; + if (!deleteRegulationId) { + throw new Error('Delete Regulation id not found'); + } + + const deletionStatus = + await this.#dataDeletionService.fetchDeletionRegulationStatus( + deleteRegulationId, + ); + + this.update((state) => { + state.metaMetricsDataDeletionStatus = deletionStatus ?? undefined; + }); + } +} diff --git a/app/scripts/controllers/metametrics.js b/app/scripts/controllers/metametrics.js index 5fa34ac0cbd1..d815607d27e8 100644 --- a/app/scripts/controllers/metametrics.js +++ b/app/scripts/controllers/metametrics.js @@ -92,6 +92,7 @@ const exceptionsToFilter = { * @property {boolean} [participateInMetaMetrics] - The user's preference for * participating in the MetaMetrics analytics program. This setting controls * whether or not events are tracked + * @property {boolean} [latestNonAnonymousEventTimestamp] - The timestamp at which last non anonymous event is tracked. * @property {{[string]: MetaMetricsEventFragment}} [fragments] - Object keyed * by UUID with stored fragments as values. * @property {Array} [eventsBeforeMetricsOptIn] - Array of queued events added before @@ -156,6 +157,7 @@ export default class MetaMetricsController { metaMetricsId: null, dataCollectionForMarketing: null, marketingCampaignCookieId: null, + latestNonAnonymousEventTimestamp: 0, eventsBeforeMetricsOptIn: [], traits: {}, previousUserTraits: {}, @@ -1089,7 +1091,11 @@ export default class MetaMetricsController { // Saving segmentApiCalls in controller store in MV3 ensures that events are tracked // even if service worker terminates before events are submiteed to segment. _submitSegmentAPICall(eventType, payload, callback) { - const { metaMetricsId, participateInMetaMetrics } = this.state; + const { + metaMetricsId, + participateInMetaMetrics, + latestNonAnonymousEventTimestamp, + } = this.state; if (!participateInMetaMetrics || !metaMetricsId) { return; } @@ -1104,6 +1110,11 @@ export default class MetaMetricsController { } const modifiedPayload = { ...payload, messageId, timestamp }; this.store.updateState({ + ...this.store.getState(), + latestNonAnonymousEventTimestamp: + modifiedPayload.anonymousId === METAMETRICS_ANONYMOUS_ID + ? latestNonAnonymousEventTimestamp + : timestamp.valueOf(), segmentApiCalls: { ...this.store.getState().segmentApiCalls, [messageId]: { diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index 50beb316faf5..43afa13ac630 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -315,6 +315,8 @@ import { PermissionNames, unrestrictedMethods, } from './controllers/permissions'; +import { MetaMetricsDataDeletionController } from './controllers/metametrics-data-deletion/metametrics-data-deletion'; +import { DataDeletionService } from './services/data-deletion-service'; import createRPCMethodTrackingMiddleware from './lib/createRPCMethodTrackingMiddleware'; import { IndexedDBPPOMStorage } from './lib/ppom/indexed-db-backend'; import { updateCurrentLocale } from './translate'; @@ -749,6 +751,19 @@ export default class MetamaskController extends EventEmitter { this.metaMetricsController.handleMetaMaskStateUpdate(update); }); + const dataDeletionService = new DataDeletionService(); + const metaMetricsDataDeletionMessenger = + this.controllerMessenger.getRestricted({ + name: 'MetaMetricsDataDeletionController', + }); + this.metaMetricsDataDeletionController = + new MetaMetricsDataDeletionController({ + dataDeletionService, + messenger: metaMetricsDataDeletionMessenger, + state: initState.metaMetricsDataDeletionController, + getMetaMetricsId: () => this.metaMetricsController.state.metaMetricsId, + }); + const gasFeeMessenger = this.controllerMessenger.getRestricted({ name: 'GasFeeController', allowedActions: [ @@ -2266,6 +2281,7 @@ export default class MetamaskController extends EventEmitter { KeyringController: this.keyringController, PreferencesController: this.preferencesController.store, MetaMetricsController: this.metaMetricsController.store, + MetaMetricsDataDeletionController: this.metaMetricsDataDeletionController, AddressBookController: this.addressBookController, CurrencyController: this.currencyRateController, NetworkController: this.networkController, @@ -2320,6 +2336,8 @@ export default class MetamaskController extends EventEmitter { KeyringController: this.keyringController, PreferencesController: this.preferencesController.store, MetaMetricsController: this.metaMetricsController.store, + MetaMetricsDataDeletionController: + this.metaMetricsDataDeletionController, AddressBookController: this.addressBookController, CurrencyController: this.currencyRateController, AlertController: this.alertController.store, @@ -3963,7 +3981,15 @@ export default class MetamaskController extends EventEmitter { ...request, ethQuery: new EthQuery(this.provider), }), - + // metrics data deleteion + createMetaMetricsDataDeletionTask: + this.metaMetricsDataDeletionController.createMetaMetricsDataDeletionTask.bind( + this.metaMetricsDataDeletionController, + ), + updateDataDeletionTaskStatus: + this.metaMetricsDataDeletionController.updateDataDeletionTaskStatus.bind( + this.metaMetricsDataDeletionController, + ), // Trace endTrace, }; diff --git a/app/scripts/services/data-deletion-service.test.ts b/app/scripts/services/data-deletion-service.test.ts new file mode 100644 index 000000000000..f1619ddd0530 --- /dev/null +++ b/app/scripts/services/data-deletion-service.test.ts @@ -0,0 +1,1212 @@ +import nock from 'nock'; + +import { + DataDeletionService, + RETRIES, + MAX_CONSECUTIVE_FAILURES, +} from './data-deletion-service'; + +// We're not customizing the default max delay +// The default can be found here: https://github.com/connor4312/cockatiel?tab=readme-ov-file#exponentialbackoff +const defaultMaxRetryDelay = 30_000; +// Timeout is well over max retry delay, so that requests don't time out when we advance the timer +// to trigger retries. +const defaultTimeout = defaultMaxRetryDelay * 2; + +const mockMetaMetricsId = 'mockMetaMetricsId'; +const mockTaskId = 'mockTaskId'; +const mockSourceId = 'mockSourceId'; +const mockAnalyticsDataDeletionOrigin = 'https://metamask.test'; +const mockAnalyticsDataDeletionPath = '/data-deletion'; +const mockAnalyticsCreateDataDeletionPath = `${mockAnalyticsDataDeletionPath}/regulations/sources/${mockSourceId}`; +// TODO: Add status check tests that use this constant +const mockAnalyticsDataDeletionStatusPath = `${mockAnalyticsDataDeletionPath}/regulations/`; +const mockAnalyticsDataDeletionEndpoint = `${mockAnalyticsDataDeletionOrigin}${mockAnalyticsDataDeletionPath}`; + +describe('DataDeletionService', () => { + describe('createDataDeletionRegulationTask', () => { + it('submits a data deletion request', async () => { + const mockResponse = { + data: { + regulateId: mockTaskId, + }, + }; + mockDataDeletionInterceptor().reply(200, mockResponse); + const dataDeletionService = new DataDeletionService(getDefaultOptions()); + + const response = + await dataDeletionService.createDataDeletionRegulationTask( + mockMetaMetricsId, + ); + + expect(response).toStrictEqual(mockTaskId); + }); + + it('throws if the request fails consistently', async () => { + mockDataDeletionInterceptor().replyWithError('Failed to fetch').persist(); + const dataDeletionService = new DataDeletionService(getDefaultOptions()); + + await expect( + dataDeletionService.createDataDeletionRegulationTask(mockMetaMetricsId), + ).rejects.toThrow('Failed to fetch'); + }); + + it('throws if the initial request and all retries fail', async () => { + const retries = RETRIES; + mockDataDeletionInterceptor() + .times(1 + retries) + .replyWithError('Failed to fetch'); + const dataDeletionService = new DataDeletionService(getDefaultOptions()); + + await expect( + dataDeletionService.createDataDeletionRegulationTask(mockMetaMetricsId), + ).rejects.toThrow('Failed to fetch'); + }); + + it('succeeds if the last retry succeeds', async () => { + const retries = RETRIES; + const mockResponse = { + data: { + regulateId: mockTaskId, + }, + }; + // Initial interceptor for failing requests + mockDataDeletionInterceptor() + .times(retries) + .replyWithError('Failed to fetch'); + // Interceptor for successful request + mockDataDeletionInterceptor().reply(200, mockResponse); + const dataDeletionService = new DataDeletionService(getDefaultOptions()); + + const response = + await dataDeletionService.createDataDeletionRegulationTask( + mockMetaMetricsId, + ); + + expect(response).toStrictEqual(mockTaskId); + }); + + describe('timeout', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('throws if all attempts exceed the timeout', async () => { + const retries = RETRIES; + const timeout = 10_000; + const mockResponse = { + data: { + regulateId: mockTaskId, + }, + }; + mockDataDeletionInterceptor() + .times(1 + retries) + .delay(timeout * 2) + .reply(200, mockResponse); + const dataDeletionService = new DataDeletionService({ + ...getDefaultOptions(), + timeout, + }); + + await expect( + fetchWithFakeTimers({ + fetchOperation: () => + dataDeletionService.createDataDeletionRegulationTask( + mockMetaMetricsId, + ), + retries, + }), + ).rejects.toThrow('The user aborted a request'); + }); + }); + + describe('before circuit break', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('does not call onDegraded when requests succeeds faster than threshold', async () => { + const degradedThreshold = 1000; + const mockResponse = { + data: { + regulateId: mockTaskId, + }, + }; + mockDataDeletionInterceptor() + .delay(degradedThreshold / 2) + .reply(200, mockResponse); + const onDegraded = jest.fn(); + const dataDeletionService = new DataDeletionService({ + ...getDefaultOptions(), + degradedThreshold, + onDegraded, + }); + + await fetchWithFakeTimers({ + fetchOperation: () => + dataDeletionService.createDataDeletionRegulationTask( + mockMetaMetricsId, + ), + retries: 0, + }); + + expect(onDegraded).not.toHaveBeenCalled(); + }); + + it('does not call onDegraded when requests succeeds on retry faster than threshold', async () => { + const responseDelay = 1000; + // Set threshold above max retry delay to ensure the time is always under the threshold, + // even with random jitter + const degradedThreshold = defaultMaxRetryDelay + responseDelay + 100; + const mockResponse = { + data: { + regulateId: mockTaskId, + }, + }; + // Initial interceptor for failing requests + mockDataDeletionInterceptor().replyWithError('Failed to fetch'); + // Second interceptor for successful response + mockDataDeletionInterceptor() + .delay(responseDelay) + .reply(200, mockResponse); + const onDegraded = jest.fn(); + const dataDeletionService = new DataDeletionService({ + ...getDefaultOptions(), + degradedThreshold, + onDegraded, + }); + + await fetchWithFakeTimers({ + fetchOperation: () => + dataDeletionService.createDataDeletionRegulationTask( + mockMetaMetricsId, + ), + retries: 1, + }); + + expect(onDegraded).not.toHaveBeenCalled(); + }); + + it('calls onDegraded when request fails', async () => { + mockDataDeletionInterceptor() + .replyWithError('Failed to fetch') + .persist(); + const onDegraded = jest.fn(); + const dataDeletionService = new DataDeletionService({ + ...getDefaultOptions(), + onDegraded, + }); + + await expect(() => + fetchWithFakeTimers({ + fetchOperation: () => + dataDeletionService.createDataDeletionRegulationTask( + mockMetaMetricsId, + ), + // Advance timers enough to resolve default number of retries + retries: RETRIES, + }), + ).rejects.toThrow('Failed to fetch'); + + expect(onDegraded).toHaveBeenCalledTimes(1); + }); + + it('calls onDegraded when request is slower than threshold', async () => { + const degradedThreshold = 1000; + const retries = 0; + const mockResponse = { + data: { + regulateId: mockTaskId, + }, + }; + nock(mockAnalyticsDataDeletionOrigin) + .post(mockAnalyticsCreateDataDeletionPath) + .delay(degradedThreshold * 2) + .reply(200, mockResponse); + const onDegraded = jest.fn(); + const dataDeletionService = new DataDeletionService({ + ...getDefaultOptions(), + degradedThreshold, + onDegraded, + }); + + await fetchWithFakeTimers({ + fetchOperation: () => + dataDeletionService.createDataDeletionRegulationTask( + mockMetaMetricsId, + ), + retries, + }); + + expect(onDegraded).toHaveBeenCalledTimes(1); + }); + + it('calls onDegraded when request is slower than threshold after retry', async () => { + const degradedThreshold = 1000; + const mockResponse = { + data: { + regulateId: mockTaskId, + }, + }; + // Initial interceptor for failing requests + mockDataDeletionInterceptor().replyWithError('Failed to fetch'); + // Second interceptor for successful response + mockDataDeletionInterceptor() + .delay(degradedThreshold * 2) + .reply(200, mockResponse); + const onDegraded = jest.fn(); + const dataDeletionService = new DataDeletionService({ + ...getDefaultOptions(), + degradedThreshold, + onDegraded, + }); + + await fetchWithFakeTimers({ + fetchOperation: () => + dataDeletionService.createDataDeletionRegulationTask( + mockMetaMetricsId, + ), + retries: 1, + }); + + expect(onDegraded).toHaveBeenCalledTimes(1); + }); + }); + + describe('after circuit break', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('stops making fetch requests after too many consecutive failures', async () => { + const retries = RETRIES; + const maxRequestsPerAttempt = retries + 1; + const attemptsToTriggerBreak = + MAX_CONSECUTIVE_FAILURES / maxRequestsPerAttempt; + const mockResponse = { + data: { + regulateId: mockTaskId, + }, + }; + // Initial interceptor for failing requests + mockDataDeletionInterceptor() + .times(MAX_CONSECUTIVE_FAILURES) + .replyWithError('Failed to fetch'); + // This interceptor should not be used + const successfullCallScope = mockDataDeletionInterceptor().reply( + 200, + mockResponse, + ); + const dataDeletionService = new DataDeletionService({ + ...getDefaultOptions(), + // Ensure break duration is well over the max delay for a single request, so that the + // break doesn't end during a retry attempt + circuitBreakDuration: defaultMaxRetryDelay * 10, + }); + const fetchOperation = () => + dataDeletionService.createDataDeletionRegulationTask( + mockMetaMetricsId, + ); + // Initial calls to exhaust maximum allowed failures + // eslint-disable-next-line @typescript-eslint/no-unused-vars + for (const _ of Array(attemptsToTriggerBreak).keys()) { + await expect(() => + fetchWithFakeTimers({ + fetchOperation, + retries, + }), + ).rejects.toThrow('Failed to fetch'); + } + + await expect(() => + fetchWithFakeTimers({ + fetchOperation, + retries, + }), + ).rejects.toThrow( + 'Execution prevented because the circuit breaker is open', + ); + expect(successfullCallScope.isDone()).toBe(false); + }); + + it('calls onBreak handler upon break', async () => { + const retries = RETRIES; + const maxRequestsPerAttempt = retries + 1; + const attemptsToTriggerBreak = + MAX_CONSECUTIVE_FAILURES / maxRequestsPerAttempt; + // Initial interceptor for failing requests + mockDataDeletionInterceptor() + .times(MAX_CONSECUTIVE_FAILURES) + .replyWithError('Failed to fetch'); + const onBreak = jest.fn(); + const dataDeletionService = new DataDeletionService({ + ...getDefaultOptions(), + // Ensure break duration is well over the max delay for a single request, so that the + // break doesn't end during a retry attempt + circuitBreakDuration: defaultMaxRetryDelay * 10, + onBreak, + }); + const fetchOperation = () => + dataDeletionService.createDataDeletionRegulationTask( + mockMetaMetricsId, + ); + + // Initial calls to exhaust maximum allowed failures + // eslint-disable-next-line @typescript-eslint/no-unused-vars + for (const _ of Array(attemptsToTriggerBreak).keys()) { + await expect(() => + fetchWithFakeTimers({ + fetchOperation, + retries, + }), + ).rejects.toThrow('Failed to fetch'); + } + + expect(onBreak).toHaveBeenCalledTimes(1); + }); + + it('stops calling onDegraded', async () => { + const retries = RETRIES; + const maxRequestsPerAttempt = retries + 1; + const attemptsToTriggerBreak = + MAX_CONSECUTIVE_FAILURES / maxRequestsPerAttempt; + // Initial interceptor for failing requests + mockDataDeletionInterceptor() + .times(MAX_CONSECUTIVE_FAILURES) + .replyWithError('Failed to fetch'); + const onBreak = jest.fn(); + const onDegraded = jest.fn(); + const dataDeletionService = new DataDeletionService({ + ...getDefaultOptions(), + // Ensure break duration is well over the max delay for a single request, so that the + // break doesn't end during a retry attempt + circuitBreakDuration: defaultMaxRetryDelay * 10, + onBreak, + onDegraded, + }); + const fetchOperation = () => + dataDeletionService.createDataDeletionRegulationTask( + mockMetaMetricsId, + ); + // Initial calls to exhaust maximum allowed failures + // eslint-disable-next-line @typescript-eslint/no-unused-vars + for (const _ of Array(attemptsToTriggerBreak).keys()) { + await expect(() => + fetchWithFakeTimers({ + fetchOperation, + retries, + }), + ).rejects.toThrow('Failed to fetch'); + } + // Confirm that circuit is broken + expect(onBreak).toHaveBeenCalledTimes(1); + // Should be called twice by now, once per update attempt prior to break + expect(onDegraded).toHaveBeenCalledTimes(attemptsToTriggerBreak - 1); + + await expect(() => + fetchWithFakeTimers({ + fetchOperation, + retries, + }), + ).rejects.toThrow( + 'Execution prevented because the circuit breaker is open', + ); + + expect(onDegraded).toHaveBeenCalledTimes(attemptsToTriggerBreak - 1); + }); + + it('keeps circuit closed if first request fails when half-open', async () => { + const retries = RETRIES; + const maxRequestsPerAttempt = retries + 1; + const attemptsToTriggerBreak = + MAX_CONSECUTIVE_FAILURES / maxRequestsPerAttempt; + const mockResponse = { + data: { + regulateId: mockTaskId, + }, + }; + // Ensure break duration is well over the max delay for a single request, so that the + // break doesn't end during a retry attempt + const circuitBreakDuration = defaultMaxRetryDelay * 10; + // Initial interceptor for failing requests + mockDataDeletionInterceptor() + // The +1 is for the additional request when the circuit is half-open + .times(MAX_CONSECUTIVE_FAILURES + 1) + .replyWithError('Failed to fetch'); + // This interceptor should not be used + const successfullCallScope = mockDataDeletionInterceptor().reply( + 200, + mockResponse, + ); + const dataDeletionService = new DataDeletionService({ + ...getDefaultOptions(), + circuitBreakDuration, + }); + const fetchOperation = () => + dataDeletionService.createDataDeletionRegulationTask( + mockMetaMetricsId, + ); + // Initial calls to exhaust maximum allowed failures + // eslint-disable-next-line @typescript-eslint/no-unused-vars + for (const _ of Array(attemptsToTriggerBreak).keys()) { + await expect(() => + fetchWithFakeTimers({ + fetchOperation, + retries, + }), + ).rejects.toThrow('Failed to fetch'); + } + // Confirm that circuit has broken + await expect(() => + fetchWithFakeTimers({ + fetchOperation, + retries, + }), + ).rejects.toThrow( + 'Execution prevented because the circuit breaker is open', + ); + // Wait for circuit to move to half-open + await jest.advanceTimersByTimeAsync(circuitBreakDuration); + + // The circuit should remain open after the first request fails + // The fetch error is replaced by the circuit break error due to the retries + await expect(() => + fetchWithFakeTimers({ + fetchOperation, + retries, + }), + ).rejects.toThrow( + 'Execution prevented because the circuit breaker is open', + ); + + // Confirm that the circuit is still open + await expect(() => + fetchWithFakeTimers({ + fetchOperation, + retries, + }), + ).rejects.toThrow( + 'Execution prevented because the circuit breaker is open', + ); + expect(successfullCallScope.isDone()).toBe(false); + }); + + it('recovers', async () => { + const retries = RETRIES; + const maxRequestsPerAttempt = retries + 1; + const attemptsToTriggerBreak = + MAX_CONSECUTIVE_FAILURES / maxRequestsPerAttempt; + const mockResponse = { + data: { + regulateId: mockTaskId, + }, + }; + // Ensure break duration is well over the max delay for a single request, so that the + // break doesn't end during a retry attempt + const circuitBreakDuration = defaultMaxRetryDelay * 10; + // Initial interceptor for failing requests + mockDataDeletionInterceptor() + .times(MAX_CONSECUTIVE_FAILURES) + .replyWithError('Failed to fetch'); + // Later interceptor for successfull request after recovery + mockDataDeletionInterceptor().reply(200, mockResponse); + const dataDeletionService = new DataDeletionService({ + ...getDefaultOptions(), + circuitBreakDuration, + }); + const fetchOperation = () => + dataDeletionService.createDataDeletionRegulationTask( + mockMetaMetricsId, + ); + // Initial calls to exhaust maximum allowed failures + // eslint-disable-next-line @typescript-eslint/no-unused-vars + for (const _ of Array(attemptsToTriggerBreak).keys()) { + await expect(() => { + return fetchWithFakeTimers({ + fetchOperation, + retries, + }); + }).rejects.toThrow('Failed to fetch'); + } + // Confirm that circuit has broken + await expect(() => + fetchWithFakeTimers({ + fetchOperation, + retries, + }), + ).rejects.toThrow( + 'Execution prevented because the circuit breaker is open', + ); + // Wait for circuit to move to half-open + await jest.advanceTimersByTimeAsync(circuitBreakDuration); + + const response = await fetchWithFakeTimers({ + fetchOperation, + retries, + }); + + expect(response).toStrictEqual(mockTaskId); + }); + }); + }); + + describe('fetchDeletionRegulationStatus', () => { + it('fetches the delete regulation status', async () => { + const mockOverAllStatus = 'UNKNOWN'; + const mockResponse = { + data: { + regulation: { + overallStatus: 'UNKNOWN', + }, + }, + }; + mockDataDeletionStatusInterceptor(mockTaskId).reply(200, mockResponse); + const dataDeletionService = new DataDeletionService(getDefaultOptions()); + + const response = await dataDeletionService.fetchDeletionRegulationStatus( + mockTaskId, + ); + + expect(response).toStrictEqual(mockOverAllStatus); + }); + + it('throws if the request fails consistently', async () => { + mockDataDeletionStatusInterceptor(mockTaskId) + .replyWithError('Failed to fetch') + .persist(); + const dataDeletionService = new DataDeletionService(getDefaultOptions()); + + await expect( + dataDeletionService.fetchDeletionRegulationStatus(mockTaskId), + ).rejects.toThrow('Failed to fetch'); + }); + + it('throws if the initial request and all retries fail', async () => { + const retries = RETRIES; + mockDataDeletionStatusInterceptor(mockTaskId) + .times(1 + retries) + .replyWithError('Failed to fetch'); + const dataDeletionService = new DataDeletionService(getDefaultOptions()); + + await expect( + dataDeletionService.fetchDeletionRegulationStatus(mockTaskId), + ).rejects.toThrow('Failed to fetch'); + }); + + it('succeeds if the last retry succeeds', async () => { + const mockOverAllStatus = 'UNKNOWN'; + const retries = RETRIES; + const mockResponse = { + data: { + regulation: { + overallStatus: 'UNKNOWN', + }, + }, + }; + // Initial interceptor for failing requests + mockDataDeletionStatusInterceptor(mockTaskId) + .times(retries) + .replyWithError('Failed to fetch'); + // Interceptor for successful request + mockDataDeletionStatusInterceptor(mockTaskId).reply(200, mockResponse); + const dataDeletionService = new DataDeletionService(getDefaultOptions()); + + const response = await dataDeletionService.fetchDeletionRegulationStatus( + mockTaskId, + ); + + expect(response).toStrictEqual(mockOverAllStatus); + }); + + describe('timeout', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('throws if all attempts exceed the timeout', async () => { + const retries = RETRIES; + const timeout = 10_000; + const mockResponse = { + data: { + regulation: { + overallStatus: 'UNKNOWN', + }, + }, + }; + mockDataDeletionStatusInterceptor(mockTaskId) + .times(1 + retries) + .delay(timeout * 2) + .reply(200, mockResponse); + const dataDeletionService = new DataDeletionService({ + ...getDefaultOptions(), + timeout, + }); + + await expect( + fetchWithFakeTimers({ + fetchOperation: () => + dataDeletionService.fetchDeletionRegulationStatus(mockTaskId), + retries, + }), + ).rejects.toThrow('The user aborted a request'); + }); + }); + + describe('before circuit break', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('does not call onDegraded when requests succeeds faster than threshold', async () => { + const degradedThreshold = 1000; + const mockResponse = { + data: { + regulation: { + overallStatus: 'UNKNOWN', + }, + }, + }; + mockDataDeletionStatusInterceptor(mockTaskId) + .delay(degradedThreshold / 2) + .reply(200, mockResponse); + const onDegraded = jest.fn(); + const dataDeletionService = new DataDeletionService({ + ...getDefaultOptions(), + degradedThreshold, + onDegraded, + }); + + await fetchWithFakeTimers({ + fetchOperation: () => + dataDeletionService.fetchDeletionRegulationStatus(mockTaskId), + retries: 0, + }); + + expect(onDegraded).not.toHaveBeenCalled(); + }); + + it('does not call onDegraded when requests succeeds on retry faster than threshold', async () => { + const responseDelay = 1000; + // Set threshold above max retry delay to ensure the time is always under the threshold, + // even with random jitter + const degradedThreshold = defaultMaxRetryDelay + responseDelay + 100; + const mockResponse = { + data: { + regulation: { + overallStatus: 'UNKNOWN', + }, + }, + }; + // Initial interceptor for failing requests + mockDataDeletionStatusInterceptor(mockTaskId).replyWithError( + 'Failed to fetch', + ); + // Second interceptor for successful response + mockDataDeletionStatusInterceptor(mockTaskId) + .delay(responseDelay) + .reply(200, mockResponse); + const onDegraded = jest.fn(); + const dataDeletionService = new DataDeletionService({ + ...getDefaultOptions(), + degradedThreshold, + onDegraded, + }); + + await fetchWithFakeTimers({ + fetchOperation: () => + dataDeletionService.fetchDeletionRegulationStatus(mockTaskId), + retries: 1, + }); + + expect(onDegraded).not.toHaveBeenCalled(); + }); + + it('calls onDegraded when request fails', async () => { + mockDataDeletionStatusInterceptor(mockTaskId) + .replyWithError('Failed to fetch') + .persist(); + const onDegraded = jest.fn(); + const dataDeletionService = new DataDeletionService({ + ...getDefaultOptions(), + onDegraded, + }); + + await expect(() => + fetchWithFakeTimers({ + fetchOperation: () => + dataDeletionService.fetchDeletionRegulationStatus(mockTaskId), + // Advance timers enough to resolve default number of retries + retries: RETRIES, + }), + ).rejects.toThrow('Failed to fetch'); + + expect(onDegraded).toHaveBeenCalledTimes(1); + }); + + it('calls onDegraded when request is slower than threshold', async () => { + const degradedThreshold = 1000; + const retries = 0; + const mockResponse = { + data: { + regulation: { + overallStatus: 'UNKNOWN', + }, + }, + }; + mockDataDeletionStatusInterceptor(mockTaskId) + .delay(degradedThreshold * 2) + .reply(200, mockResponse); + // nock(mockAnalyticsDataDeletionOrigin) + // .post(mockAnalyticsCreateDataDeletionPath) + // .delay(degradedThreshold * 2) + // .reply(200, mockResponse); + const onDegraded = jest.fn(); + const dataDeletionService = new DataDeletionService({ + ...getDefaultOptions(), + degradedThreshold, + onDegraded, + }); + + await fetchWithFakeTimers({ + fetchOperation: () => + dataDeletionService.fetchDeletionRegulationStatus(mockTaskId), + retries, + }); + + expect(onDegraded).toHaveBeenCalledTimes(1); + }); + + it('calls onDegraded when request is slower than threshold after retry', async () => { + const degradedThreshold = 1000; + const mockResponse = { + data: { + regulation: { + overallStatus: 'UNKNOWN', + }, + }, + }; + // Initial interceptor for failing requests + mockDataDeletionStatusInterceptor(mockTaskId).replyWithError( + 'Failed to fetch', + ); + // Second interceptor for successful response + mockDataDeletionStatusInterceptor(mockTaskId) + .delay(degradedThreshold * 2) + .reply(200, mockResponse); + const onDegraded = jest.fn(); + const dataDeletionService = new DataDeletionService({ + ...getDefaultOptions(), + degradedThreshold, + onDegraded, + }); + + await fetchWithFakeTimers({ + fetchOperation: () => + dataDeletionService.fetchDeletionRegulationStatus(mockTaskId), + retries: 1, + }); + + expect(onDegraded).toHaveBeenCalledTimes(1); + }); + }); + + describe('after circuit break', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('stops making fetch requests after too many consecutive failures', async () => { + const retries = RETRIES; + const maxRequestsPerAttempt = retries + 1; + const attemptsToTriggerBreak = + MAX_CONSECUTIVE_FAILURES / maxRequestsPerAttempt; + const mockResponse = { + data: { + regulation: { + overallStatus: 'UNKNOWN', + }, + }, + }; + // Initial interceptor for failing requests + mockDataDeletionStatusInterceptor(mockTaskId) + .times(MAX_CONSECUTIVE_FAILURES) + .replyWithError('Failed to fetch'); + // This interceptor should not be used + const successfullCallScope = mockDataDeletionStatusInterceptor( + mockTaskId, + ).reply(200, mockResponse); + const dataDeletionService = new DataDeletionService({ + ...getDefaultOptions(), + // Ensure break duration is well over the max delay for a single request, so that the + // break doesn't end during a retry attempt + circuitBreakDuration: defaultMaxRetryDelay * 10, + }); + const fetchOperation = () => + dataDeletionService.fetchDeletionRegulationStatus(mockTaskId); + // Initial calls to exhaust maximum allowed failures + // eslint-disable-next-line @typescript-eslint/no-unused-vars + for (const _ of Array(attemptsToTriggerBreak).keys()) { + await expect(() => + fetchWithFakeTimers({ + fetchOperation, + retries, + }), + ).rejects.toThrow('Failed to fetch'); + } + + await expect(() => + fetchWithFakeTimers({ + fetchOperation, + retries, + }), + ).rejects.toThrow( + 'Execution prevented because the circuit breaker is open', + ); + expect(successfullCallScope.isDone()).toBe(false); + }); + + it('calls onBreak handler upon break', async () => { + const retries = RETRIES; + const maxRequestsPerAttempt = retries + 1; + const attemptsToTriggerBreak = + MAX_CONSECUTIVE_FAILURES / maxRequestsPerAttempt; + // Initial interceptor for failing requests + mockDataDeletionStatusInterceptor(mockTaskId) + .times(MAX_CONSECUTIVE_FAILURES) + .replyWithError('Failed to fetch'); + const onBreak = jest.fn(); + const dataDeletionService = new DataDeletionService({ + ...getDefaultOptions(), + // Ensure break duration is well over the max delay for a single request, so that the + // break doesn't end during a retry attempt + circuitBreakDuration: defaultMaxRetryDelay * 10, + onBreak, + }); + const fetchOperation = () => + dataDeletionService.fetchDeletionRegulationStatus(mockTaskId); + + // Initial calls to exhaust maximum allowed failures + // eslint-disable-next-line @typescript-eslint/no-unused-vars + for (const _ of Array(attemptsToTriggerBreak).keys()) { + await expect(() => + fetchWithFakeTimers({ + fetchOperation, + retries, + }), + ).rejects.toThrow('Failed to fetch'); + } + + expect(onBreak).toHaveBeenCalledTimes(1); + }); + + it('stops calling onDegraded', async () => { + const retries = RETRIES; + const maxRequestsPerAttempt = retries + 1; + const attemptsToTriggerBreak = + MAX_CONSECUTIVE_FAILURES / maxRequestsPerAttempt; + // Initial interceptor for failing requests + mockDataDeletionStatusInterceptor(mockTaskId) + .times(MAX_CONSECUTIVE_FAILURES) + .replyWithError('Failed to fetch'); + const onBreak = jest.fn(); + const onDegraded = jest.fn(); + const dataDeletionService = new DataDeletionService({ + ...getDefaultOptions(), + // Ensure break duration is well over the max delay for a single request, so that the + // break doesn't end during a retry attempt + circuitBreakDuration: defaultMaxRetryDelay * 10, + onBreak, + onDegraded, + }); + const fetchOperation = () => + dataDeletionService.fetchDeletionRegulationStatus(mockTaskId); + // Initial calls to exhaust maximum allowed failures + // eslint-disable-next-line @typescript-eslint/no-unused-vars + for (const _ of Array(attemptsToTriggerBreak).keys()) { + await expect(() => + fetchWithFakeTimers({ + fetchOperation, + retries, + }), + ).rejects.toThrow('Failed to fetch'); + } + // Confirm that circuit is broken + expect(onBreak).toHaveBeenCalledTimes(1); + // Should be called twice by now, once per update attempt prior to break + expect(onDegraded).toHaveBeenCalledTimes(attemptsToTriggerBreak - 1); + + await expect(() => + fetchWithFakeTimers({ + fetchOperation, + retries, + }), + ).rejects.toThrow( + 'Execution prevented because the circuit breaker is open', + ); + + expect(onDegraded).toHaveBeenCalledTimes(attemptsToTriggerBreak - 1); + }); + + it('keeps circuit closed if first request fails when half-open', async () => { + const retries = RETRIES; + const maxRequestsPerAttempt = retries + 1; + const attemptsToTriggerBreak = + MAX_CONSECUTIVE_FAILURES / maxRequestsPerAttempt; + const mockResponse = { + data: { + regulation: { + overallStatus: 'UNKNOWN', + }, + }, + }; + // Ensure break duration is well over the max delay for a single request, so that the + // break doesn't end during a retry attempt + const circuitBreakDuration = defaultMaxRetryDelay * 10; + // Initial interceptor for failing requests + mockDataDeletionStatusInterceptor(mockTaskId) + // The +1 is for the additional request when the circuit is half-open + .times(MAX_CONSECUTIVE_FAILURES + 1) + .replyWithError('Failed to fetch'); + // This interceptor should not be used + const successfullCallScope = mockDataDeletionStatusInterceptor( + mockTaskId, + ).reply(200, mockResponse); + const dataDeletionService = new DataDeletionService({ + ...getDefaultOptions(), + circuitBreakDuration, + }); + const fetchOperation = () => + dataDeletionService.fetchDeletionRegulationStatus(mockTaskId); + // Initial calls to exhaust maximum allowed failures + // eslint-disable-next-line @typescript-eslint/no-unused-vars + for (const _ of Array(attemptsToTriggerBreak).keys()) { + await expect(() => + fetchWithFakeTimers({ + fetchOperation, + retries, + }), + ).rejects.toThrow('Failed to fetch'); + } + // Confirm that circuit has broken + await expect(() => + fetchWithFakeTimers({ + fetchOperation, + retries, + }), + ).rejects.toThrow( + 'Execution prevented because the circuit breaker is open', + ); + // Wait for circuit to move to half-open + await jest.advanceTimersByTimeAsync(circuitBreakDuration); + + // The circuit should remain open after the first request fails + // The fetch error is replaced by the circuit break error due to the retries + await expect(() => + fetchWithFakeTimers({ + fetchOperation, + retries, + }), + ).rejects.toThrow( + 'Execution prevented because the circuit breaker is open', + ); + + // Confirm that the circuit is still open + await expect(() => + fetchWithFakeTimers({ + fetchOperation, + retries, + }), + ).rejects.toThrow( + 'Execution prevented because the circuit breaker is open', + ); + expect(successfullCallScope.isDone()).toBe(false); + }); + + it('recovers', async () => { + const retries = RETRIES; + const maxRequestsPerAttempt = retries + 1; + const attemptsToTriggerBreak = + MAX_CONSECUTIVE_FAILURES / maxRequestsPerAttempt; + const mockOverAllStatus = 'UNKNOWN'; + const mockResponse = { + data: { + regulation: { + overallStatus: 'UNKNOWN', + }, + }, + }; + // Ensure break duration is well over the max delay for a single request, so that the + // break doesn't end during a retry attempt + const circuitBreakDuration = defaultMaxRetryDelay * 10; + // Initial interceptor for failing requests + mockDataDeletionStatusInterceptor(mockTaskId) + .times(MAX_CONSECUTIVE_FAILURES) + .replyWithError('Failed to fetch'); + // Later interceptor for successfull request after recovery + mockDataDeletionStatusInterceptor(mockTaskId).reply(200, mockResponse); + const dataDeletionService = new DataDeletionService({ + ...getDefaultOptions(), + circuitBreakDuration, + }); + const fetchOperation = () => + dataDeletionService.fetchDeletionRegulationStatus(mockTaskId); + // Initial calls to exhaust maximum allowed failures + // eslint-disable-next-line @typescript-eslint/no-unused-vars + for (const _ of Array(attemptsToTriggerBreak).keys()) { + await expect(() => { + return fetchWithFakeTimers({ + fetchOperation, + retries, + }); + }).rejects.toThrow('Failed to fetch'); + } + // Confirm that circuit has broken + await expect(() => + fetchWithFakeTimers({ + fetchOperation, + retries, + }), + ).rejects.toThrow( + 'Execution prevented because the circuit breaker is open', + ); + // Wait for circuit to move to half-open + await jest.advanceTimersByTimeAsync(circuitBreakDuration); + + const response = await fetchWithFakeTimers({ + fetchOperation, + retries, + }); + + expect(response).toStrictEqual(mockOverAllStatus); + }); + }); + }); +}); + +/** + * Calls the given fetch operation while advancing the fake timers clock, allowing the function to + * resolve. + * + * Fetching in an environment with fake timers is challenging because we're using a library that + * automatically retries failed requests, which uses `setTimeout` internally. We have to advance + * the clock after the update call starts but before awaiting the result, otherwise it never + * resolves. + * + * @param args - Arguments + * @param args.fetchOperation - The fetch operation to call. + * @param args.retries - The number of retries the fetch call is configured to make. + */ +async function fetchWithFakeTimers({ + fetchOperation, + retries, +}: { + fetchOperation: () => Promise; + retries: number; +}) { + const pendingUpdate = fetchOperation(); + + // This IIFE will track when the request has resolved, and will ensure that + // the tests don't trigger an "Unhandled Promise error" due to the delayed + // handling of the Promise. + let resolved = false; + (async () => { + try { + await pendingUpdate; + } catch (error) { + // suppress Unhandled Promise error + } finally { + resolved = true; + } + })(); + + // Advance timer enough to exceed max possible retry delay for initial call and all + // subsequent retries, until request has resolved. + // eslint-disable-next-line @typescript-eslint/no-unused-vars + for (const _ of Array(retries + 1).keys()) { + if (resolved) { + break; + } + // Advance timer in steps to allow room for other timers/Promises to resolve during this + // waiting period, and to prevent unnecessarily long waiting. + const intervalLength = defaultMaxRetryDelay / 10; + const numberOfIntervals = defaultMaxRetryDelay / intervalLength; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + for (const _interval of new Array(numberOfIntervals).keys()) { + if (resolved) { + break; + } + await jest.advanceTimersByTimeAsync(intervalLength); + } + } + + return await pendingUpdate; +} + +/** + * Create a Nock scope for the "create data deletion task" route. + * + * @returns A Nock interceptor for the "create data deletion" request. + */ +function mockDataDeletionInterceptor(): nock.Interceptor { + return nock(mockAnalyticsDataDeletionOrigin).post( + mockAnalyticsCreateDataDeletionPath, + { + regulationType: 'DELETE_ONLY', + subjectType: 'USER_ID', + subjectIds: [mockMetaMetricsId], + }, + ); +} + +/** + * Create a Nock scope for the "data regulation status check" route. + * + * @param regulationId + * @returns A Nock interceptor for the "data regulation status check" request. + */ +function mockDataDeletionStatusInterceptor( + regulationId: string, +): nock.Interceptor { + return nock(mockAnalyticsDataDeletionOrigin).get( + mockAnalyticsDataDeletionStatusPath + regulationId, + ); +} + +/** + * Get default options for the DataDeletionService. + * + * @returns Default options for the data deletion service. + */ +function getDefaultOptions(): ConstructorParameters< + typeof DataDeletionService +>[0] { + return { + analyticsDataDeletionEndpoint: mockAnalyticsDataDeletionEndpoint, + analyticsDataDeletionSourceId: mockSourceId, + timeout: defaultTimeout, + }; +} diff --git a/app/scripts/services/data-deletion-service.ts b/app/scripts/services/data-deletion-service.ts new file mode 100644 index 000000000000..3bdafc03b582 --- /dev/null +++ b/app/scripts/services/data-deletion-service.ts @@ -0,0 +1,274 @@ +import { hasProperty, isObject } from '@metamask/utils'; +import { + circuitBreaker, + ConsecutiveBreaker, + ExponentialBackoff, + handleAll, + type IPolicy, + retry, + wrap, + CircuitState, +} from 'cockatiel'; +import getFetchWithTimeout from '../../../shared/modules/fetch-with-timeout'; +import { DeleteRegulationStatus } from '../../../shared/constants/metametrics'; + +const DEFAULT_ANALYTICS_DATA_DELETION_SOURCE_ID = + process.env.ANALYTICS_DATA_DELETION_SOURCE_ID ?? 'test'; +const DEFAULT_ANALYTICS_DATA_DELETION_ENDPOINT = + process.env.ANALYTICS_DATA_DELETION_ENDPOINT ?? + 'https://metametrics.metamask.test'; + +/** + * The number of times we retry a specific failed request to the data deletion API. + */ +export const RETRIES = 3; + +/** + * The maximum conseutive failures allowed before treating the server as inaccessible, and + * breaking the circuit. + * + * Each update attempt will result (1 + retries) calls if the server is down. + */ +export const MAX_CONSECUTIVE_FAILURES = (1 + RETRIES) * 3; + +/** + * When the circuit breaks, we wait for this period of time (in milliseconds) before allowing + * a request to go through to the API. + */ +const DEFAULT_CIRCUIT_BREAK_DURATION = 30 * 60 * 1000; + +/** + * The threshold (in milliseconds) for when a successful request is considered "degraded". + */ +const DEFAULT_DEGRADED_THRESHOLD = 5_000; + +/** + * Type guard for Fetch network responses with a `statusCode` property. + * + * @param response - A suspected Fetch network response. + * @returns A type checked Fetch network response. + */ +function isValidResponse( + response: unknown, +): response is { statusCode: number } { + return ( + isObject(response) && + hasProperty(response, 'statusCode') && + typeof response.statusCode === 'number' + ); +} + +/** + * Returns `true` if the parameter is a Fetch network response with a status code that indiciates + * server failure. + * + * @param response - The response to check. + * @returns `true` if the response indicates a server failure, `false` otherwise. + */ +function onServerFailure(response: unknown) { + return isValidResponse(response) && response.statusCode >= 500; +} + +/** + * Create a Cockatiel retry policy. + * + * This policy uses a retry and circuit breaker strategy. Callbacks are accepted for circuit breaks + * and degraded responses as well. + * + * @param args - Arguments + * @param args.circuitBreakDuration - The amount of time to wait when the circuit breaks + * from too many consecutive failures. + * @param args.degradedThreshold - The threshold between "normal" and "degrated" service, + * in milliseconds. + * @param args.maximumConsecutiveFailures - The maximum number of consecutive failures + * allowed before breaking the circuit and pausing further updates. + * @param args.onBreak - An event handler for when the circuit breaks, useful for capturing + * metrics about network failures. + * @param args.onDegraded - An event handler for when the circuit remains closed, but requests + * are failing or resolving too slowly (i.e. resolving more slowly than the `degradedThreshold`). + * @param args.retries - Number of retry attempts. + * @returns A Cockatiel retry policy. + */ +function createRetryPolicy({ + circuitBreakDuration, + degradedThreshold, + maximumConsecutiveFailures, + onBreak, + onDegraded, + retries, +}: { + circuitBreakDuration: number; + degradedThreshold: number; + maximumConsecutiveFailures: number; + onBreak?: () => void; + onDegraded?: () => void; + retries: number; +}) { + const retryPolicy = retry(handleAll.orWhenResult(onServerFailure), { + maxAttempts: retries, + backoff: new ExponentialBackoff(), + }); + const circuitBreakerPolicy = circuitBreaker(handleAll, { + halfOpenAfter: circuitBreakDuration, + breaker: new ConsecutiveBreaker(maximumConsecutiveFailures), + }); + if (onBreak) { + circuitBreakerPolicy.onBreak(onBreak); + } + if (onDegraded) { + retryPolicy.onGiveUp(() => { + if (circuitBreakerPolicy.state === CircuitState.Closed) { + onDegraded(); + } + }); + retryPolicy.onSuccess(({ duration }) => { + if ( + circuitBreakerPolicy.state === CircuitState.Closed && + duration > degradedThreshold + ) { + onDegraded(); + } + }); + } + return wrap(retryPolicy, circuitBreakerPolicy); +} + +/** + * A serivce for requesting the deletion of analytics data. + */ +export class DataDeletionService { + #analyticsDataDeletionEndpoint: string; + + #analyticsDataDeletionSourceId: string; + + #fetchStatusPolicy: IPolicy; + + #createDataDeletionTaskPolicy: IPolicy; + + #fetchWithTimeout: ReturnType; + + /** + * Construct a data deletion service. + * + * @param options - Options. + * @param options.analyticsDataDeletionEndpoint - The base URL for the data deletion API. + * @param options.analyticsDataDeletionSourceId - The Segment source ID to delete data from. + * @param options.circuitBreakDuration - The amount of time to wait when the circuit breaks + * from too many consecutive failures. + * @param options.degradedThreshold - The threshold between "normal" and "degrated" service, + * in milliseconds. + * @param options.onBreak - An event handler for when the circuit breaks, useful for capturing + * metrics about network failures. + * @param options.onDegraded - An event handler for when the circuit remains closed, but requests + * are failing or resolving too slowly (i.e. resolving more slowly than the `degradedThreshold`). + * @param options.timeout - The timeout allowed for network calls before they are aborted. + */ + constructor({ + analyticsDataDeletionEndpoint = DEFAULT_ANALYTICS_DATA_DELETION_ENDPOINT, + analyticsDataDeletionSourceId = DEFAULT_ANALYTICS_DATA_DELETION_SOURCE_ID, + circuitBreakDuration = DEFAULT_CIRCUIT_BREAK_DURATION, + degradedThreshold = DEFAULT_DEGRADED_THRESHOLD, + onBreak, + onDegraded, + timeout, + }: { + analyticsDataDeletionEndpoint?: string; + analyticsDataDeletionSourceId?: string; + circuitBreakDuration?: number; + degradedThreshold?: number; + onBreak?: () => void; + onDegraded?: () => void; + timeout?: number; + } = {}) { + if (!analyticsDataDeletionEndpoint) { + throw new Error('Missing ANALYTICS_DATA_DELETION_ENDPOINT'); + } else if (!analyticsDataDeletionSourceId) { + throw new Error('Missing ANALYTICS_DATA_DELETION_SOURCE_ID'); + } + this.#fetchWithTimeout = getFetchWithTimeout(timeout); + this.#analyticsDataDeletionEndpoint = analyticsDataDeletionEndpoint; + this.#analyticsDataDeletionSourceId = analyticsDataDeletionSourceId; + this.#createDataDeletionTaskPolicy = createRetryPolicy({ + circuitBreakDuration, + degradedThreshold, + maximumConsecutiveFailures: MAX_CONSECUTIVE_FAILURES, + onBreak, + onDegraded, + retries: RETRIES, + }); + this.#fetchStatusPolicy = createRetryPolicy({ + circuitBreakDuration, + degradedThreshold, + maximumConsecutiveFailures: MAX_CONSECUTIVE_FAILURES, + onBreak, + onDegraded, + retries: RETRIES, + }); + } + + /** + * Submit a deletion request. + * + * We use Segment for this request. Segment calls this deletion request a "regulation", and + * returns a "regulation ID" to keep track of this request and get status updates for it. + * https://docs.segmentapis.com/tag/Deletion-and-Suppression#operation/createSourceRegulation + * + * @param metaMetricsId - The ID associated with the analytics data that we will be deleting. + * @returns The regulation ID for the deletion request. + */ + async createDataDeletionRegulationTask( + metaMetricsId: string, + ): Promise { + const response = await this.#createDataDeletionTaskPolicy.execute(() => + this.#fetchWithTimeout( + `${this.#analyticsDataDeletionEndpoint}/regulations/sources/${ + this.#analyticsDataDeletionSourceId + }`, + { + method: 'POST', + headers: { 'Content-Type': 'application/vnd.segment.v1+json' }, + body: JSON.stringify({ + regulationType: 'DELETE_ONLY', + subjectType: 'USER_ID', + subjectIds: [metaMetricsId], + }), + }, + ), + ); + if (!response.ok) { + throw new Error( + `Fetch failed with status '${response.status}' for request`, + ); + } + return (await response.json()).data.regulateId; + } + + /** + * Fetch the status of the given deletion request. + * https://docs.segmentapis.com/tag/Deletion-and-Suppression#operation/getRegulation + * + * @param deleteRegulationId - The Segment "regulation ID" for the deletion request to check. + * @returns The status of the given deletion request. + */ + async fetchDeletionRegulationStatus( + deleteRegulationId: string, + ): Promise { + const response = await this.#fetchStatusPolicy.execute(() => + this.#fetchWithTimeout( + `${ + this.#analyticsDataDeletionEndpoint + }/regulations/${deleteRegulationId}`, + { + method: 'GET', + headers: { 'Content-Type': 'application/vnd.segment.v1+json' }, + }, + ), + ); + if (!response.ok) { + throw new Error( + `Fetch failed with status '${response.status}' for request`, + ); + } + return (await response.json()).data.regulation.overallStatus; + } +} diff --git a/builds.yml b/builds.yml index 836970631315..573b0633b78c 100644 --- a/builds.yml +++ b/builds.yml @@ -230,6 +230,10 @@ env: - INFURA_PROJECT_ID # Modified in /development/build/scripts.js:@getSegmentWriteKey - SEGMENT_WRITE_KEY: '' + # Modified in /development/build/scripts.js:@getAnalyticsDataDeletionSourceId + - ANALYTICS_DATA_DELETION_SOURCE_ID: null + # Modified in /development/build/scripts.js:@getAnalyticsDataDeletionEndpoint + - ANALYTICS_DATA_DELETION_ENDPOINT: null # Modified in /development/build/scripts.js:@setEnvironmentVariables # Also see DEBUG and NODE_DEBUG - METAMASK_DEBUG: false diff --git a/lavamoat/browserify/beta/policy.json b/lavamoat/browserify/beta/policy.json index 34a45c7f7992..b27b95f111a4 100644 --- a/lavamoat/browserify/beta/policy.json +++ b/lavamoat/browserify/beta/policy.json @@ -853,7 +853,6 @@ "@metamask/abi-utils": true, "@metamask/assets-controllers>@metamask/base-controller": true, "@metamask/assets-controllers>@metamask/polling-controller": true, - "@metamask/assets-controllers>cockatiel": true, "@metamask/assets-controllers>multiformats": true, "@metamask/contract-metadata": true, "@metamask/controller-utils": true, @@ -863,6 +862,7 @@ "@metamask/rpc-errors": true, "@metamask/utils": true, "bn.js": true, + "cockatiel": true, "lodash": true, "single-call-balance-checker-abi": true, "uuid": true @@ -888,19 +888,6 @@ "uuid": true } }, - "@metamask/assets-controllers>cockatiel": { - "globals": { - "AbortController": true, - "AbortSignal": true, - "WeakRef": true, - "clearTimeout": true, - "performance": true, - "setTimeout": true - }, - "packages": { - "process": true - } - }, "@metamask/assets-controllers>multiformats": { "globals": { "TextDecoder": true, @@ -3891,6 +3878,19 @@ "define": true } }, + "cockatiel": { + "globals": { + "AbortController": true, + "AbortSignal": true, + "WeakRef": true, + "clearTimeout": true, + "performance": true, + "setTimeout": true + }, + "packages": { + "process": true + } + }, "copy-to-clipboard": { "globals": { "clipboardData": true, diff --git a/lavamoat/browserify/flask/policy.json b/lavamoat/browserify/flask/policy.json index 34a45c7f7992..b27b95f111a4 100644 --- a/lavamoat/browserify/flask/policy.json +++ b/lavamoat/browserify/flask/policy.json @@ -853,7 +853,6 @@ "@metamask/abi-utils": true, "@metamask/assets-controllers>@metamask/base-controller": true, "@metamask/assets-controllers>@metamask/polling-controller": true, - "@metamask/assets-controllers>cockatiel": true, "@metamask/assets-controllers>multiformats": true, "@metamask/contract-metadata": true, "@metamask/controller-utils": true, @@ -863,6 +862,7 @@ "@metamask/rpc-errors": true, "@metamask/utils": true, "bn.js": true, + "cockatiel": true, "lodash": true, "single-call-balance-checker-abi": true, "uuid": true @@ -888,19 +888,6 @@ "uuid": true } }, - "@metamask/assets-controllers>cockatiel": { - "globals": { - "AbortController": true, - "AbortSignal": true, - "WeakRef": true, - "clearTimeout": true, - "performance": true, - "setTimeout": true - }, - "packages": { - "process": true - } - }, "@metamask/assets-controllers>multiformats": { "globals": { "TextDecoder": true, @@ -3891,6 +3878,19 @@ "define": true } }, + "cockatiel": { + "globals": { + "AbortController": true, + "AbortSignal": true, + "WeakRef": true, + "clearTimeout": true, + "performance": true, + "setTimeout": true + }, + "packages": { + "process": true + } + }, "copy-to-clipboard": { "globals": { "clipboardData": true, diff --git a/lavamoat/browserify/main/policy.json b/lavamoat/browserify/main/policy.json index 34a45c7f7992..b27b95f111a4 100644 --- a/lavamoat/browserify/main/policy.json +++ b/lavamoat/browserify/main/policy.json @@ -853,7 +853,6 @@ "@metamask/abi-utils": true, "@metamask/assets-controllers>@metamask/base-controller": true, "@metamask/assets-controllers>@metamask/polling-controller": true, - "@metamask/assets-controllers>cockatiel": true, "@metamask/assets-controllers>multiformats": true, "@metamask/contract-metadata": true, "@metamask/controller-utils": true, @@ -863,6 +862,7 @@ "@metamask/rpc-errors": true, "@metamask/utils": true, "bn.js": true, + "cockatiel": true, "lodash": true, "single-call-balance-checker-abi": true, "uuid": true @@ -888,19 +888,6 @@ "uuid": true } }, - "@metamask/assets-controllers>cockatiel": { - "globals": { - "AbortController": true, - "AbortSignal": true, - "WeakRef": true, - "clearTimeout": true, - "performance": true, - "setTimeout": true - }, - "packages": { - "process": true - } - }, "@metamask/assets-controllers>multiformats": { "globals": { "TextDecoder": true, @@ -3891,6 +3878,19 @@ "define": true } }, + "cockatiel": { + "globals": { + "AbortController": true, + "AbortSignal": true, + "WeakRef": true, + "clearTimeout": true, + "performance": true, + "setTimeout": true + }, + "packages": { + "process": true + } + }, "copy-to-clipboard": { "globals": { "clipboardData": true, diff --git a/lavamoat/browserify/mmi/policy.json b/lavamoat/browserify/mmi/policy.json index b402ef148b03..56b9dc57a34a 100644 --- a/lavamoat/browserify/mmi/policy.json +++ b/lavamoat/browserify/mmi/policy.json @@ -945,7 +945,6 @@ "@metamask/abi-utils": true, "@metamask/assets-controllers>@metamask/base-controller": true, "@metamask/assets-controllers>@metamask/polling-controller": true, - "@metamask/assets-controllers>cockatiel": true, "@metamask/assets-controllers>multiformats": true, "@metamask/contract-metadata": true, "@metamask/controller-utils": true, @@ -955,6 +954,7 @@ "@metamask/rpc-errors": true, "@metamask/utils": true, "bn.js": true, + "cockatiel": true, "lodash": true, "single-call-balance-checker-abi": true, "uuid": true @@ -980,19 +980,6 @@ "uuid": true } }, - "@metamask/assets-controllers>cockatiel": { - "globals": { - "AbortController": true, - "AbortSignal": true, - "WeakRef": true, - "clearTimeout": true, - "performance": true, - "setTimeout": true - }, - "packages": { - "process": true - } - }, "@metamask/assets-controllers>multiformats": { "globals": { "TextDecoder": true, @@ -3983,6 +3970,19 @@ "define": true } }, + "cockatiel": { + "globals": { + "AbortController": true, + "AbortSignal": true, + "WeakRef": true, + "clearTimeout": true, + "performance": true, + "setTimeout": true + }, + "packages": { + "process": true + } + }, "copy-to-clipboard": { "globals": { "clipboardData": true, diff --git a/package.json b/package.json index ecb05d3ca387..bd4c7f3cdd34 100644 --- a/package.json +++ b/package.json @@ -383,6 +383,7 @@ "bowser": "^2.11.0", "chart.js": "^4.4.1", "classnames": "^2.2.6", + "cockatiel": "^3.1.2", "copy-to-clipboard": "^3.3.3", "currency-formatter": "^1.4.2", "debounce-stream": "^2.0.0", diff --git a/shared/constants/metametrics.ts b/shared/constants/metametrics.ts index ea50d7a5f9fd..502f166c8e91 100644 --- a/shared/constants/metametrics.ts +++ b/shared/constants/metametrics.ts @@ -849,3 +849,18 @@ export enum MetaMetricsEventUiCustomization { export enum MetaMetricsContextProp { PageTitle = 'location', } + +/** + * The status on which to filter the returned regulations. + * Mentioned here: https://docs.segmentapis.com/tag/Deletion-and-Suppression#operation/listRegulationsFromSource + */ +export enum DeleteRegulationStatus { + Failed = 'FAILED', + Finished = 'FINISHED', + Initialized = 'INITIALIZED', + Invalid = 'INVALID', + NotSupported = 'NOT_SUPPORTED', + PartialSuccess = 'PARTIAL_SUCCESS', + Running = 'RUNNING', + Unknown = 'UNKNOWN', +} diff --git a/test/e2e/default-fixture.js b/test/e2e/default-fixture.js index 27db906cd754..3b8ebd46de35 100644 --- a/test/e2e/default-fixture.js +++ b/test/e2e/default-fixture.js @@ -151,6 +151,11 @@ function defaultFixture(inputChainId = CHAIN_IDS.LOCALHOST) { participateInMetaMetrics: false, dataCollectionForMarketing: false, traits: {}, + latestNonAnonymousEventTimestamp: 0, + }, + MetaMetricsDataDeletionController: { + metaMetricsDataDeletionId: null, + metaMetricsDataDeletionTimestamp: 0, }, NetworkController: { ...mockNetworkState({ diff --git a/test/e2e/tests/metrics/errors.spec.js b/test/e2e/tests/metrics/errors.spec.js index d66ad97ba939..ee22bdd93815 100644 --- a/test/e2e/tests/metrics/errors.spec.js +++ b/test/e2e/tests/metrics/errors.spec.js @@ -59,6 +59,8 @@ const removedBackgroundFields = [ 'AppStateController.lastInteractedConfirmationInfo', 'PPOMController.chainStatus.0x539.lastVisited', 'PPOMController.versionInfo', + // This property is timing-dependent + 'MetaMetricsController.latestNonAnonymousEventTimestamp', ]; const removedUiFields = removedBackgroundFields.map(backgroundToUiField); diff --git a/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-background-state.json b/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-background-state.json index c8b74e873e7e..9a2c76210b4d 100644 --- a/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-background-state.json +++ b/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-background-state.json @@ -124,6 +124,10 @@ "fragments": "object", "segmentApiCalls": "object" }, + "MetaMetricsDataDeletionController": { + "metaMetricsDataDeletionId": null, + "metaMetricsDataDeletionTimestamp": 0 + }, "MultichainBalancesController": { "balances": "object" }, "MultichainRatesController": { "fiatCurrency": "usd", diff --git a/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-ui-state.json b/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-ui-state.json index 4124e34a6752..5a4b95548315 100644 --- a/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-ui-state.json +++ b/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-ui-state.json @@ -128,6 +128,8 @@ "selectedAddress": "string", "metaMetricsId": "fake-metrics-id", "marketingCampaignCookieId": null, + "metaMetricsDataDeletionId": null, + "metaMetricsDataDeletionTimestamp": 0, "eventsBeforeMetricsOptIn": "object", "traits": "object", "previousUserTraits": "object", diff --git a/test/e2e/tests/metrics/state-snapshots/errors-before-init-opt-in-background-state.json b/test/e2e/tests/metrics/state-snapshots/errors-before-init-opt-in-background-state.json index c30e331583cd..6d3608c22960 100644 --- a/test/e2e/tests/metrics/state-snapshots/errors-before-init-opt-in-background-state.json +++ b/test/e2e/tests/metrics/state-snapshots/errors-before-init-opt-in-background-state.json @@ -74,6 +74,10 @@ "dataCollectionForMarketing": "boolean", "traits": "object" }, + "MetaMetricsDataDeletionController": { + "metaMetricsDataDeletionId": null, + "metaMetricsDataDeletionTimestamp": 0 + }, "NetworkController": { "selectedNetworkClientId": "string", "networksMetadata": { diff --git a/test/e2e/tests/metrics/state-snapshots/errors-before-init-opt-in-ui-state.json b/test/e2e/tests/metrics/state-snapshots/errors-before-init-opt-in-ui-state.json index 16204cb379fc..1f607a7c9d46 100644 --- a/test/e2e/tests/metrics/state-snapshots/errors-before-init-opt-in-ui-state.json +++ b/test/e2e/tests/metrics/state-snapshots/errors-before-init-opt-in-ui-state.json @@ -74,6 +74,10 @@ "dataCollectionForMarketing": "boolean", "traits": "object" }, + "MetaMetricsDataDeletionController": { + "metaMetricsDataDeletionId": null, + "metaMetricsDataDeletionTimestamp": 0 + }, "NetworkController": { "selectedNetworkClientId": "string", "networksMetadata": { diff --git a/ui/store/actions.test.js b/ui/store/actions.test.js index 2215b677ab91..543390be7d15 100644 --- a/ui/store/actions.test.js +++ b/ui/store/actions.test.js @@ -2531,4 +2531,43 @@ describe('Actions', () => { expect(store.getActions()).toStrictEqual(expectedActions); }); }); + describe('#createMetaMetricsDataDeletionTask', () => { + afterEach(() => { + sinon.restore(); + }); + + it('calls createMetaMetricsDataDeletionTask in background', async () => { + const createMetaMetricsDataDeletionTaskStub = sinon + .stub() + .callsFake((cb) => cb()); + background.getApi.returns({ + createMetaMetricsDataDeletionTask: + createMetaMetricsDataDeletionTaskStub, + }); + + setBackgroundConnection(background.getApi()); + + await actions.createMetaMetricsDataDeletionTask(); + expect(createMetaMetricsDataDeletionTaskStub.callCount).toStrictEqual(1); + }); + }); + describe('#updateDataDeletionTaskStatus', () => { + afterEach(() => { + sinon.restore(); + }); + + it('calls updateDataDeletionTaskStatus in background', async () => { + const updateDataDeletionTaskStatusStub = sinon + .stub() + .callsFake((cb) => cb()); + background.getApi.returns({ + updateDataDeletionTaskStatus: updateDataDeletionTaskStatusStub, + }); + + setBackgroundConnection(background.getApi()); + + await actions.updateDataDeletionTaskStatus(); + expect(updateDataDeletionTaskStatusStub.callCount).toStrictEqual(1); + }); + }); }); diff --git a/ui/store/actions.ts b/ui/store/actions.ts index 0c6f61654d09..499acba711bd 100644 --- a/ui/store/actions.ts +++ b/ui/store/actions.ts @@ -5087,6 +5087,20 @@ export function setName( }) as any; } +/** + * To create a data deletion regulation for MetaMetrics data deletion + */ +export async function createMetaMetricsDataDeletionTask() { + return await submitRequestToBackground('createMetaMetricsDataDeletionTask'); +} + +/** + * To check the status of the current delete regulation. + */ +export async function updateDataDeletionTaskStatus() { + return await submitRequestToBackground('updateDataDeletionTaskStatus'); +} + /** * Throw an error in the background for testing purposes. * diff --git a/yarn.lock b/yarn.lock index 57364033473e..07f23e959624 100644 --- a/yarn.lock +++ b/yarn.lock @@ -26145,6 +26145,7 @@ __metadata: chart.js: "npm:^4.4.1" chokidar: "npm:^3.6.0" classnames: "npm:^2.2.6" + cockatiel: "npm:^3.1.2" concurrently: "npm:^8.2.2" copy-to-clipboard: "npm:^3.3.3" copy-webpack-plugin: "npm:^12.0.2"