From bc4da885403690e447e0986935bd8be8da4c9ea5 Mon Sep 17 00:00:00 2001 From: Nicolas MASSART Date: Thu, 15 Jan 2026 14:21:42 +0100 Subject: [PATCH 01/12] feat: initial analytics privacy controller --- README.md | 4 + .../analytics-privacy-controller/CHANGELOG.md | 10 + packages/analytics-privacy-controller/LICENSE | 20 + .../analytics-privacy-controller/README.md | 15 + .../jest.config.js | 26 + .../analytics-privacy-controller/package.json | 77 ++ ...csPrivacyController-method-action-types.ts | 80 ++ .../src/AnalyticsPrivacyController.test.ts | 927 ++++++++++++++++++ .../src/AnalyticsPrivacyController.ts | 376 +++++++ .../src/AnalyticsPrivacyLogger.ts | 7 + ...yticsPrivacyService-method-action-types.ts | 35 + .../src/AnalyticsPrivacyService.test.ts | 564 +++++++++++ .../src/AnalyticsPrivacyService.ts | 391 ++++++++ .../src/constants.ts | 23 + .../analytics-privacy-controller/src/index.ts | 31 + .../src/selectors.test.ts | 79 ++ .../src/selectors.ts | 41 + .../analytics-privacy-controller/src/types.ts | 57 ++ .../tsconfig.build.json | 15 + .../tsconfig.json | 13 + .../analytics-privacy-controller/typedoc.json | 7 + tsconfig.build.json | 3 + tsconfig.json | 3 + yarn.lock | 78 +- 24 files changed, 2879 insertions(+), 3 deletions(-) create mode 100644 packages/analytics-privacy-controller/CHANGELOG.md create mode 100644 packages/analytics-privacy-controller/LICENSE create mode 100644 packages/analytics-privacy-controller/README.md create mode 100644 packages/analytics-privacy-controller/jest.config.js create mode 100644 packages/analytics-privacy-controller/package.json create mode 100644 packages/analytics-privacy-controller/src/AnalyticsPrivacyController-method-action-types.ts create mode 100644 packages/analytics-privacy-controller/src/AnalyticsPrivacyController.test.ts create mode 100644 packages/analytics-privacy-controller/src/AnalyticsPrivacyController.ts create mode 100644 packages/analytics-privacy-controller/src/AnalyticsPrivacyLogger.ts create mode 100644 packages/analytics-privacy-controller/src/AnalyticsPrivacyService-method-action-types.ts create mode 100644 packages/analytics-privacy-controller/src/AnalyticsPrivacyService.test.ts create mode 100644 packages/analytics-privacy-controller/src/AnalyticsPrivacyService.ts create mode 100644 packages/analytics-privacy-controller/src/constants.ts create mode 100644 packages/analytics-privacy-controller/src/index.ts create mode 100644 packages/analytics-privacy-controller/src/selectors.test.ts create mode 100644 packages/analytics-privacy-controller/src/selectors.ts create mode 100644 packages/analytics-privacy-controller/src/types.ts create mode 100644 packages/analytics-privacy-controller/tsconfig.build.json create mode 100644 packages/analytics-privacy-controller/tsconfig.json create mode 100644 packages/analytics-privacy-controller/typedoc.json diff --git a/README.md b/README.md index 92d9c369396..56111dfbe7a 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ Each package in this repository has its own README where you can find installati - [`@metamask/accounts-controller`](packages/accounts-controller) - [`@metamask/address-book-controller`](packages/address-book-controller) - [`@metamask/analytics-controller`](packages/analytics-controller) +- [`@metamask/analytics-privacy-controller`](packages/analytics-privacy-controller) - [`@metamask/announcement-controller`](packages/announcement-controller) - [`@metamask/app-metadata-controller`](packages/app-metadata-controller) - [`@metamask/approval-controller`](packages/approval-controller) @@ -99,6 +100,7 @@ linkStyle default opacity:0.5 accounts_controller(["@metamask/accounts-controller"]); address_book_controller(["@metamask/address-book-controller"]); analytics_controller(["@metamask/analytics-controller"]); + analytics_privacy_controller(["@metamask/analytics-privacy-controller"]); announcement_controller(["@metamask/announcement-controller"]); app_metadata_controller(["@metamask/app-metadata-controller"]); approval_controller(["@metamask/approval-controller"]); @@ -183,6 +185,8 @@ linkStyle default opacity:0.5 app_metadata_controller --> messenger; approval_controller --> base_controller; approval_controller --> messenger; + assets_controller --> base_controller; + assets_controller --> messenger; assets_controllers --> account_tree_controller; assets_controllers --> accounts_controller; assets_controllers --> approval_controller; diff --git a/packages/analytics-privacy-controller/CHANGELOG.md b/packages/analytics-privacy-controller/CHANGELOG.md new file mode 100644 index 00000000000..b518709c7b8 --- /dev/null +++ b/packages/analytics-privacy-controller/CHANGELOG.md @@ -0,0 +1,10 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +[Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/analytics-privacy-controller/LICENSE b/packages/analytics-privacy-controller/LICENSE new file mode 100644 index 00000000000..c8a0ff6be3a --- /dev/null +++ b/packages/analytics-privacy-controller/LICENSE @@ -0,0 +1,20 @@ +MIT License + +Copyright (c) 2026 MetaMask + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE diff --git a/packages/analytics-privacy-controller/README.md b/packages/analytics-privacy-controller/README.md new file mode 100644 index 00000000000..0dbd01172e4 --- /dev/null +++ b/packages/analytics-privacy-controller/README.md @@ -0,0 +1,15 @@ +# `@metamask/analytics-privacy-controller` + +Controller for managing analytics privacy and GDPR/CCPA data deletion functionality + +## Installation + +`yarn add @metamask/analytics-privacy-controller` + +or + +`npm install @metamask/analytics-privacy-controller` + +## Contributing + +This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/core#readme). diff --git a/packages/analytics-privacy-controller/jest.config.js b/packages/analytics-privacy-controller/jest.config.js new file mode 100644 index 00000000000..ca084133399 --- /dev/null +++ b/packages/analytics-privacy-controller/jest.config.js @@ -0,0 +1,26 @@ +/* + * For a detailed explanation regarding each configuration property and type check, visit: + * https://jestjs.io/docs/configuration + */ + +const merge = require('deepmerge'); +const path = require('path'); + +const baseConfig = require('../../jest.config.packages'); + +const displayName = path.basename(__dirname); + +module.exports = merge(baseConfig, { + // The display name when running multiple projects + displayName, + + // An object that configures minimum threshold enforcement for coverage results + coverageThreshold: { + global: { + branches: 100, + functions: 100, + lines: 100, + statements: 100, + }, + }, +}); diff --git a/packages/analytics-privacy-controller/package.json b/packages/analytics-privacy-controller/package.json new file mode 100644 index 00000000000..76a28528125 --- /dev/null +++ b/packages/analytics-privacy-controller/package.json @@ -0,0 +1,77 @@ +{ + "name": "@metamask/analytics-privacy-controller", + "version": "0.0.0", + "description": "Controller for managing analytics privacy and GDPR/CCPA data deletion functionality", + "keywords": [ + "MetaMask", + "Ethereum" + ], + "homepage": "https://github.com/MetaMask/core/tree/main/packages/analytics-privacy-controller#readme", + "bugs": { + "url": "https://github.com/MetaMask/core/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/core.git" + }, + "license": "MIT", + "sideEffects": false, + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", + "files": [ + "dist/" + ], + "scripts": { + "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references", + "build:all": "ts-bridge --project tsconfig.build.json --verbose --clean", + "build:docs": "typedoc", + "changelog:update": "../../scripts/update-changelog.sh @metamask/analytics-privacy-controller", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/analytics-privacy-controller", + "publish:preview": "yarn npm publish --tag preview", + "since-latest-release": "../../scripts/since-latest-release.sh", + "test": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter", + "test:clean": "NODE_OPTIONS=--experimental-vm-modules jest --clearCache", + "test:verbose": "NODE_OPTIONS=--experimental-vm-modules jest --verbose", + "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" + }, + "dependencies": { + "@metamask/analytics-controller": "^1.0.0", + "@metamask/base-controller": "^9.0.0", + "@metamask/controller-utils": "^7.0.0", + "@metamask/messenger": "^0.3.0", + "@metamask/utils": "^11.9.0" + }, + "devDependencies": { + "@metamask/auto-changelog": "^3.4.4", + "@ts-bridge/cli": "^0.6.4", + "@types/jest": "^27.4.1", + "deepmerge": "^4.2.2", + "jest": "^27.5.1", + "nock": "^13.3.1", + "sinon": "^9.2.4", + "ts-jest": "^27.1.4", + "typedoc": "^0.24.8", + "typedoc-plugin-missing-exports": "^2.0.0", + "typescript": "~5.3.3" + }, + "engines": { + "node": "^18.18 || >=20" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + } +} diff --git a/packages/analytics-privacy-controller/src/AnalyticsPrivacyController-method-action-types.ts b/packages/analytics-privacy-controller/src/AnalyticsPrivacyController-method-action-types.ts new file mode 100644 index 00000000000..ea4aabe7dbd --- /dev/null +++ b/packages/analytics-privacy-controller/src/AnalyticsPrivacyController-method-action-types.ts @@ -0,0 +1,80 @@ +/** + * This file is auto generated by `scripts/generate-method-action-types.ts`. + * Do not edit manually. + */ + +import type { AnalyticsPrivacyController } from './AnalyticsPrivacyController'; + +/** + * Creates a new delete regulation for the user. + * This is necessary to respect the GDPR and CCPA regulations. + * + * @returns Promise containing the status of the request + */ +export type AnalyticsPrivacyControllerCreateDataDeletionTaskAction = { + type: `AnalyticsPrivacyController:createDataDeletionTask`; + handler: AnalyticsPrivacyController['createDataDeletionTask']; +}; + +/** + * Check the latest delete regulation status. + * + * @returns Promise containing the date, delete status and collected data flag + */ +export type AnalyticsPrivacyControllerCheckDataDeleteStatusAction = { + type: `AnalyticsPrivacyController:checkDataDeleteStatus`; + handler: AnalyticsPrivacyController['checkDataDeleteStatus']; +}; + +/** + * Get the latest delete regulation request date. + * + * @returns The date as a DD/MM/YYYY string, or undefined + */ +export type AnalyticsPrivacyControllerGetDeleteRegulationCreationDateAction = { + type: `AnalyticsPrivacyController:getDeleteRegulationCreationDate`; + handler: AnalyticsPrivacyController['getDeleteRegulationCreationDate']; +}; + +/** + * Get the latest delete regulation request id. + * + * @returns The id string, or undefined + */ +export type AnalyticsPrivacyControllerGetDeleteRegulationIdAction = { + type: `AnalyticsPrivacyController:getDeleteRegulationId`; + handler: AnalyticsPrivacyController['getDeleteRegulationId']; +}; + +/** + * Indicate if events have been recorded since the last deletion request. + * + * @returns true if events have been recorded since the last deletion request + */ +export type AnalyticsPrivacyControllerIsDataRecordedAction = { + type: `AnalyticsPrivacyController:isDataRecorded`; + handler: AnalyticsPrivacyController['isDataRecorded']; +}; + +/** + * Update the data recording flag if needed. + * This method should be called after tracking events to ensure + * the data recording flag is properly updated for data deletion workflows. + * + * @param saveDataRecording - Whether to save the data recording flag (default: true) + */ +export type AnalyticsPrivacyControllerUpdateDataRecordingFlagAction = { + type: `AnalyticsPrivacyController:updateDataRecordingFlag`; + handler: AnalyticsPrivacyController['updateDataRecordingFlag']; +}; + +/** + * Union of all AnalyticsPrivacyController action types. + */ +export type AnalyticsPrivacyControllerMethodActions = + | AnalyticsPrivacyControllerCreateDataDeletionTaskAction + | AnalyticsPrivacyControllerCheckDataDeleteStatusAction + | AnalyticsPrivacyControllerGetDeleteRegulationCreationDateAction + | AnalyticsPrivacyControllerGetDeleteRegulationIdAction + | AnalyticsPrivacyControllerIsDataRecordedAction + | AnalyticsPrivacyControllerUpdateDataRecordingFlagAction; diff --git a/packages/analytics-privacy-controller/src/AnalyticsPrivacyController.test.ts b/packages/analytics-privacy-controller/src/AnalyticsPrivacyController.test.ts new file mode 100644 index 00000000000..b81d41c27aa --- /dev/null +++ b/packages/analytics-privacy-controller/src/AnalyticsPrivacyController.test.ts @@ -0,0 +1,927 @@ +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { MockAnyNamespace } from '@metamask/messenger'; + +import { + AnalyticsPrivacyController, + getDefaultAnalyticsPrivacyControllerState, +} from '.'; +import type { + AnalyticsPrivacyControllerMessenger, + AnalyticsPrivacyControllerActions, + AnalyticsPrivacyControllerEvents, + AnalyticsPrivacyControllerState, +} from '.'; +import type { AnalyticsPrivacyServiceActions } from './AnalyticsPrivacyService'; +import type { AnalyticsControllerState } from '@metamask/analytics-controller'; +import { DataDeleteResponseStatus, DataDeleteStatus } from './types'; + +type SetupControllerOptions = { + state?: Partial; +}; + +type SetupControllerReturn = { + controller: AnalyticsPrivacyController; + messenger: AnalyticsPrivacyControllerMessenger; + rootMessenger: Messenger< + MockAnyNamespace, + AnalyticsPrivacyControllerActions | AnalyticsPrivacyServiceActions | { type: 'AnalyticsController:getState'; handler: () => { analyticsId: string; optedIn: boolean } }, + AnalyticsPrivacyControllerEvents + >; +}; + +/** + * Sets up an AnalyticsPrivacyController for testing. + * + * @param options - Controller options + * @param options.state - Optional partial controller state + * @returns The controller, messenger, and root messenger + */ +function setupController( + options: SetupControllerOptions = {}, +): SetupControllerReturn { + const { state = {} } = options; + + const rootMessenger = new Messenger< + MockAnyNamespace, + AnalyticsPrivacyControllerActions | AnalyticsPrivacyServiceActions | { type: 'AnalyticsController:getState'; handler: () => { analyticsId: string; optedIn: boolean } }, + AnalyticsPrivacyControllerEvents + >({ namespace: MOCK_ANY_NAMESPACE }); + + const analyticsPrivacyControllerMessenger = new Messenger< + 'AnalyticsPrivacyController', + AnalyticsPrivacyControllerActions | AnalyticsPrivacyServiceActions | { type: 'AnalyticsController:getState'; handler: () => { analyticsId: string; optedIn: boolean } }, + AnalyticsPrivacyControllerEvents, + typeof rootMessenger + >({ + namespace: 'AnalyticsPrivacyController', + parent: rootMessenger, + }); + + // Mock AnalyticsController:getState action + rootMessenger.registerActionHandler('AnalyticsController:getState', () => ({ + analyticsId: 'test-analytics-id', + optedIn: true, + })); + + // Mock AnalyticsPrivacyService actions (can be overridden in individual tests) + rootMessenger.registerActionHandler( + 'AnalyticsPrivacyService:createDataDeletionTask', + jest.fn().mockResolvedValue({ + status: DataDeleteResponseStatus.ok, + regulateId: 'test-regulate-id', + }), + ); + + rootMessenger.registerActionHandler( + 'AnalyticsPrivacyService:checkDataDeleteStatus', + jest.fn().mockResolvedValue({ + status: DataDeleteResponseStatus.ok, + dataDeleteStatus: DataDeleteStatus.finished, + }), + ); + + // Delegate service actions and AnalyticsController actions to controller messenger + rootMessenger.delegate({ + messenger: analyticsPrivacyControllerMessenger, + actions: [ + 'AnalyticsPrivacyService:createDataDeletionTask', + 'AnalyticsPrivacyService:checkDataDeleteStatus', + 'AnalyticsController:getState', + ], + }); + + const controller = new AnalyticsPrivacyController({ + messenger: analyticsPrivacyControllerMessenger, + state, + }); + + return { + controller, + messenger: analyticsPrivacyControllerMessenger, + rootMessenger, + }; +} + +describe('AnalyticsPrivacyController', () => { + describe('getDefaultAnalyticsPrivacyControllerState', () => { + it('returns default state with all fields undefined/false', () => { + const defaults = getDefaultAnalyticsPrivacyControllerState(); + + expect(defaults).toStrictEqual({ + dataRecorded: false, + deleteRegulationId: null, + deleteRegulationDate: null, + }); + }); + + it('returns the same values on each call (deterministic)', () => { + const defaults1 = getDefaultAnalyticsPrivacyControllerState(); + const defaults2 = getDefaultAnalyticsPrivacyControllerState(); + + expect(defaults1).toStrictEqual(defaults2); + }); + }); + + describe('constructor', () => { + it('initializes with default state when no state provided', () => { + const { controller } = setupController(); + + expect(controller.state).toStrictEqual( + getDefaultAnalyticsPrivacyControllerState(), + ); + }); + + it('initializes with provided state', () => { + const initialState = { + dataRecorded: true, + deleteRegulationId: 'existing-id', + deleteRegulationDate: '01/01/2024', + }; + + const { controller } = setupController({ state: initialState }); + + expect(controller.state).toStrictEqual(initialState); + }); + + it('merges provided state with defaults', () => { + const partialState = { + dataRecorded: true, + }; + + const { controller } = setupController({ state: partialState }); + + expect(controller.state.dataRecorded).toBe(true); + expect(controller.state.deleteRegulationId).toBeNull(); + expect(controller.state.deleteRegulationDate).toBeNull(); + }); + }); + + describe('AnalyticsPrivacyController:createDataDeletionTask', () => { + it('creates a data deletion task and updates state', async () => { + const { controller, rootMessenger } = setupController(); + + const response = await rootMessenger.call( + 'AnalyticsPrivacyController:createDataDeletionTask', + ); + + expect(response.status).toBe(DataDeleteResponseStatus.ok); + expect(response.regulateId).toBe('test-regulate-id'); + expect(controller.state.deleteRegulationId).toBe('test-regulate-id'); + expect(controller.state.deleteRegulationDate).toMatch( + /^\d{1,2}\/\d{1,2}\/\d{4}$/, + ); + expect(controller.state.dataRecorded).toBe(false); + }); + + it('formats deletion date in DD/MM/YYYY format', async () => { + const rootMessenger = new Messenger< + MockAnyNamespace, + AnalyticsPrivacyControllerActions | AnalyticsPrivacyServiceActions | { type: 'AnalyticsController:getState'; handler: () => { analyticsId: string; optedIn: boolean } }, + AnalyticsPrivacyControllerEvents + >({ namespace: MOCK_ANY_NAMESPACE }); + + const analyticsPrivacyControllerMessenger = new Messenger< + 'AnalyticsPrivacyController', + AnalyticsPrivacyControllerActions | AnalyticsPrivacyServiceActions | { type: 'AnalyticsController:getState'; handler: () => { analyticsId: string; optedIn: boolean } }, + AnalyticsPrivacyControllerEvents, + typeof rootMessenger + >({ + namespace: 'AnalyticsPrivacyController', + parent: rootMessenger, + }); + + rootMessenger.registerActionHandler('AnalyticsController:getState', () => ({ + analyticsId: 'test-analytics-id', + optedIn: true, + })); + + rootMessenger.registerActionHandler( + 'AnalyticsPrivacyService:createDataDeletionTask', + jest.fn().mockResolvedValue({ + status: DataDeleteResponseStatus.ok, + regulateId: 'test-regulate-id', + }), + ); + + rootMessenger.delegate({ + messenger: analyticsPrivacyControllerMessenger, + actions: [ + 'AnalyticsPrivacyService:createDataDeletionTask', + 'AnalyticsController:getState', + ], + }); + + const controller = new AnalyticsPrivacyController({ + messenger: analyticsPrivacyControllerMessenger, + }); + + const fixedDate = new Date('2024-01-15T12:00:00Z'); + jest.useFakeTimers(); + jest.setSystemTime(fixedDate); + + await rootMessenger.call( + 'AnalyticsPrivacyController:createDataDeletionTask', + ); + + // Note: getUTCDate() returns 15, getUTCMonth() returns 0 (January), so +1 = 1 + expect(controller.state.deleteRegulationDate).toBe('15/01/2024'); + + jest.useRealTimers(); + }); + + it('emits dataDeletionTaskCreated event', async () => { + const { rootMessenger, messenger } = setupController(); + const eventListener = jest.fn(); + + messenger.subscribe( + 'AnalyticsPrivacyController:dataDeletionTaskCreated', + eventListener, + ); + + const response = await rootMessenger.call( + 'AnalyticsPrivacyController:createDataDeletionTask', + ); + + // Verify the response is correct first + expect(response.status).toBe(DataDeleteResponseStatus.ok); + expect(response.regulateId).toBe('test-regulate-id'); + + // Then verify the event was emitted + expect(eventListener).toHaveBeenCalledWith({ + status: DataDeleteResponseStatus.ok, + regulateId: 'test-regulate-id', + }); + }); + + it('returns error if analyticsId is missing from AnalyticsController state', async () => { + const rootMessenger = new Messenger< + MockAnyNamespace, + AnalyticsPrivacyControllerActions | AnalyticsPrivacyServiceActions | { type: 'AnalyticsController:getState'; handler: () => AnalyticsControllerState }, + AnalyticsPrivacyControllerEvents + >({ namespace: MOCK_ANY_NAMESPACE }); + + const analyticsPrivacyControllerMessenger = new Messenger< + 'AnalyticsPrivacyController', + AnalyticsPrivacyControllerActions | AnalyticsPrivacyServiceActions | { type: 'AnalyticsController:getState'; handler: () => AnalyticsControllerState }, + AnalyticsPrivacyControllerEvents, + typeof rootMessenger + >({ + namespace: 'AnalyticsPrivacyController', + parent: rootMessenger, + }); + + rootMessenger.registerActionHandler('AnalyticsController:getState', () => ({ + analyticsId: '', // Empty string to test the !analyticsId check + optedIn: true, + })); + + rootMessenger.registerActionHandler( + 'AnalyticsPrivacyService:createDataDeletionTask', + jest.fn().mockResolvedValue({ + status: DataDeleteResponseStatus.ok, + regulateId: 'test-regulate-id', + }), + ); + + rootMessenger.delegate({ + messenger: analyticsPrivacyControllerMessenger, + actions: [ + 'AnalyticsPrivacyService:createDataDeletionTask', + 'AnalyticsController:getState', + ], + }); + + const controller = new AnalyticsPrivacyController({ + messenger: analyticsPrivacyControllerMessenger, + }); + + const response = await rootMessenger.call( + 'AnalyticsPrivacyController:createDataDeletionTask', + ); + + expect(response.status).toBe(DataDeleteResponseStatus.error); + expect(response.error).toBe('Analytics ID not found'); + }); + + it('handles service response with undefined regulateId', async () => { + const rootMessenger = new Messenger< + MockAnyNamespace, + AnalyticsPrivacyControllerActions | AnalyticsPrivacyServiceActions | { type: 'AnalyticsController:getState'; handler: () => AnalyticsControllerState }, + AnalyticsPrivacyControllerEvents + >({ namespace: MOCK_ANY_NAMESPACE }); + + const analyticsPrivacyControllerMessenger = new Messenger< + 'AnalyticsPrivacyController', + AnalyticsPrivacyControllerActions | AnalyticsPrivacyServiceActions | { type: 'AnalyticsController:getState'; handler: () => AnalyticsControllerState }, + AnalyticsPrivacyControllerEvents, + typeof rootMessenger + >({ + namespace: 'AnalyticsPrivacyController', + parent: rootMessenger, + }); + + rootMessenger.registerActionHandler('AnalyticsController:getState', () => ({ + analyticsId: 'test-analytics-id', + optedIn: true, + })); + + rootMessenger.registerActionHandler( + 'AnalyticsPrivacyService:createDataDeletionTask', + jest.fn().mockResolvedValue({ + status: DataDeleteResponseStatus.ok, + // regulateId is undefined + }), + ); + + rootMessenger.delegate({ + messenger: analyticsPrivacyControllerMessenger, + actions: [ + 'AnalyticsPrivacyService:createDataDeletionTask', + 'AnalyticsController:getState', + ], + }); + + const controller = new AnalyticsPrivacyController({ + messenger: analyticsPrivacyControllerMessenger, + }); + + const response = await rootMessenger.call( + 'AnalyticsPrivacyController:createDataDeletionTask', + ); + + expect(response.status).toBe(DataDeleteResponseStatus.ok); + expect(response.regulateId).toBeUndefined(); + // State should not be updated when regulateId is missing (condition fails) + expect(controller.state.deleteRegulationId).toBeNull(); + }); + + it('handles empty string regulateId (falsy but not null/undefined)', async () => { + const rootMessenger = new Messenger< + MockAnyNamespace, + AnalyticsPrivacyControllerActions | AnalyticsPrivacyServiceActions | { type: 'AnalyticsController:getState'; handler: () => AnalyticsControllerState }, + AnalyticsPrivacyControllerEvents + >({ namespace: MOCK_ANY_NAMESPACE }); + + const analyticsPrivacyControllerMessenger = new Messenger< + 'AnalyticsPrivacyController', + AnalyticsPrivacyControllerActions | AnalyticsPrivacyServiceActions | { type: 'AnalyticsController:getState'; handler: () => AnalyticsControllerState }, + AnalyticsPrivacyControllerEvents, + typeof rootMessenger + >({ + namespace: 'AnalyticsPrivacyController', + parent: rootMessenger, + }); + + rootMessenger.registerActionHandler('AnalyticsController:getState', () => ({ + analyticsId: 'test-analytics-id', + optedIn: true, + })); + + // Empty string is falsy, so condition fails and we don't enter the block + // But this tests the edge case + rootMessenger.registerActionHandler( + 'AnalyticsPrivacyService:createDataDeletionTask', + jest.fn().mockResolvedValue({ + status: DataDeleteResponseStatus.ok, + regulateId: '', // Empty string is falsy + }), + ); + + rootMessenger.delegate({ + messenger: analyticsPrivacyControllerMessenger, + actions: [ + 'AnalyticsPrivacyService:createDataDeletionTask', + 'AnalyticsController:getState', + ], + }); + + const controller = new AnalyticsPrivacyController({ + messenger: analyticsPrivacyControllerMessenger, + }); + + const response = await rootMessenger.call( + 'AnalyticsPrivacyController:createDataDeletionTask', + ); + + expect(response.status).toBe(DataDeleteResponseStatus.ok); + // Empty string is falsy, so condition fails and state is not updated + expect(controller.state.deleteRegulationId).toBeNull(); + }); + + it('handles null deleteRegulationDate in status', async () => { + const rootMessenger = new Messenger< + MockAnyNamespace, + AnalyticsPrivacyControllerActions | AnalyticsPrivacyServiceActions | { type: 'AnalyticsController:getState'; handler: () => AnalyticsControllerState }, + AnalyticsPrivacyControllerEvents + >({ namespace: MOCK_ANY_NAMESPACE }); + + const analyticsPrivacyControllerMessenger = new Messenger< + 'AnalyticsPrivacyController', + AnalyticsPrivacyControllerActions | AnalyticsPrivacyServiceActions | { type: 'AnalyticsController:getState'; handler: () => AnalyticsControllerState }, + AnalyticsPrivacyControllerEvents, + typeof rootMessenger + >({ + namespace: 'AnalyticsPrivacyController', + parent: rootMessenger, + }); + + rootMessenger.registerActionHandler('AnalyticsController:getState', () => ({ + analyticsId: 'test-analytics-id', + optedIn: true, + })); + + // Mock a response where regulateId is explicitly undefined (to test ?? null) + rootMessenger.registerActionHandler( + 'AnalyticsPrivacyService:createDataDeletionTask', + jest.fn().mockResolvedValue({ + status: DataDeleteResponseStatus.ok, + regulateId: undefined as string | undefined, + }), + ); + + rootMessenger.delegate({ + messenger: analyticsPrivacyControllerMessenger, + actions: [ + 'AnalyticsPrivacyService:createDataDeletionTask', + 'AnalyticsController:getState', + ], + }); + + const controller = new AnalyticsPrivacyController({ + messenger: analyticsPrivacyControllerMessenger, + }); + + const response = await rootMessenger.call( + 'AnalyticsPrivacyController:createDataDeletionTask', + ); + + expect(response.status).toBe(DataDeleteResponseStatus.ok); + // When regulateId is undefined, the condition fails, so state is not updated + expect(controller.state.deleteRegulationId).toBeNull(); + }); + + it('returns error if AnalyticsController:getState fails', async () => { + const rootMessenger = new Messenger< + MockAnyNamespace, + AnalyticsPrivacyControllerActions | AnalyticsPrivacyServiceActions | { type: 'AnalyticsController:getState'; handler: () => { analyticsId: string; optedIn: boolean } }, + AnalyticsPrivacyControllerEvents + >({ namespace: MOCK_ANY_NAMESPACE }); + + const analyticsPrivacyControllerMessenger = new Messenger< + 'AnalyticsPrivacyController', + AnalyticsPrivacyControllerActions | AnalyticsPrivacyServiceActions | { type: 'AnalyticsController:getState'; handler: () => { analyticsId: string; optedIn: boolean } }, + AnalyticsPrivacyControllerEvents, + typeof rootMessenger + >({ + namespace: 'AnalyticsPrivacyController', + parent: rootMessenger, + }); + + rootMessenger.registerActionHandler('AnalyticsController:getState', () => { + throw new Error('Analytics ID not found'); + }); + + rootMessenger.registerActionHandler( + 'AnalyticsPrivacyService:createDataDeletionTask', + jest.fn().mockResolvedValue({ + status: DataDeleteResponseStatus.ok, + regulateId: 'test-regulate-id', + }), + ); + + const controller = new AnalyticsPrivacyController({ + messenger: analyticsPrivacyControllerMessenger, + }); + + const response = await rootMessenger.call( + 'AnalyticsPrivacyController:createDataDeletionTask', + ); + + expect(response.status).toBe(DataDeleteResponseStatus.error); + expect(response.error).toBe('Analytics Deletion Task Error'); + + }); + + it('returns error if service call fails', async () => { + const rootMessenger = new Messenger< + MockAnyNamespace, + AnalyticsPrivacyControllerActions | AnalyticsPrivacyServiceActions | { type: 'AnalyticsController:getState'; handler: () => { analyticsId: string; optedIn: boolean } }, + AnalyticsPrivacyControllerEvents + >({ namespace: MOCK_ANY_NAMESPACE }); + + const analyticsPrivacyControllerMessenger = new Messenger< + 'AnalyticsPrivacyController', + AnalyticsPrivacyControllerActions | AnalyticsPrivacyServiceActions | { type: 'AnalyticsController:getState'; handler: () => { analyticsId: string; optedIn: boolean } }, + AnalyticsPrivacyControllerEvents, + typeof rootMessenger + >({ + namespace: 'AnalyticsPrivacyController', + parent: rootMessenger, + }); + + rootMessenger.registerActionHandler('AnalyticsController:getState', () => ({ + analyticsId: 'test-analytics-id', + optedIn: true, + })); + + rootMessenger.registerActionHandler( + 'AnalyticsPrivacyService:createDataDeletionTask', + jest.fn().mockResolvedValue({ + status: DataDeleteResponseStatus.error, + error: 'Service error', + }), + ); + + const controller = new AnalyticsPrivacyController({ + messenger: analyticsPrivacyControllerMessenger, + }); + + const response = await rootMessenger.call( + 'AnalyticsPrivacyController:createDataDeletionTask', + ); + + expect(response.status).toBe(DataDeleteResponseStatus.error); + expect(response.error).toBe('Analytics Deletion Task Error'); + }); + + it('does not update state if service returns error', async () => { + const rootMessenger = new Messenger< + MockAnyNamespace, + AnalyticsPrivacyControllerActions | AnalyticsPrivacyServiceActions | { type: 'AnalyticsController:getState'; handler: () => { analyticsId: string; optedIn: boolean } }, + AnalyticsPrivacyControllerEvents + >({ namespace: MOCK_ANY_NAMESPACE }); + + const analyticsPrivacyControllerMessenger = new Messenger< + 'AnalyticsPrivacyController', + AnalyticsPrivacyControllerActions | AnalyticsPrivacyServiceActions | { type: 'AnalyticsController:getState'; handler: () => { analyticsId: string; optedIn: boolean } }, + AnalyticsPrivacyControllerEvents, + typeof rootMessenger + >({ + namespace: 'AnalyticsPrivacyController', + parent: rootMessenger, + }); + + rootMessenger.registerActionHandler('AnalyticsController:getState', () => ({ + analyticsId: 'test-analytics-id', + optedIn: true, + })); + + rootMessenger.registerActionHandler( + 'AnalyticsPrivacyService:createDataDeletionTask', + jest.fn().mockResolvedValue({ + status: DataDeleteResponseStatus.error, + error: 'Service error', + }), + ); + + const controller = new AnalyticsPrivacyController({ + messenger: analyticsPrivacyControllerMessenger, + }); + const initialState = controller.state; + + await rootMessenger.call( + 'AnalyticsPrivacyController:createDataDeletionTask', + ); + + expect(controller.state).toStrictEqual(initialState); + }); + }); + + describe('AnalyticsPrivacyController:checkDataDeleteStatus', () => { + it('returns status with all fields when regulationId exists', async () => { + const { controller, rootMessenger } = setupController({ + state: { + deleteRegulationId: 'test-regulation-id', + deleteRegulationDate: '15/01/2024', + dataRecorded: true, + }, + }); + + const status = await rootMessenger.call( + 'AnalyticsPrivacyController:checkDataDeleteStatus', + ); + + expect(status).toStrictEqual({ + deletionRequestDate: '15/01/2024', + dataDeletionRequestStatus: DataDeleteStatus.finished, + hasCollectedDataSinceDeletionRequest: true, + }); + }); + + it('returns unknown status when regulationId is missing', async () => { + const { rootMessenger } = setupController(); + + const status = await rootMessenger.call( + 'AnalyticsPrivacyController:checkDataDeleteStatus', + ); + + expect(status).toStrictEqual({ + deletionRequestDate: undefined, + dataDeletionRequestStatus: DataDeleteStatus.unknown, + hasCollectedDataSinceDeletionRequest: false, + }); + }); + + it('handles null deleteRegulationDate in status', async () => { + const rootMessenger = new Messenger< + MockAnyNamespace, + AnalyticsPrivacyControllerActions | AnalyticsPrivacyServiceActions | { type: 'AnalyticsController:getState'; handler: () => AnalyticsControllerState }, + AnalyticsPrivacyControllerEvents + >({ namespace: MOCK_ANY_NAMESPACE }); + + const analyticsPrivacyControllerMessenger = new Messenger< + 'AnalyticsPrivacyController', + AnalyticsPrivacyControllerActions | AnalyticsPrivacyServiceActions | { type: 'AnalyticsController:getState'; handler: () => AnalyticsControllerState }, + AnalyticsPrivacyControllerEvents, + typeof rootMessenger + >({ + namespace: 'AnalyticsPrivacyController', + parent: rootMessenger, + }); + + rootMessenger.registerActionHandler('AnalyticsController:getState', () => ({ + analyticsId: 'test-analytics-id', + optedIn: true, + })); + + rootMessenger.registerActionHandler( + 'AnalyticsPrivacyService:checkDataDeleteStatus', + jest.fn().mockResolvedValue({ + status: DataDeleteResponseStatus.ok, + dataDeleteStatus: DataDeleteStatus.finished, + }), + ); + + rootMessenger.delegate({ + messenger: analyticsPrivacyControllerMessenger, + actions: [ + 'AnalyticsPrivacyService:checkDataDeleteStatus', + 'AnalyticsController:getState', + ], + }); + + const controller = new AnalyticsPrivacyController({ + messenger: analyticsPrivacyControllerMessenger, + state: { + deleteRegulationId: 'test-regulation-id', + deleteRegulationDate: null, // null date + dataRecorded: false, + }, + }); + + const status = await rootMessenger.call( + 'AnalyticsPrivacyController:checkDataDeleteStatus', + ); + + expect(status.deletionRequestDate).toBeUndefined(); + expect(status.dataDeletionRequestStatus).toBe(DataDeleteStatus.finished); + }); + + it('handles service errors gracefully', async () => { + const rootMessenger = new Messenger< + MockAnyNamespace, + AnalyticsPrivacyControllerActions | AnalyticsPrivacyServiceActions | { type: 'AnalyticsController:getState'; handler: () => { analyticsId: string; optedIn: boolean } }, + AnalyticsPrivacyControllerEvents + >({ namespace: MOCK_ANY_NAMESPACE }); + + const analyticsPrivacyControllerMessenger = new Messenger< + 'AnalyticsPrivacyController', + AnalyticsPrivacyControllerActions | AnalyticsPrivacyServiceActions | { type: 'AnalyticsController:getState'; handler: () => { analyticsId: string; optedIn: boolean } }, + AnalyticsPrivacyControllerEvents, + typeof rootMessenger + >({ + namespace: 'AnalyticsPrivacyController', + parent: rootMessenger, + }); + + rootMessenger.registerActionHandler('AnalyticsController:getState', () => ({ + analyticsId: 'test-analytics-id', + optedIn: true, + })); + + rootMessenger.registerActionHandler( + 'AnalyticsPrivacyService:checkDataDeleteStatus', + jest.fn().mockRejectedValue(new Error('Service error')), + ); + + const controller = new AnalyticsPrivacyController({ + messenger: analyticsPrivacyControllerMessenger, + state: { + deleteRegulationId: 'test-regulation-id', + deleteRegulationDate: '15/01/2024', + dataRecorded: false, + }, + }); + + const status = await rootMessenger.call( + 'AnalyticsPrivacyController:checkDataDeleteStatus', + ); + + expect(status.dataDeletionRequestStatus).toBe(DataDeleteStatus.unknown); + expect(status.deletionRequestDate).toBe('15/01/2024'); + expect(status.hasCollectedDataSinceDeletionRequest).toBe(false); + }); + }); + + describe('AnalyticsPrivacyController:getDeleteRegulationCreationDate', () => { + it('returns the deletion date when set', () => { + const { controller, rootMessenger } = setupController({ + state: { + deleteRegulationDate: '15/01/2024', + }, + }); + + const date = rootMessenger.call( + 'AnalyticsPrivacyController:getDeleteRegulationCreationDate', + ); + + expect(date).toBe('15/01/2024'); + }); + + it('returns undefined when deletion date is not set', () => { + const { rootMessenger } = setupController(); + + const date = rootMessenger.call( + 'AnalyticsPrivacyController:getDeleteRegulationCreationDate', + ); + + expect(date).toBeUndefined(); + }); + }); + + describe('AnalyticsPrivacyController:getDeleteRegulationId', () => { + it('returns the regulation ID when set', () => { + const { rootMessenger } = setupController({ + state: { + deleteRegulationId: 'test-regulation-id', + }, + }); + + const id = rootMessenger.call( + 'AnalyticsPrivacyController:getDeleteRegulationId', + ); + + expect(id).toBe('test-regulation-id'); + }); + + it('returns undefined when regulation ID is not set', () => { + const { rootMessenger } = setupController(); + + const id = rootMessenger.call( + 'AnalyticsPrivacyController:getDeleteRegulationId', + ); + + expect(id).toBeUndefined(); + }); + }); + + describe('AnalyticsPrivacyController:isDataRecorded', () => { + it('returns true when data has been recorded', () => { + const { rootMessenger } = setupController({ + state: { + dataRecorded: true, + }, + }); + + const isRecorded = rootMessenger.call( + 'AnalyticsPrivacyController:isDataRecorded', + ); + + expect(isRecorded).toBe(true); + }); + + it('returns false when data has not been recorded', () => { + const { rootMessenger } = setupController(); + + const isRecorded = rootMessenger.call( + 'AnalyticsPrivacyController:isDataRecorded', + ); + + expect(isRecorded).toBe(false); + }); + }); + + describe('AnalyticsPrivacyController:updateDataRecordingFlag', () => { + it('updates dataRecorded to true when saveDataRecording is true', () => { + const { controller, rootMessenger } = setupController({ + state: { + dataRecorded: false, + }, + }); + + rootMessenger.call( + 'AnalyticsPrivacyController:updateDataRecordingFlag', + true, + ); + + expect(controller.state.dataRecorded).toBe(true); + }); + + it('does not update when saveDataRecording is false', () => { + const { controller, rootMessenger } = setupController({ + state: { + dataRecorded: false, + }, + }); + + rootMessenger.call( + 'AnalyticsPrivacyController:updateDataRecordingFlag', + false, + ); + + expect(controller.state.dataRecorded).toBe(false); + }); + + it('does not update when dataRecorded is already true', () => { + const { controller, rootMessenger } = setupController({ + state: { + dataRecorded: true, + }, + }); + + rootMessenger.call( + 'AnalyticsPrivacyController:updateDataRecordingFlag', + true, + ); + + expect(controller.state.dataRecorded).toBe(true); + }); + + it('defaults saveDataRecording to true', () => { + const { controller, rootMessenger } = setupController({ + state: { + dataRecorded: false, + }, + }); + + rootMessenger.call( + 'AnalyticsPrivacyController:updateDataRecordingFlag', + ); + + expect(controller.state.dataRecorded).toBe(true); + }); + + it('emits dataRecordingFlagUpdated event when flag is updated', () => { + const { rootMessenger } = setupController({ + state: { + dataRecorded: false, + }, + }); + + const eventListener = jest.fn(); + rootMessenger.subscribe( + 'AnalyticsPrivacyController:dataRecordingFlagUpdated', + eventListener, + ); + + rootMessenger.call( + 'AnalyticsPrivacyController:updateDataRecordingFlag', + true, + ); + + expect(eventListener).toHaveBeenCalledWith(true); + }); + + it('does not emit event when flag is not updated', () => { + const { rootMessenger } = setupController({ + state: { + dataRecorded: false, + }, + }); + + const eventListener = jest.fn(); + rootMessenger.subscribe( + 'AnalyticsPrivacyController:dataRecordingFlagUpdated', + eventListener, + ); + + rootMessenger.call( + 'AnalyticsPrivacyController:updateDataRecordingFlag', + false, + ); + + expect(eventListener).not.toHaveBeenCalled(); + }); + }); + + describe('stateChange event', () => { + it('emits stateChange event when state is updated', () => { + const { controller, rootMessenger, messenger } = setupController(); + + const eventListener = jest.fn(); + messenger.subscribe( + 'AnalyticsPrivacyController:stateChange', + eventListener, + ); + + rootMessenger.call( + 'AnalyticsPrivacyController:updateDataRecordingFlag', + true, + ); + + expect(eventListener).toHaveBeenCalled(); + const [newState] = eventListener.mock.calls[0]; + expect(newState.dataRecorded).toBe(true); + }); + }); +}); diff --git a/packages/analytics-privacy-controller/src/AnalyticsPrivacyController.ts b/packages/analytics-privacy-controller/src/AnalyticsPrivacyController.ts new file mode 100644 index 00000000000..d72f960c272 --- /dev/null +++ b/packages/analytics-privacy-controller/src/AnalyticsPrivacyController.ts @@ -0,0 +1,376 @@ +import type { + ControllerGetStateAction, + ControllerStateChangeEvent, + StateMetadata, +} from '@metamask/base-controller'; +import { BaseController } from '@metamask/base-controller'; +import type { Messenger } from '@metamask/messenger'; + +import type { AnalyticsControllerGetStateAction } from '@metamask/analytics-controller'; +import type { AnalyticsPrivacyServiceActions } from './AnalyticsPrivacyService'; +import { projectLogger as log } from './AnalyticsPrivacyLogger'; +import type { AnalyticsPrivacyControllerMethodActions } from './AnalyticsPrivacyController-method-action-types'; +import { + DataDeleteResponseStatus, + DataDeleteStatus, + type IDeleteRegulationResponse, + type IDeleteRegulationStatus, +} from './types'; + +// === GENERAL === + +/** + * The name of the {@link AnalyticsPrivacyController}, used to namespace the + * controller's actions and events and to namespace the controller's state data + * when composed with other controllers. + */ +export const controllerName = 'AnalyticsPrivacyController'; + +// === STATE === + +/** + * Describes the shape of the state object for {@link AnalyticsPrivacyController}. + */ +export type AnalyticsPrivacyControllerState = { + /** + * Indicates if data has been recorded since the last deletion request. + */ + dataRecorded: boolean; + + /** + * Segment's data deletion regulation ID. + * The ID returned by the Segment delete API which allows checking the status of the deletion request. + */ + deleteRegulationId: string | null; + + /** + * Segment's data deletion regulation creation date. + * The date when the deletion request was created, in DD/MM/YYYY format. + */ + deleteRegulationDate: string | null; +}; + +/** + * Returns default values for AnalyticsPrivacyController state. + * + * @returns Default state + */ +export function getDefaultAnalyticsPrivacyControllerState(): AnalyticsPrivacyControllerState { + return { + dataRecorded: false, + deleteRegulationId: null, + deleteRegulationDate: null, + }; +} + +/** + * The metadata for each property in {@link AnalyticsPrivacyControllerState}. + */ +const analyticsPrivacyControllerMetadata = { + dataRecorded: { + includeInStateLogs: true, + persist: true, + includeInDebugSnapshot: true, + usedInUi: true, + }, + deleteRegulationId: { + includeInStateLogs: true, + persist: true, + includeInDebugSnapshot: true, + usedInUi: true, + }, + deleteRegulationDate: { + includeInStateLogs: true, + persist: true, + includeInDebugSnapshot: true, + usedInUi: true, + }, +} satisfies StateMetadata; + +// === MESSENGER === + +const MESSENGER_EXPOSED_METHODS = [ + 'createDataDeletionTask', + 'checkDataDeleteStatus', + 'getDeleteRegulationCreationDate', + 'getDeleteRegulationId', + 'isDataRecorded', + 'updateDataRecordingFlag', +] as const; + +/** + * Returns the state of the {@link AnalyticsPrivacyController}. + */ +export type AnalyticsPrivacyControllerGetStateAction = + ControllerGetStateAction< + typeof controllerName, + AnalyticsPrivacyControllerState + >; + +/** + * Actions that {@link AnalyticsPrivacyControllerMessenger} exposes to other consumers. + */ +export type AnalyticsPrivacyControllerActions = + | AnalyticsPrivacyControllerGetStateAction + | AnalyticsPrivacyControllerMethodActions; + +/** + * Actions from other messengers that {@link AnalyticsPrivacyControllerMessenger} calls. + */ +type AllowedActions = + | AnalyticsControllerGetStateAction + | AnalyticsPrivacyServiceActions; + +/** + * Event emitted when a data deletion task is created. + */ +export type DataDeletionTaskCreatedEvent = { + type: `${typeof controllerName}:dataDeletionTaskCreated`; + payload: [IDeleteRegulationResponse]; +}; + +/** + * Event emitted when the data recording flag is updated. + */ +export type DataRecordingFlagUpdatedEvent = { + type: `${typeof controllerName}:dataRecordingFlagUpdated`; + payload: [boolean]; +}; + +/** + * Event emitted when the state of the {@link AnalyticsPrivacyController} changes. + */ +export type AnalyticsPrivacyControllerStateChangeEvent = + ControllerStateChangeEvent< + typeof controllerName, + AnalyticsPrivacyControllerState + >; + +/** + * Events that {@link AnalyticsPrivacyControllerMessenger} exposes to other consumers. + */ +export type AnalyticsPrivacyControllerEvents = + | AnalyticsPrivacyControllerStateChangeEvent + | DataDeletionTaskCreatedEvent + | DataRecordingFlagUpdatedEvent; + +/** + * Events from other messengers that {@link AnalyticsPrivacyControllerMessenger} subscribes to. + */ +type AllowedEvents = never; + +/** + * The messenger restricted to actions and events accessed by + * {@link AnalyticsPrivacyController}. + */ +export type AnalyticsPrivacyControllerMessenger = Messenger< + typeof controllerName, + AnalyticsPrivacyControllerActions | AllowedActions, + AnalyticsPrivacyControllerEvents | AllowedEvents +>; + +// === CONTROLLER DEFINITION === + +/** + * The options that AnalyticsPrivacyController takes. + */ +export type AnalyticsPrivacyControllerOptions = { + /** + * Initial controller state. + */ + state?: Partial; + /** + * Messenger used to communicate with BaseController and other controllers. + */ + messenger: AnalyticsPrivacyControllerMessenger; +}; + +/** + * The AnalyticsPrivacyController manages analytics privacy and GDPR/CCPA data deletion functionality. + * It communicates with Segment's Regulations API via a proxy to create and monitor data deletion requests. + * + * This controller follows the MetaMask controller pattern and integrates with the + * messenger system to allow other controllers and components to manage data deletion tasks. + */ +export class AnalyticsPrivacyController extends BaseController< + typeof controllerName, + AnalyticsPrivacyControllerState, + AnalyticsPrivacyControllerMessenger +> { + /** + * Constructs an AnalyticsPrivacyController instance. + * + * @param options - Controller options + * @param options.state - Initial controller state. Use `getDefaultAnalyticsPrivacyControllerState()` for defaults. + * @param options.messenger - Messenger used to communicate with BaseController + */ + constructor({ + state = {}, + messenger, + }: AnalyticsPrivacyControllerOptions) { + const initialState: AnalyticsPrivacyControllerState = { + ...getDefaultAnalyticsPrivacyControllerState(), + ...state, + }; + + super({ + name: controllerName, + metadata: analyticsPrivacyControllerMetadata, + state: initialState, + messenger, + }); + + this.messenger.registerMethodActionHandlers( + this, + MESSENGER_EXPOSED_METHODS, + ); + + log('AnalyticsPrivacyController initialized', { + dataRecorded: this.state.dataRecorded, + hasDeleteRegulationId: !!this.state.deleteRegulationId, + deleteRegulationDate: this.state.deleteRegulationDate, + }); + } + + /** + * Creates a new delete regulation for the user. + * This is necessary to respect the GDPR and CCPA regulations. + * + * @returns Promise containing the status of the request + */ + async createDataDeletionTask(): Promise { + try { + const analyticsControllerState = await this.messenger.call( + 'AnalyticsController:getState', + ); + const analyticsId = analyticsControllerState.analyticsId; + + if (!analyticsId || analyticsId.trim() === '') { + log('Analytics Deletion Task Error', new Error('Analytics ID not found')); + return { + status: DataDeleteResponseStatus.error, + error: 'Analytics ID not found', + }; + } + + const response = await this.messenger.call( + 'AnalyticsPrivacyService:createDataDeletionTask', + analyticsId, + ); + + if ( + response.status === DataDeleteResponseStatus.ok && + response.regulateId && + typeof response.regulateId === 'string' && + response.regulateId.trim() !== '' + ) { + const currentDate = new Date(); + const day = currentDate.getUTCDate().toString().padStart(2, '0'); + const month = (currentDate.getUTCMonth() + 1).toString().padStart(2, '0'); + const year = currentDate.getUTCFullYear(); + const deletionDate = `${day}/${month}/${year}`; + + this.update((state) => { + state.deleteRegulationId = response.regulateId as string; + state.deleteRegulationDate = deletionDate; + state.dataRecorded = false; + }); + + this.messenger.publish( + `${controllerName}:dataDeletionTaskCreated`, + response, + ); + } + + return response; + } catch (error) { + log('Analytics Deletion Task Error', error); + return { + status: DataDeleteResponseStatus.error, + error: 'Analytics Deletion Task Error', + }; + } + } + + /** + * Check the latest delete regulation status. + * + * @returns Promise containing the date, delete status and collected data flag + */ + async checkDataDeleteStatus(): Promise { + const status: IDeleteRegulationStatus = { + deletionRequestDate: undefined, + dataDeletionRequestStatus: DataDeleteStatus.unknown, + hasCollectedDataSinceDeletionRequest: false, + }; + + if (!this.state.deleteRegulationId) { + return status; + } + + try { + const dataDeletionTaskStatus = await this.messenger.call( + 'AnalyticsPrivacyService:checkDataDeleteStatus', + this.state.deleteRegulationId, + ); + + status.dataDeletionRequestStatus = + dataDeletionTaskStatus.dataDeleteStatus; + } catch (error) { + log('Error checkDataDeleteStatus', error); + status.dataDeletionRequestStatus = DataDeleteStatus.unknown; + } + + status.deletionRequestDate = this.state.deleteRegulationDate ?? undefined; + status.hasCollectedDataSinceDeletionRequest = this.state.dataRecorded; + + return status; + } + + /** + * Get the latest delete regulation request date. + * + * @returns The date as a DD/MM/YYYY string, or undefined + */ + getDeleteRegulationCreationDate(): string | undefined { + return this.state.deleteRegulationDate ?? undefined; + } + + /** + * Get the latest delete regulation request id. + * + * @returns The id string, or undefined + */ + getDeleteRegulationId(): string | undefined { + return this.state.deleteRegulationId ?? undefined; + } + + /** + * Indicate if events have been recorded since the last deletion request. + * + * @returns true if events have been recorded since the last deletion request + */ + isDataRecorded(): boolean { + return this.state.dataRecorded; + } + + /** + * Update the data recording flag if needed. + * This method should be called after tracking events to ensure + * the data recording flag is properly updated for data deletion workflows. + * + * @param saveDataRecording - Whether to save the data recording flag (default: true) + */ + updateDataRecordingFlag(saveDataRecording: boolean = true): void { + if (saveDataRecording && !this.state.dataRecorded) { + this.update((state) => { + state.dataRecorded = true; + }); + + this.messenger.publish( + `${controllerName}:dataRecordingFlagUpdated`, + true, + ); + } + } +} diff --git a/packages/analytics-privacy-controller/src/AnalyticsPrivacyLogger.ts b/packages/analytics-privacy-controller/src/AnalyticsPrivacyLogger.ts new file mode 100644 index 00000000000..0aa9c90f65f --- /dev/null +++ b/packages/analytics-privacy-controller/src/AnalyticsPrivacyLogger.ts @@ -0,0 +1,7 @@ +/* istanbul ignore file */ + +import { createProjectLogger, createModuleLogger } from '@metamask/utils'; + +export const projectLogger = createProjectLogger('analytics-privacy-controller'); + +export { createModuleLogger }; diff --git a/packages/analytics-privacy-controller/src/AnalyticsPrivacyService-method-action-types.ts b/packages/analytics-privacy-controller/src/AnalyticsPrivacyService-method-action-types.ts new file mode 100644 index 00000000000..edade11fb0a --- /dev/null +++ b/packages/analytics-privacy-controller/src/AnalyticsPrivacyService-method-action-types.ts @@ -0,0 +1,35 @@ +/** + * This file is auto generated by `scripts/generate-method-action-types.ts`. + * Do not edit manually. + */ + +import type { AnalyticsPrivacyService } from './AnalyticsPrivacyService'; + +/** + * Creates a DELETE_ONLY regulation for the given analyticsId. + * + * @param analyticsId - The analytics ID of the user for whom to create the deletion task. + * @returns Promise resolving to the deletion regulation response. + */ +export type AnalyticsPrivacyServiceCreateDataDeletionTaskAction = { + type: `AnalyticsPrivacyService:createDataDeletionTask`; + handler: AnalyticsPrivacyService['createDataDeletionTask']; +}; + +/** + * Checks the status of a regulation by ID. + * + * @param regulationId - The regulation ID to check. + * @returns Promise resolving to the regulation status response. + */ +export type AnalyticsPrivacyServiceCheckDataDeleteStatusAction = { + type: `AnalyticsPrivacyService:checkDataDeleteStatus`; + handler: AnalyticsPrivacyService['checkDataDeleteStatus']; +}; + +/** + * Union of all AnalyticsPrivacyService action types. + */ +export type AnalyticsPrivacyServiceMethodActions = + | AnalyticsPrivacyServiceCreateDataDeletionTaskAction + | AnalyticsPrivacyServiceCheckDataDeleteStatusAction; diff --git a/packages/analytics-privacy-controller/src/AnalyticsPrivacyService.test.ts b/packages/analytics-privacy-controller/src/AnalyticsPrivacyService.test.ts new file mode 100644 index 00000000000..1c1a5b5400a --- /dev/null +++ b/packages/analytics-privacy-controller/src/AnalyticsPrivacyService.test.ts @@ -0,0 +1,564 @@ +import { HttpError } from '@metamask/controller-utils'; +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MockAnyNamespace, + MessengerActions, + MessengerEvents, +} from '@metamask/messenger'; +import nock from 'nock'; +import { useFakeTimers } from 'sinon'; +import type { SinonFakeTimers } from 'sinon'; + +import type { AnalyticsPrivacyServiceMessenger } from './AnalyticsPrivacyService'; +import { AnalyticsPrivacyService } from './AnalyticsPrivacyService'; +import { DataDeleteResponseStatus, DataDeleteStatus } from './types'; + +describe('AnalyticsPrivacyService', () => { + let clock: SinonFakeTimers; + const segmentSourceId = 'test-source-id'; + const segmentRegulationsEndpoint = 'https://proxy.example.com/v1beta'; + + beforeEach(() => { + clock = useFakeTimers(); + nock.cleanAll(); + nock.disableNetConnect(); + }); + + afterEach(() => { + clock.restore(); + nock.cleanAll(); + nock.enableNetConnect(); + }); + + describe('AnalyticsPrivacyService:createDataDeletionTask', () => { + it('creates a data deletion task and returns the regulateId', async () => { + const analyticsId = 'test-analytics-id'; + const regulateId = 'test-regulate-id'; + + nock(segmentRegulationsEndpoint) + .post(`/regulations/sources/${segmentSourceId}`) + .reply(200, { + data: { + data: { + regulateId, + }, + }, + }); + + const { rootMessenger } = getService(); + + const response = await rootMessenger.call( + 'AnalyticsPrivacyService:createDataDeletionTask', + analyticsId, + ); + + expect(response).toStrictEqual({ + status: DataDeleteResponseStatus.ok, + regulateId, + }); + }); + + it('returns error if segmentSourceId is missing', async () => { + const analyticsId = 'test-analytics-id'; + + const { rootMessenger } = getService({ + options: { + segmentSourceId: '', + segmentRegulationsEndpoint, + }, + }); + + const response = await rootMessenger.call( + 'AnalyticsPrivacyService:createDataDeletionTask', + analyticsId, + ); + + expect(response).toStrictEqual({ + status: DataDeleteResponseStatus.error, + error: 'Segment API source ID or endpoint not found', + }); + }); + + it('returns error if segmentRegulationsEndpoint is missing', async () => { + const analyticsId = 'test-analytics-id'; + + const { rootMessenger } = getService({ + options: { + segmentSourceId, + segmentRegulationsEndpoint: '', + }, + }); + + const response = await rootMessenger.call( + 'AnalyticsPrivacyService:createDataDeletionTask', + analyticsId, + ); + + expect(response).toStrictEqual({ + status: DataDeleteResponseStatus.error, + error: 'Segment API source ID or endpoint not found', + }); + }); + + it('returns error if API returns non-200 status', async () => { + const analyticsId = 'test-analytics-id'; + + nock(segmentRegulationsEndpoint) + .post(`/regulations/sources/${segmentSourceId}`) + .reply(500); + + const { rootMessenger } = getService({ + options: { + policyOptions: { + maxRetries: 0, // Disable retries for faster test execution + }, + }, + }); + + const response = await rootMessenger.call( + 'AnalyticsPrivacyService:createDataDeletionTask', + analyticsId, + ); + + expect(response).toStrictEqual({ + status: DataDeleteResponseStatus.error, + error: 'Analytics Deletion Task Error', + }); + }); + + it('returns error if API returns malformed response', async () => { + const analyticsId = 'test-analytics-id'; + + nock(segmentRegulationsEndpoint) + .post(`/regulations/sources/${segmentSourceId}`) + .reply(200, { + data: { + // Missing data.regulateId + }, + }); + + const { rootMessenger } = getService(); + + const response = await rootMessenger.call( + 'AnalyticsPrivacyService:createDataDeletionTask', + analyticsId, + ); + + expect(response).toStrictEqual({ + status: DataDeleteResponseStatus.error, + error: 'Analytics Deletion Task Error', + }); + }); + + it('sends correct request body with DELETE_ONLY regulation type', async () => { + const analyticsId = 'test-analytics-id'; + const regulateId = 'test-regulate-id'; + + const scope = nock(segmentRegulationsEndpoint) + .post(`/regulations/sources/${segmentSourceId}`, (body) => { + const parsedBody = typeof body === 'string' ? JSON.parse(body) : body; + return ( + parsedBody.regulationType === 'DELETE_ONLY' && + parsedBody.subjectType === 'USER_ID' && + Array.isArray(parsedBody.subjectIds) && + parsedBody.subjectIds.length === 1 && + parsedBody.subjectIds[0] === analyticsId + ); + }) + .reply(200, { + data: { + data: { + regulateId, + }, + }, + }); + + const { rootMessenger } = getService(); + + await rootMessenger.call( + 'AnalyticsPrivacyService:createDataDeletionTask', + analyticsId, + ); + + expect(scope.isDone()).toBe(true); + }); + + it('sends correct Content-Type header', async () => { + const analyticsId = 'test-analytics-id'; + const regulateId = 'test-regulate-id'; + + const scope = nock(segmentRegulationsEndpoint, { + reqheaders: { + 'Content-Type': 'application/vnd.segment.v1+json', + }, + }) + .post(`/regulations/sources/${segmentSourceId}`) + .reply(200, { + data: { + data: { + regulateId, + }, + }, + }); + + const { rootMessenger } = getService(); + + await rootMessenger.call( + 'AnalyticsPrivacyService:createDataDeletionTask', + analyticsId, + ); + + expect(scope.isDone()).toBe(true); + }); + }); + + describe('AnalyticsPrivacyService:checkDataDeleteStatus', () => { + it('checks data deletion status and returns the status', async () => { + const regulationId = 'test-regulation-id'; + const status = DataDeleteStatus.finished; + + nock(segmentRegulationsEndpoint) + .get(`/regulations/${regulationId}`) + .reply(200, { + data: { + data: { + regulation: { + overallStatus: status, + }, + }, + }, + }); + + const { rootMessenger } = getService(); + + const response = await rootMessenger.call( + 'AnalyticsPrivacyService:checkDataDeleteStatus', + regulationId, + ); + + expect(response).toStrictEqual({ + status: DataDeleteResponseStatus.ok, + dataDeleteStatus: status, + }); + }); + + it('returns unknown status if regulationId is missing', async () => { + const { rootMessenger } = getService(); + + const response = await rootMessenger.call( + 'AnalyticsPrivacyService:checkDataDeleteStatus', + '', + ); + + expect(response).toStrictEqual({ + status: DataDeleteResponseStatus.error, + dataDeleteStatus: DataDeleteStatus.unknown, + }); + }); + + it('returns unknown status if segmentRegulationsEndpoint is missing', async () => { + const regulationId = 'test-regulation-id'; + + const { rootMessenger } = getService({ + options: { + segmentSourceId, + segmentRegulationsEndpoint: '', + }, + }); + + const response = await rootMessenger.call( + 'AnalyticsPrivacyService:checkDataDeleteStatus', + regulationId, + ); + + expect(response).toStrictEqual({ + status: DataDeleteResponseStatus.error, + dataDeleteStatus: DataDeleteStatus.unknown, + }); + }); + + it('returns unknown status if API returns non-200 status', async () => { + const regulationId = 'test-regulation-id'; + + nock(segmentRegulationsEndpoint) + .get(`/regulations/${regulationId}`) + .reply(500); + + const { rootMessenger } = getService({ + options: { + policyOptions: { + maxRetries: 0, // Disable retries for faster test execution + }, + }, + }); + + const response = await rootMessenger.call( + 'AnalyticsPrivacyService:checkDataDeleteStatus', + regulationId, + ); + + expect(response).toStrictEqual({ + status: DataDeleteResponseStatus.error, + dataDeleteStatus: DataDeleteStatus.unknown, + }); + }); + + it('returns unknown status if API response is missing overallStatus', async () => { + const regulationId = 'test-regulation-id'; + + nock(segmentRegulationsEndpoint) + .get(`/regulations/${regulationId}`) + .reply(200, { + data: { + data: { + regulation: { + // Missing overallStatus + }, + }, + }, + }); + + const { rootMessenger } = getService(); + + const response = await rootMessenger.call( + 'AnalyticsPrivacyService:checkDataDeleteStatus', + regulationId, + ); + + expect(response).toStrictEqual({ + status: DataDeleteResponseStatus.ok, + dataDeleteStatus: DataDeleteStatus.unknown, + }); + }); + + it('sends correct Content-Type header', async () => { + const regulationId = 'test-regulation-id'; + const status = DataDeleteStatus.running; + + const scope = nock(segmentRegulationsEndpoint, { + reqheaders: { + 'Content-Type': 'application/vnd.segment.v1+json', + }, + }) + .get(`/regulations/${regulationId}`) + .reply(200, { + data: { + data: { + regulation: { + overallStatus: status, + }, + }, + }, + }); + + const { rootMessenger } = getService(); + + await rootMessenger.call( + 'AnalyticsPrivacyService:checkDataDeleteStatus', + regulationId, + ); + + expect(scope.isDone()).toBe(true); + }); + }); + + describe('onRetry', () => { + it('registers and calls retry listeners', async () => { + nock(segmentRegulationsEndpoint) + .post(`/regulations/sources/${segmentSourceId}`) + .times(2) + .reply(500); + + const { service, rootMessenger } = getService({ + options: { + policyOptions: { + maxRetries: 1, + }, + }, + }); + + const onRetryListener = jest.fn(); + service.onRetry(() => { + clock.nextAsync().catch(console.error); + onRetryListener(); + }); + + await expect( + rootMessenger.call( + 'AnalyticsPrivacyService:createDataDeletionTask', + 'test-analytics-id', + ), + ).resolves.toMatchObject({ + status: DataDeleteResponseStatus.error, + }); + + expect(onRetryListener).toHaveBeenCalled(); + }); + }); + + describe('onBreak', () => { + it('registers and calls break listeners when circuit breaker opens', async () => { + nock(segmentRegulationsEndpoint) + .post(`/regulations/sources/${segmentSourceId}`) + .times(12) + .reply(500); + + const { service, rootMessenger } = getService(); + service.onRetry(() => { + clock.nextAsync().catch(console.error); + }); + + const onBreakListener = jest.fn(); + service.onBreak(onBreakListener); + + // Make 3 failed requests to trigger circuit breaker + for (let i = 0; i < 3; i++) { + await expect( + rootMessenger.call( + 'AnalyticsPrivacyService:createDataDeletionTask', + 'test-analytics-id', + ), + ).resolves.toMatchObject({ + status: DataDeleteResponseStatus.error, + }); + } + + // 4th request should trigger circuit breaker - service catches and returns error + await expect( + rootMessenger.call( + 'AnalyticsPrivacyService:createDataDeletionTask', + 'test-analytics-id', + ), + ).resolves.toMatchObject({ + status: DataDeleteResponseStatus.error, + }); + + expect(onBreakListener).toHaveBeenCalled(); + }); + }); + + describe('onDegraded', () => { + it('calls onDegraded listeners if the request takes longer than 5 seconds to resolve', async () => { + nock(segmentRegulationsEndpoint) + .post(`/regulations/sources/${segmentSourceId}`) + .reply(200, () => { + clock.tick(6000); + return { + data: { + data: { + regulateId: 'test-regulate-id', + }, + }, + }; + }); + + const { service, rootMessenger } = getService(); + const onDegradedListener = jest.fn(); + service.onDegraded(onDegradedListener); + + await rootMessenger.call( + 'AnalyticsPrivacyService:createDataDeletionTask', + 'test-analytics-id', + ); + + expect(onDegradedListener).toHaveBeenCalled(); + }); + + it('calls onDegraded listeners when the maximum number of retries is exceeded', async () => { + nock(segmentRegulationsEndpoint) + .post(`/regulations/sources/${segmentSourceId}`) + .times(4) + .reply(500); + + const { service, rootMessenger } = getService(); + service.onRetry(() => { + clock.nextAsync().catch(console.error); + }); + const onDegradedListener = jest.fn(); + service.onDegraded(onDegradedListener); + + await expect( + rootMessenger.call( + 'AnalyticsPrivacyService:createDataDeletionTask', + 'test-analytics-id', + ), + ).resolves.toMatchObject({ + status: DataDeleteResponseStatus.error, + }); + + expect(onDegradedListener).toHaveBeenCalled(); + }); + }); +}); + +/** + * The type of the messenger populated with all external actions and events + * required by the service under test. + */ +type RootMessenger = Messenger< + MockAnyNamespace, + MessengerActions, + MessengerEvents +>; + +/** + * Constructs the messenger populated with all external actions and events + * required by the service under test. + * + * @returns The root messenger. + */ +function getRootMessenger(): RootMessenger { + return new Messenger({ namespace: MOCK_ANY_NAMESPACE }); +} + +/** + * Constructs the messenger for the service under test. + * + * @param rootMessenger - The root messenger, with all external actions and + * events required by the service's messenger. + * @returns The service-specific messenger. + */ +function getMessenger( + rootMessenger: RootMessenger, +): AnalyticsPrivacyServiceMessenger { + return new Messenger({ + namespace: 'AnalyticsPrivacyService', + parent: rootMessenger, + }); +} + +/** + * Constructs the service under test. + * + * @param args - The arguments to this function. + * @param args.options - The options that the service constructor takes. All are + * optional and will be filled in with defaults as needed (including + * `messenger`). + * @returns The new service, root messenger, and service messenger. + */ +function getService({ + options = {}, +}: { + options?: Partial< + ConstructorParameters[0] + >; +} = {}): { + service: AnalyticsPrivacyService; + rootMessenger: RootMessenger; + messenger: AnalyticsPrivacyServiceMessenger; +} { + const rootMessenger = getRootMessenger(); + const messenger = getMessenger(rootMessenger); + const defaultSegmentSourceId = 'test-source-id'; + const defaultSegmentRegulationsEndpoint = 'https://proxy.example.com/v1beta'; + + const service = new AnalyticsPrivacyService({ + fetch, + messenger, + segmentSourceId: options.segmentSourceId ?? defaultSegmentSourceId, + segmentRegulationsEndpoint: options.segmentRegulationsEndpoint ?? defaultSegmentRegulationsEndpoint, + ...options, + }); + + return { service, rootMessenger, messenger }; +} diff --git a/packages/analytics-privacy-controller/src/AnalyticsPrivacyService.ts b/packages/analytics-privacy-controller/src/AnalyticsPrivacyService.ts new file mode 100644 index 00000000000..b824f548f5f --- /dev/null +++ b/packages/analytics-privacy-controller/src/AnalyticsPrivacyService.ts @@ -0,0 +1,391 @@ +import type { + CreateServicePolicyOptions, + ServicePolicy, +} from '@metamask/controller-utils'; +import { + createServicePolicy, + HttpError, +} from '@metamask/controller-utils'; +import type { Messenger } from '@metamask/messenger'; +import type { IDisposable } from 'cockatiel'; + +import { projectLogger as log } from './AnalyticsPrivacyLogger'; +import { + DataDeleteResponseStatus, + DataDeleteStatus, + type IDeleteRegulationResponse, + type IDeleteRegulationStatusResponse, +} from './types'; +import type { AnalyticsPrivacyServiceMethodActions } from './AnalyticsPrivacyService-method-action-types'; +import { + SEGMENT_REGULATION_TYPE_DELETE_ONLY, + SEGMENT_SUBJECT_TYPE_USER_ID, + SEGMENT_CONTENT_TYPE, +} from './constants'; + +// === GENERAL === + +/** + * The name of the {@link AnalyticsPrivacyService}, used to namespace the + * service's actions and events. + */ +export const serviceName = 'AnalyticsPrivacyService'; + +// === MESSENGER === + +const MESSENGER_EXPOSED_METHODS = [ + 'createDataDeletionTask', + 'checkDataDeleteStatus', +] as const; + +/** + * Actions that {@link AnalyticsPrivacyService} exposes to other consumers. + */ +export type AnalyticsPrivacyServiceActions = + AnalyticsPrivacyServiceMethodActions; + +/** + * Actions from other messengers that {@link AnalyticsPrivacyServiceMessenger} calls. + */ +type AllowedActions = never; + +/** + * Events that {@link AnalyticsPrivacyService} exposes to other consumers. + */ +export type AnalyticsPrivacyServiceEvents = never; + +/** + * Events from other messengers that {@link AnalyticsPrivacyService} subscribes to. + */ +type AllowedEvents = never; + +/** + * The messenger which is restricted to actions and events accessed by + * {@link AnalyticsPrivacyService}. + */ +export type AnalyticsPrivacyServiceMessenger = Messenger< + typeof serviceName, + AnalyticsPrivacyServiceActions | AllowedActions, + AnalyticsPrivacyServiceEvents | AllowedEvents +>; + +// === SERVICE DEFINITION === + +/** + * Response structure from Segment API for creating a regulation. + */ +type CreateRegulationResponse = { + data: { + data: { + regulateId: string; + }; + }; +}; + +/** + * Response structure from Segment API for getting regulation status. + */ +type GetRegulationStatusResponse = { + data: { + data: { + regulation: { + overallStatus: string; + }; + }; + }; +}; + +/** + * Options for constructing {@link AnalyticsPrivacyService}. + */ +export type AnalyticsPrivacyServiceOptions = { + /** + * The messenger suited for this service. + */ + messenger: AnalyticsPrivacyServiceMessenger; + + /** + * A function that can be used to make an HTTP request. + */ + fetch: typeof fetch; + + /** + * Segment API source ID (required for creating regulations). + */ + segmentSourceId: string; + + /** + * Base URL for the proxy endpoint (not Segment API directly). + * The proxy forwards requests to Segment API and adds authentication tokens. + */ + segmentRegulationsEndpoint: string; + + /** + * Options to pass to `createServicePolicy`, which is used to wrap each request. + */ + policyOptions?: CreateServicePolicyOptions; +}; + +/** + * This service object is responsible for making requests to the Segment Regulations API + * via a proxy endpoint for GDPR/CCPA data deletion functionality. + * + * @example + * + * ```ts + * import { Messenger } from '@metamask/messenger'; + * import type { + * AnalyticsPrivacyServiceActions, + * AnalyticsPrivacyServiceEvents, + * } from '@metamask/analytics-privacy-controller'; + * + * const rootMessenger = new Messenger< + * 'Root', + * AnalyticsPrivacyServiceActions, + * AnalyticsPrivacyServiceEvents + * >({ namespace: 'Root' }); + * const serviceMessenger = new Messenger< + * 'AnalyticsPrivacyService', + * AnalyticsPrivacyServiceActions, + * AnalyticsPrivacyServiceEvents, + * typeof rootMessenger, + * >({ + * namespace: 'AnalyticsPrivacyService', + * parent: rootMessenger, + * }); + * // Instantiate the service to register its actions on the messenger + * new AnalyticsPrivacyService({ + * messenger: serviceMessenger, + * fetch, + * segmentSourceId: 'abc123', + * segmentRegulationsEndpoint: 'https://proxy.example.com/v1beta', + * }); + * + * // Later... + * // Create a data deletion task + * const response = await rootMessenger.call( + * 'AnalyticsPrivacyService:createDataDeletionTask', + * 'user-analytics-id', + * ); + * ``` + */ +export class AnalyticsPrivacyService { + /** + * The name of the service. + */ + readonly name: typeof serviceName; + + /** + * The messenger suited for this service. + */ + readonly #messenger: AnalyticsPrivacyServiceMessenger; + + /** + * A function that can be used to make an HTTP request. + */ + readonly #fetch: typeof fetch; + + /** + * Segment API source ID. + */ + readonly #segmentSourceId: string; + + /** + * Base URL for the proxy endpoint. + */ + readonly #segmentRegulationsEndpoint: string; + + /** + * The policy that wraps the request. + * + * @see {@link createServicePolicy} + */ + readonly #policy: ServicePolicy; + + /** + * Constructs a new AnalyticsPrivacyService object. + * + * @param options - The constructor options. + */ + constructor(options: AnalyticsPrivacyServiceOptions) { + this.name = serviceName; + this.#messenger = options.messenger; + this.#fetch = options.fetch; + this.#segmentSourceId = options.segmentSourceId; + this.#segmentRegulationsEndpoint = options.segmentRegulationsEndpoint; + this.#policy = createServicePolicy(options.policyOptions ?? {}); + + this.#messenger.registerMethodActionHandlers( + this, + MESSENGER_EXPOSED_METHODS, + ); + } + + /** + * Registers a handler that will be called after a request returns a non-500 + * response, causing a retry. Primarily useful in tests where timers are being + * mocked. + * + * @param listener - The handler to be called. + * @returns An object that can be used to unregister the handler. + * @see {@link createServicePolicy} + */ + onRetry(listener: Parameters[0]): IDisposable { + return this.#policy.onRetry(listener); + } + + /** + * Registers a handler that will be called after a set number of retry rounds + * prove that requests to the API endpoint consistently return a 5xx response. + * + * @param listener - The handler to be called. + * @returns An object that can be used to unregister the handler. + * @see {@link createServicePolicy} + */ + onBreak(listener: Parameters[0]): IDisposable { + return this.#policy.onBreak(listener); + } + + /** + * Registers a handler that will be called under one of two circumstances: + * + * 1. After a set number of retries prove that requests to the API + * consistently result in failures. + * 2. After a successful request is made to the API, but the response takes + * longer than a set duration to return. + * + * @param listener - The handler to be called. + * @returns An object that can be used to unregister the handler. + */ + onDegraded( + listener: Parameters[0], + ): IDisposable { + return this.#policy.onDegraded(listener); + } + + /** + * Creates a DELETE_ONLY regulation for the given analyticsId. + * + * @param analyticsId - The analytics ID of the user for whom to create the deletion task. + * @returns Promise resolving to the deletion regulation response. + */ + async createDataDeletionTask( + analyticsId: string, + ): Promise { + if (!this.#segmentSourceId || !this.#segmentRegulationsEndpoint) { + return { + status: DataDeleteResponseStatus.error, + error: 'Segment API source ID or endpoint not found', + }; + } + + try { + const url = `${this.#segmentRegulationsEndpoint}/regulations/sources/${this.#segmentSourceId}`; + const body = JSON.stringify({ + regulationType: SEGMENT_REGULATION_TYPE_DELETE_ONLY, + subjectType: SEGMENT_SUBJECT_TYPE_USER_ID, + subjectIds: [analyticsId], + }); + + const response = await this.#policy.execute(async () => { + const localResponse = await this.#fetch(url, { + method: 'POST', + headers: { + 'Content-Type': SEGMENT_CONTENT_TYPE, + }, + body, + }); + + if (!localResponse.ok) { + throw new HttpError( + localResponse.status, + `Creating data deletion task failed with status '${localResponse.status}'`, + ); + } + + return localResponse; + }); + + const jsonResponse = (await response.json()) as CreateRegulationResponse; + + if ( + jsonResponse?.data?.data?.regulateId && + typeof jsonResponse.data.data.regulateId === 'string' + ) { + return { + status: DataDeleteResponseStatus.ok, + regulateId: jsonResponse.data.data.regulateId, + }; + } + + log('Analytics Deletion Task Error', new Error('Malformed response from Segment API')); + return { + status: DataDeleteResponseStatus.error, + error: 'Analytics Deletion Task Error', + }; + } catch (error) { + log('Analytics Deletion Task Error', error); + return { + status: DataDeleteResponseStatus.error, + error: 'Analytics Deletion Task Error', + }; + } + } + + /** + * Checks the status of a regulation by ID. + * + * @param regulationId - The regulation ID to check. + * @returns Promise resolving to the regulation status response. + */ + async checkDataDeleteStatus( + regulationId: string, + ): Promise { + if (!regulationId || !this.#segmentRegulationsEndpoint) { + return { + status: DataDeleteResponseStatus.error, + dataDeleteStatus: DataDeleteStatus.unknown, + }; + } + + try { + const url = `${this.#segmentRegulationsEndpoint}/regulations/${regulationId}`; + + const response = await this.#policy.execute(async () => { + const localResponse = await this.#fetch(url, { + method: 'GET', + headers: { + 'Content-Type': SEGMENT_CONTENT_TYPE, + }, + }); + + if (!localResponse.ok) { + throw new HttpError( + localResponse.status, + `Checking data deletion status failed with status '${localResponse.status}'`, + ); + } + + return localResponse; + }); + + const jsonResponse = + (await response.json()) as GetRegulationStatusResponse; + + const status = + jsonResponse?.data?.data?.regulation?.overallStatus || + DataDeleteStatus.unknown; + + return { + status: DataDeleteResponseStatus.ok, + dataDeleteStatus: status as DataDeleteStatus, + }; + } catch (error) { + log('Analytics Deletion Task Check Error', error); + return { + status: DataDeleteResponseStatus.error, + dataDeleteStatus: DataDeleteStatus.unknown, + }; + } + } +} diff --git a/packages/analytics-privacy-controller/src/constants.ts b/packages/analytics-privacy-controller/src/constants.ts new file mode 100644 index 00000000000..c717d8b56f1 --- /dev/null +++ b/packages/analytics-privacy-controller/src/constants.ts @@ -0,0 +1,23 @@ +/** + * Constants used by the analytics privacy controller and service. + */ + +/** + * Date format string for deletion regulation creation date (DD/MM/YYYY). + */ +export const DATE_FORMAT_DD_MM_YYYY = 'DD/MM/YYYY'; + +/** + * Segment API regulation type for DELETE_ONLY operations. + */ +export const SEGMENT_REGULATION_TYPE_DELETE_ONLY = 'DELETE_ONLY'; + +/** + * Segment API subject type for user ID operations. + */ +export const SEGMENT_SUBJECT_TYPE_USER_ID = 'USER_ID'; + +/** + * Segment API Content-Type header value. + */ +export const SEGMENT_CONTENT_TYPE = 'application/vnd.segment.v1+json'; diff --git a/packages/analytics-privacy-controller/src/index.ts b/packages/analytics-privacy-controller/src/index.ts new file mode 100644 index 00000000000..173dfb63254 --- /dev/null +++ b/packages/analytics-privacy-controller/src/index.ts @@ -0,0 +1,31 @@ +export { + AnalyticsPrivacyController, + getDefaultAnalyticsPrivacyControllerState, +} from './AnalyticsPrivacyController'; +export type { AnalyticsPrivacyControllerOptions } from './AnalyticsPrivacyController'; + +export { AnalyticsPrivacyService } from './AnalyticsPrivacyService'; +export type { AnalyticsPrivacyServiceOptions } from './AnalyticsPrivacyService'; + +export type { + DataDeleteStatus, + DataDeleteResponseStatus, + IDeleteRegulationResponse, + IDeleteRegulationStatus, + IDeleteRegulationStatusResponse, +} from './types'; + +export type { AnalyticsPrivacyControllerState } from './AnalyticsPrivacyController'; + +export { analyticsPrivacyControllerSelectors } from './selectors'; + +export type { AnalyticsPrivacyControllerMessenger } from './AnalyticsPrivacyController'; + +export type { + AnalyticsPrivacyControllerActions, + AnalyticsPrivacyControllerEvents, + AnalyticsPrivacyControllerGetStateAction, + AnalyticsPrivacyControllerStateChangeEvent, + DataDeletionTaskCreatedEvent, + DataRecordingFlagUpdatedEvent, +} from './AnalyticsPrivacyController'; diff --git a/packages/analytics-privacy-controller/src/selectors.test.ts b/packages/analytics-privacy-controller/src/selectors.test.ts new file mode 100644 index 00000000000..481457eb269 --- /dev/null +++ b/packages/analytics-privacy-controller/src/selectors.test.ts @@ -0,0 +1,79 @@ +import { + analyticsPrivacyControllerSelectors, + getDefaultAnalyticsPrivacyControllerState, +} from '.'; +import type { AnalyticsPrivacyControllerState } from './AnalyticsPrivacyController'; + +describe('analyticsPrivacyControllerSelectors', () => { + describe('selectDataRecorded', () => { + it('returns the dataRecorded flag from state', () => { + const state: AnalyticsPrivacyControllerState = { + ...getDefaultAnalyticsPrivacyControllerState(), + dataRecorded: true, + }; + + expect( + analyticsPrivacyControllerSelectors.selectDataRecorded(state), + ).toBe(true); + }); + + it('returns false when dataRecorded is false', () => { + const state: AnalyticsPrivacyControllerState = { + ...getDefaultAnalyticsPrivacyControllerState(), + dataRecorded: false, + }; + + expect( + analyticsPrivacyControllerSelectors.selectDataRecorded(state), + ).toBe(false); + }); + }); + + describe('selectDeleteRegulationId', () => { + it('returns the deleteRegulationId when set', () => { + const state: AnalyticsPrivacyControllerState = { + ...getDefaultAnalyticsPrivacyControllerState(), + deleteRegulationId: 'test-regulation-id', + }; + + expect( + analyticsPrivacyControllerSelectors.selectDeleteRegulationId(state), + ).toBe('test-regulation-id'); + }); + + it('returns undefined when deleteRegulationId is null', () => { + const state: AnalyticsPrivacyControllerState = { + ...getDefaultAnalyticsPrivacyControllerState(), + deleteRegulationId: null, + }; + + expect( + analyticsPrivacyControllerSelectors.selectDeleteRegulationId(state), + ).toBeUndefined(); + }); + }); + + describe('selectDeleteRegulationDate', () => { + it('returns the deleteRegulationDate when set', () => { + const state: AnalyticsPrivacyControllerState = { + ...getDefaultAnalyticsPrivacyControllerState(), + deleteRegulationDate: '15/01/2024', + }; + + expect( + analyticsPrivacyControllerSelectors.selectDeleteRegulationDate(state), + ).toBe('15/01/2024'); + }); + + it('returns undefined when deleteRegulationDate is null', () => { + const state: AnalyticsPrivacyControllerState = { + ...getDefaultAnalyticsPrivacyControllerState(), + deleteRegulationDate: null, + }; + + expect( + analyticsPrivacyControllerSelectors.selectDeleteRegulationDate(state), + ).toBeUndefined(); + }); + }); +}); diff --git a/packages/analytics-privacy-controller/src/selectors.ts b/packages/analytics-privacy-controller/src/selectors.ts new file mode 100644 index 00000000000..c7098df6d63 --- /dev/null +++ b/packages/analytics-privacy-controller/src/selectors.ts @@ -0,0 +1,41 @@ +import type { AnalyticsPrivacyControllerState } from './AnalyticsPrivacyController'; + +/** + * Selects the data recorded flag from the controller state. + * + * @param state - The controller state + * @returns Whether data has been recorded since the last deletion request + */ +const selectDataRecorded = ( + state: AnalyticsPrivacyControllerState, +): boolean => state.dataRecorded; + +/** + * Selects the delete regulation ID from the controller state. + * + * @param state - The controller state + * @returns The regulation ID, or undefined if not set + */ +const selectDeleteRegulationId = ( + state: AnalyticsPrivacyControllerState, +): string | undefined => state.deleteRegulationId ?? undefined; + +/** + * Selects the delete regulation creation date from the controller state. + * + * @param state - The controller state + * @returns The deletion date in DD/MM/YYYY format, or undefined if not set + */ +const selectDeleteRegulationDate = ( + state: AnalyticsPrivacyControllerState, +): string | undefined => state.deleteRegulationDate ?? undefined; + +/** + * Selectors for the AnalyticsPrivacyController state. + * These can be used with Redux or directly with controller state. + */ +export const analyticsPrivacyControllerSelectors = { + selectDataRecorded, + selectDeleteRegulationId, + selectDeleteRegulationDate, +}; diff --git a/packages/analytics-privacy-controller/src/types.ts b/packages/analytics-privacy-controller/src/types.ts new file mode 100644 index 00000000000..8812b1f59e5 --- /dev/null +++ b/packages/analytics-privacy-controller/src/types.ts @@ -0,0 +1,57 @@ +/** + * Status values for data deletion requests from Segment API. + */ +export enum DataDeleteStatus { + failed = 'FAILED', + finished = 'FINISHED', + initialized = 'INITIALIZED', + invalid = 'INVALID', + notSupported = 'NOT_SUPPORTED', + partialSuccess = 'PARTIAL_SUCCESS', + running = 'RUNNING', + unknown = 'UNKNOWN', +} + +/** + * Response status for deletion regulation operations. + */ +export enum DataDeleteResponseStatus { + ok = 'ok', + error = 'error', +} + +/** + * Response from creating a data deletion task. + */ +export interface IDeleteRegulationResponse { + status: DataDeleteResponseStatus; + regulateId?: string; // Using exact API field name from Segment API response + error?: string; +} + +/** + * Status information for a data deletion request. + */ +export interface IDeleteRegulationStatus { + deletionRequestDate?: string; + hasCollectedDataSinceDeletionRequest: boolean; + dataDeletionRequestStatus: DataDeleteStatus; +} + +/** + * Response from checking data deletion status. + */ +export interface IDeleteRegulationStatusResponse { + status: DataDeleteResponseStatus; + dataDeleteStatus: DataDeleteStatus; +} + +/** + * Date format for deletion regulation creation date (DD/MM/YYYY). + */ +export type DataDeleteDate = string | undefined; + +/** + * Regulation ID from Segment API. + */ +export type DataDeleteRegulationId = string | undefined; diff --git a/packages/analytics-privacy-controller/tsconfig.build.json b/packages/analytics-privacy-controller/tsconfig.build.json new file mode 100644 index 00000000000..0a7c43ebabb --- /dev/null +++ b/packages/analytics-privacy-controller/tsconfig.build.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "./dist", + "rootDir": "./src" + }, + "references": [ + { "path": "../analytics-controller/tsconfig.build.json" }, + { "path": "../base-controller/tsconfig.build.json" }, + { "path": "../controller-utils/tsconfig.build.json" }, + { "path": "../messenger/tsconfig.build.json" } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/analytics-privacy-controller/tsconfig.json b/packages/analytics-privacy-controller/tsconfig.json new file mode 100644 index 00000000000..ee7bb866ed7 --- /dev/null +++ b/packages/analytics-privacy-controller/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./" + }, + "references": [ + { "path": "../analytics-controller" }, + { "path": "../base-controller" }, + { "path": "../controller-utils" }, + { "path": "../messenger" } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/analytics-privacy-controller/typedoc.json b/packages/analytics-privacy-controller/typedoc.json new file mode 100644 index 00000000000..c9da015dbf8 --- /dev/null +++ b/packages/analytics-privacy-controller/typedoc.json @@ -0,0 +1,7 @@ +{ + "entryPoints": ["./src/index.ts"], + "excludePrivate": true, + "hideGenerator": true, + "out": "docs", + "tsconfig": "./tsconfig.build.json" +} diff --git a/tsconfig.build.json b/tsconfig.build.json index b9952494357..6fc21748da3 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -19,6 +19,9 @@ { "path": "./packages/analytics-controller/tsconfig.build.json" }, + { + "path": "./packages/analytics-privacy-controller/tsconfig.build.json" + }, { "path": "./packages/announcement-controller/tsconfig.build.json" }, diff --git a/tsconfig.json b/tsconfig.json index c712ed48c73..280b99f57ad 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -20,6 +20,9 @@ { "path": "./packages/analytics-controller" }, + { + "path": "./packages/analytics-privacy-controller" + }, { "path": "./packages/announcement-controller" }, diff --git a/yarn.lock b/yarn.lock index 878193cad39..d5d470a9d54 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2531,7 +2531,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/analytics-controller@workspace:packages/analytics-controller": +"@metamask/analytics-controller@npm:^1.0.0, @metamask/analytics-controller@workspace:packages/analytics-controller": version: 0.0.0-use.local resolution: "@metamask/analytics-controller@workspace:packages/analytics-controller" dependencies: @@ -2550,6 +2550,27 @@ __metadata: languageName: unknown linkType: soft +"@metamask/analytics-privacy-controller@workspace:packages/analytics-privacy-controller": + version: 0.0.0-use.local + resolution: "@metamask/analytics-privacy-controller@workspace:packages/analytics-privacy-controller" + dependencies: + "@metamask/analytics-controller": "npm:^1.0.0" + "@metamask/auto-changelog": "npm:^3.4.4" + "@metamask/base-controller": "npm:^9.0.0" + "@metamask/controller-utils": "npm:^7.0.0" + "@metamask/messenger": "npm:^0.3.0" + "@metamask/utils": "npm:^11.9.0" + "@ts-bridge/cli": "npm:^0.6.4" + "@types/jest": "npm:^27.4.1" + deepmerge: "npm:^4.2.2" + jest: "npm:^27.5.1" + ts-jest: "npm:^27.1.4" + typedoc: "npm:^0.24.8" + typedoc-plugin-missing-exports: "npm:^2.0.0" + typescript: "npm:~5.3.3" + languageName: unknown + linkType: soft + "@metamask/announcement-controller@workspace:packages/announcement-controller": version: 0.0.0-use.local resolution: "@metamask/announcement-controller@workspace:packages/announcement-controller" @@ -3005,6 +3026,21 @@ __metadata: languageName: unknown linkType: soft +"@metamask/controller-utils@npm:^7.0.0": + version: 7.0.0 + resolution: "@metamask/controller-utils@npm:7.0.0" + dependencies: + "@metamask/eth-query": "npm:^4.0.0" + "@metamask/ethjs-unit": "npm:^0.2.1" + "@metamask/utils": "npm:^8.2.0" + "@spruceid/siwe-parser": "npm:1.1.3" + eth-ens-namehash: "npm:^2.0.8" + ethereumjs-util: "npm:^7.0.10" + fast-deep-equal: "npm:^3.1.3" + checksum: 10/405b23bf7066ce410f5e8a09bbf63056fa36ebfa7c8450ef83e76b5ce3eecd160d2d8ad2e4023d79bf611b1855f45f1eb834cb51028e7f87964cc3b322ae708d + languageName: node + linkType: hard + "@metamask/core-backend@npm:^5.0.0, @metamask/core-backend@workspace:packages/core-backend": version: 0.0.0-use.local resolution: "@metamask/core-backend@workspace:packages/core-backend" @@ -3650,6 +3686,16 @@ __metadata: languageName: node linkType: hard +"@metamask/ethjs-unit@npm:^0.2.1": + version: 0.2.1 + resolution: "@metamask/ethjs-unit@npm:0.2.1" + dependencies: + bn.js: "npm:4.11.6" + number-to-bn: "npm:1.7.0" + checksum: 10/a67792099e316c102d640782a538359b30937db5d9f3b796e3dc1a03415063632765828cfe1f6b0c37ed8584a3a92f3f1522a2ced40ba0a96766114036db21f3 + languageName: node + linkType: hard + "@metamask/ethjs-unit@npm:^0.3.0": version: 0.3.0 resolution: "@metamask/ethjs-unit@npm:0.3.0" @@ -4986,7 +5032,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/superstruct@npm:^3.1.0, @metamask/superstruct@npm:^3.2.1": +"@metamask/superstruct@npm:^3.0.0, @metamask/superstruct@npm:^3.1.0, @metamask/superstruct@npm:^3.2.1": version: 3.2.1 resolution: "@metamask/superstruct@npm:3.2.1" checksum: 10/9e29380f2cf8b129283ccb2b568296d92682b705109ba62dbd7739ffd6a1982fe38c7228cdcf3cbee94dbcdd5fcc1c846ab9d1dd3582167154f914422fcff547 @@ -5182,6 +5228,23 @@ __metadata: languageName: node linkType: hard +"@metamask/utils@npm:^8.2.0": + version: 8.5.0 + resolution: "@metamask/utils@npm:8.5.0" + dependencies: + "@ethereumjs/tx": "npm:^4.2.0" + "@metamask/superstruct": "npm:^3.0.0" + "@noble/hashes": "npm:^1.3.1" + "@scure/base": "npm:^1.1.3" + "@types/debug": "npm:^4.1.7" + debug: "npm:^4.3.4" + pony-cause: "npm:^2.1.10" + semver: "npm:^7.5.4" + uuid: "npm:^9.0.1" + checksum: 10/68a42a55f7dc750b75467fb7c05a496c20dac073a2753e0f4d9642c4d8dcb3f9ddf51a09d30337e11637f1777f3dfe22e15b5159dbafb0fdb7bd8c9236056153 + languageName: node + linkType: hard + "@metamask/utils@npm:^9.0.0": version: 9.3.0 resolution: "@metamask/utils@npm:9.3.0" @@ -5654,6 +5717,15 @@ __metadata: languageName: node linkType: hard +"@spruceid/siwe-parser@npm:1.1.3": + version: 1.1.3 + resolution: "@spruceid/siwe-parser@npm:1.1.3" + dependencies: + apg-js: "npm:^4.1.1" + checksum: 10/c953fa1e79c633a92f030b68a44225b28c71396553dc5eb8d4d5b263e8b2e5b988131720170df2eaf202ee5251d4369ccff99c130b691a1accca2a1ff93b1111 + languageName: node + linkType: hard + "@spruceid/siwe-parser@npm:2.1.0": version: 2.1.0 resolution: "@spruceid/siwe-parser@npm:2.1.0" @@ -8666,7 +8738,7 @@ __metadata: languageName: node linkType: hard -"ethereumjs-util@npm:^7.1.2": +"ethereumjs-util@npm:^7.0.10, ethereumjs-util@npm:^7.1.2": version: 7.1.5 resolution: "ethereumjs-util@npm:7.1.5" dependencies: From 69d0fb04abfc3825abfad57ef164cae28487fc56 Mon Sep 17 00:00:00 2001 From: Nicolas MASSART Date: Thu, 15 Jan 2026 18:24:25 +0100 Subject: [PATCH 02/12] refactor: update analytics privacy controller to use timestamps instead of dates - Changed `deleteRegulationDate` to `deleteRegulationTimestamp` in the state and related methods to store timestamps in milliseconds since epoch. - Updated relevant methods and tests to reflect the new timestamp format. - Removed date formatting logic and adjusted selectors accordingly. - Added new dependencies for testing and updated the test suite to ensure proper functionality with the new timestamp format. This change enhances consistency in handling date-related data within the analytics privacy controller. --- eslint-suppressions.json | 2 +- ...csPrivacyController-method-action-types.ts | 17 +- .../src/AnalyticsPrivacyController.test.ts | 397 +++++++++++++----- .../src/AnalyticsPrivacyController.ts | 82 ++-- .../src/AnalyticsPrivacyLogger.ts | 4 +- .../src/AnalyticsPrivacyService.test.ts | 46 +- .../src/AnalyticsPrivacyService.ts | 22 +- .../src/constants.ts | 5 - .../src/selectors.test.ts | 17 +- .../src/selectors.ts | 15 +- .../analytics-privacy-controller/src/types.ts | 24 +- yarn.lock | 2 + 12 files changed, 404 insertions(+), 229 deletions(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 081170617f6..0d755579341 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -2060,4 +2060,4 @@ "count": 1 } } -} +} \ No newline at end of file diff --git a/packages/analytics-privacy-controller/src/AnalyticsPrivacyController-method-action-types.ts b/packages/analytics-privacy-controller/src/AnalyticsPrivacyController-method-action-types.ts index ea4aabe7dbd..8e44106c441 100644 --- a/packages/analytics-privacy-controller/src/AnalyticsPrivacyController-method-action-types.ts +++ b/packages/analytics-privacy-controller/src/AnalyticsPrivacyController-method-action-types.ts @@ -19,7 +19,7 @@ export type AnalyticsPrivacyControllerCreateDataDeletionTaskAction = { /** * Check the latest delete regulation status. * - * @returns Promise containing the date, delete status and collected data flag + * @returns Promise containing the timestamp, delete status and collected data flag */ export type AnalyticsPrivacyControllerCheckDataDeleteStatusAction = { type: `AnalyticsPrivacyController:checkDataDeleteStatus`; @@ -27,14 +27,15 @@ export type AnalyticsPrivacyControllerCheckDataDeleteStatusAction = { }; /** - * Get the latest delete regulation request date. + * Get the latest delete regulation request timestamp. * - * @returns The date as a DD/MM/YYYY string, or undefined + * @returns The timestamp (in milliseconds since epoch), or undefined */ -export type AnalyticsPrivacyControllerGetDeleteRegulationCreationDateAction = { - type: `AnalyticsPrivacyController:getDeleteRegulationCreationDate`; - handler: AnalyticsPrivacyController['getDeleteRegulationCreationDate']; -}; +export type AnalyticsPrivacyControllerGetDeleteRegulationCreationTimestampAction = + { + type: `AnalyticsPrivacyController:getDeleteRegulationCreationTimestamp`; + handler: AnalyticsPrivacyController['getDeleteRegulationCreationTimestamp']; + }; /** * Get the latest delete regulation request id. @@ -74,7 +75,7 @@ export type AnalyticsPrivacyControllerUpdateDataRecordingFlagAction = { export type AnalyticsPrivacyControllerMethodActions = | AnalyticsPrivacyControllerCreateDataDeletionTaskAction | AnalyticsPrivacyControllerCheckDataDeleteStatusAction - | AnalyticsPrivacyControllerGetDeleteRegulationCreationDateAction + | AnalyticsPrivacyControllerGetDeleteRegulationCreationTimestampAction | AnalyticsPrivacyControllerGetDeleteRegulationIdAction | AnalyticsPrivacyControllerIsDataRecordedAction | AnalyticsPrivacyControllerUpdateDataRecordingFlagAction; diff --git a/packages/analytics-privacy-controller/src/AnalyticsPrivacyController.test.ts b/packages/analytics-privacy-controller/src/AnalyticsPrivacyController.test.ts index b81d41c27aa..146a29ce2e7 100644 --- a/packages/analytics-privacy-controller/src/AnalyticsPrivacyController.test.ts +++ b/packages/analytics-privacy-controller/src/AnalyticsPrivacyController.test.ts @@ -1,3 +1,4 @@ +import type { AnalyticsControllerState } from '@metamask/analytics-controller'; import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; import type { MockAnyNamespace } from '@metamask/messenger'; @@ -12,7 +13,6 @@ import type { AnalyticsPrivacyControllerState, } from '.'; import type { AnalyticsPrivacyServiceActions } from './AnalyticsPrivacyService'; -import type { AnalyticsControllerState } from '@metamask/analytics-controller'; import { DataDeleteResponseStatus, DataDeleteStatus } from './types'; type SetupControllerOptions = { @@ -24,7 +24,12 @@ type SetupControllerReturn = { messenger: AnalyticsPrivacyControllerMessenger; rootMessenger: Messenger< MockAnyNamespace, - AnalyticsPrivacyControllerActions | AnalyticsPrivacyServiceActions | { type: 'AnalyticsController:getState'; handler: () => { analyticsId: string; optedIn: boolean } }, + | AnalyticsPrivacyControllerActions + | AnalyticsPrivacyServiceActions + | { + type: 'AnalyticsController:getState'; + handler: () => { analyticsId: string; optedIn: boolean }; + }, AnalyticsPrivacyControllerEvents >; }; @@ -43,13 +48,23 @@ function setupController( const rootMessenger = new Messenger< MockAnyNamespace, - AnalyticsPrivacyControllerActions | AnalyticsPrivacyServiceActions | { type: 'AnalyticsController:getState'; handler: () => { analyticsId: string; optedIn: boolean } }, + | AnalyticsPrivacyControllerActions + | AnalyticsPrivacyServiceActions + | { + type: 'AnalyticsController:getState'; + handler: () => { analyticsId: string; optedIn: boolean }; + }, AnalyticsPrivacyControllerEvents >({ namespace: MOCK_ANY_NAMESPACE }); const analyticsPrivacyControllerMessenger = new Messenger< 'AnalyticsPrivacyController', - AnalyticsPrivacyControllerActions | AnalyticsPrivacyServiceActions | { type: 'AnalyticsController:getState'; handler: () => { analyticsId: string; optedIn: boolean } }, + | AnalyticsPrivacyControllerActions + | AnalyticsPrivacyServiceActions + | { + type: 'AnalyticsController:getState'; + handler: () => { analyticsId: string; optedIn: boolean }; + }, AnalyticsPrivacyControllerEvents, typeof rootMessenger >({ @@ -110,7 +125,7 @@ describe('AnalyticsPrivacyController', () => { expect(defaults).toStrictEqual({ dataRecorded: false, deleteRegulationId: null, - deleteRegulationDate: null, + deleteRegulationTimestamp: null, }); }); @@ -135,7 +150,7 @@ describe('AnalyticsPrivacyController', () => { const initialState = { dataRecorded: true, deleteRegulationId: 'existing-id', - deleteRegulationDate: '01/01/2024', + deleteRegulationTimestamp: new Date('2026-01-15T12:00:00Z').getTime(), }; const { controller } = setupController({ state: initialState }); @@ -152,7 +167,7 @@ describe('AnalyticsPrivacyController', () => { expect(controller.state.dataRecorded).toBe(true); expect(controller.state.deleteRegulationId).toBeNull(); - expect(controller.state.deleteRegulationDate).toBeNull(); + expect(controller.state.deleteRegulationTimestamp).toBeNull(); }); }); @@ -160,6 +175,10 @@ describe('AnalyticsPrivacyController', () => { it('creates a data deletion task and updates state', async () => { const { controller, rootMessenger } = setupController(); + const fixedTimestamp = new Date('2026-01-15T12:00:00Z').getTime(); + jest.useFakeTimers(); + jest.setSystemTime(new Date(fixedTimestamp)); + const response = await rootMessenger.call( 'AnalyticsPrivacyController:createDataDeletionTask', ); @@ -167,22 +186,32 @@ describe('AnalyticsPrivacyController', () => { expect(response.status).toBe(DataDeleteResponseStatus.ok); expect(response.regulateId).toBe('test-regulate-id'); expect(controller.state.deleteRegulationId).toBe('test-regulate-id'); - expect(controller.state.deleteRegulationDate).toMatch( - /^\d{1,2}\/\d{1,2}\/\d{4}$/, - ); + expect(controller.state.deleteRegulationTimestamp).toBe(fixedTimestamp); expect(controller.state.dataRecorded).toBe(false); + + jest.useRealTimers(); }); - it('formats deletion date in DD/MM/YYYY format', async () => { + it('stores deletion timestamp correctly', async () => { const rootMessenger = new Messenger< MockAnyNamespace, - AnalyticsPrivacyControllerActions | AnalyticsPrivacyServiceActions | { type: 'AnalyticsController:getState'; handler: () => { analyticsId: string; optedIn: boolean } }, + | AnalyticsPrivacyControllerActions + | AnalyticsPrivacyServiceActions + | { + type: 'AnalyticsController:getState'; + handler: () => { analyticsId: string; optedIn: boolean }; + }, AnalyticsPrivacyControllerEvents >({ namespace: MOCK_ANY_NAMESPACE }); const analyticsPrivacyControllerMessenger = new Messenger< 'AnalyticsPrivacyController', - AnalyticsPrivacyControllerActions | AnalyticsPrivacyServiceActions | { type: 'AnalyticsController:getState'; handler: () => { analyticsId: string; optedIn: boolean } }, + | AnalyticsPrivacyControllerActions + | AnalyticsPrivacyServiceActions + | { + type: 'AnalyticsController:getState'; + handler: () => { analyticsId: string; optedIn: boolean }; + }, AnalyticsPrivacyControllerEvents, typeof rootMessenger >({ @@ -190,10 +219,13 @@ describe('AnalyticsPrivacyController', () => { parent: rootMessenger, }); - rootMessenger.registerActionHandler('AnalyticsController:getState', () => ({ - analyticsId: 'test-analytics-id', - optedIn: true, - })); + rootMessenger.registerActionHandler( + 'AnalyticsController:getState', + () => ({ + analyticsId: 'test-analytics-id', + optedIn: true, + }), + ); rootMessenger.registerActionHandler( 'AnalyticsPrivacyService:createDataDeletionTask', @@ -214,17 +246,17 @@ describe('AnalyticsPrivacyController', () => { const controller = new AnalyticsPrivacyController({ messenger: analyticsPrivacyControllerMessenger, }); - - const fixedDate = new Date('2024-01-15T12:00:00Z'); + + const fixedTimestamp = new Date('2026-01-15T12:00:00Z').getTime(); jest.useFakeTimers(); - jest.setSystemTime(fixedDate); + jest.setSystemTime(new Date(fixedTimestamp)); await rootMessenger.call( 'AnalyticsPrivacyController:createDataDeletionTask', ); - // Note: getUTCDate() returns 15, getUTCMonth() returns 0 (January), so +1 = 1 - expect(controller.state.deleteRegulationDate).toBe('15/01/2024'); + expect(controller.state.deleteRegulationTimestamp).toBe(fixedTimestamp); + expect(typeof controller.state.deleteRegulationTimestamp).toBe('number'); jest.useRealTimers(); }); @@ -245,7 +277,7 @@ describe('AnalyticsPrivacyController', () => { // Verify the response is correct first expect(response.status).toBe(DataDeleteResponseStatus.ok); expect(response.regulateId).toBe('test-regulate-id'); - + // Then verify the event was emitted expect(eventListener).toHaveBeenCalledWith({ status: DataDeleteResponseStatus.ok, @@ -256,13 +288,23 @@ describe('AnalyticsPrivacyController', () => { it('returns error if analyticsId is missing from AnalyticsController state', async () => { const rootMessenger = new Messenger< MockAnyNamespace, - AnalyticsPrivacyControllerActions | AnalyticsPrivacyServiceActions | { type: 'AnalyticsController:getState'; handler: () => AnalyticsControllerState }, + | AnalyticsPrivacyControllerActions + | AnalyticsPrivacyServiceActions + | { + type: 'AnalyticsController:getState'; + handler: () => AnalyticsControllerState; + }, AnalyticsPrivacyControllerEvents >({ namespace: MOCK_ANY_NAMESPACE }); const analyticsPrivacyControllerMessenger = new Messenger< 'AnalyticsPrivacyController', - AnalyticsPrivacyControllerActions | AnalyticsPrivacyServiceActions | { type: 'AnalyticsController:getState'; handler: () => AnalyticsControllerState }, + | AnalyticsPrivacyControllerActions + | AnalyticsPrivacyServiceActions + | { + type: 'AnalyticsController:getState'; + handler: () => AnalyticsControllerState; + }, AnalyticsPrivacyControllerEvents, typeof rootMessenger >({ @@ -270,10 +312,13 @@ describe('AnalyticsPrivacyController', () => { parent: rootMessenger, }); - rootMessenger.registerActionHandler('AnalyticsController:getState', () => ({ - analyticsId: '', // Empty string to test the !analyticsId check - optedIn: true, - })); + rootMessenger.registerActionHandler( + 'AnalyticsController:getState', + () => ({ + analyticsId: '', // Empty string to test the !analyticsId check + optedIn: true, + }), + ); rootMessenger.registerActionHandler( 'AnalyticsPrivacyService:createDataDeletionTask', @@ -291,7 +336,9 @@ describe('AnalyticsPrivacyController', () => { ], }); - const controller = new AnalyticsPrivacyController({ + // Controller is instantiated to register action handlers + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const _controller = new AnalyticsPrivacyController({ messenger: analyticsPrivacyControllerMessenger, }); @@ -306,13 +353,23 @@ describe('AnalyticsPrivacyController', () => { it('handles service response with undefined regulateId', async () => { const rootMessenger = new Messenger< MockAnyNamespace, - AnalyticsPrivacyControllerActions | AnalyticsPrivacyServiceActions | { type: 'AnalyticsController:getState'; handler: () => AnalyticsControllerState }, + | AnalyticsPrivacyControllerActions + | AnalyticsPrivacyServiceActions + | { + type: 'AnalyticsController:getState'; + handler: () => AnalyticsControllerState; + }, AnalyticsPrivacyControllerEvents >({ namespace: MOCK_ANY_NAMESPACE }); const analyticsPrivacyControllerMessenger = new Messenger< 'AnalyticsPrivacyController', - AnalyticsPrivacyControllerActions | AnalyticsPrivacyServiceActions | { type: 'AnalyticsController:getState'; handler: () => AnalyticsControllerState }, + | AnalyticsPrivacyControllerActions + | AnalyticsPrivacyServiceActions + | { + type: 'AnalyticsController:getState'; + handler: () => AnalyticsControllerState; + }, AnalyticsPrivacyControllerEvents, typeof rootMessenger >({ @@ -320,10 +377,13 @@ describe('AnalyticsPrivacyController', () => { parent: rootMessenger, }); - rootMessenger.registerActionHandler('AnalyticsController:getState', () => ({ - analyticsId: 'test-analytics-id', - optedIn: true, - })); + rootMessenger.registerActionHandler( + 'AnalyticsController:getState', + () => ({ + analyticsId: 'test-analytics-id', + optedIn: true, + }), + ); rootMessenger.registerActionHandler( 'AnalyticsPrivacyService:createDataDeletionTask', @@ -341,7 +401,9 @@ describe('AnalyticsPrivacyController', () => { ], }); - const controller = new AnalyticsPrivacyController({ + // Controller is instantiated to register action handlers + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const _controller = new AnalyticsPrivacyController({ messenger: analyticsPrivacyControllerMessenger, }); @@ -351,20 +413,28 @@ describe('AnalyticsPrivacyController', () => { expect(response.status).toBe(DataDeleteResponseStatus.ok); expect(response.regulateId).toBeUndefined(); - // State should not be updated when regulateId is missing (condition fails) - expect(controller.state.deleteRegulationId).toBeNull(); }); it('handles empty string regulateId (falsy but not null/undefined)', async () => { const rootMessenger = new Messenger< MockAnyNamespace, - AnalyticsPrivacyControllerActions | AnalyticsPrivacyServiceActions | { type: 'AnalyticsController:getState'; handler: () => AnalyticsControllerState }, + | AnalyticsPrivacyControllerActions + | AnalyticsPrivacyServiceActions + | { + type: 'AnalyticsController:getState'; + handler: () => AnalyticsControllerState; + }, AnalyticsPrivacyControllerEvents >({ namespace: MOCK_ANY_NAMESPACE }); const analyticsPrivacyControllerMessenger = new Messenger< 'AnalyticsPrivacyController', - AnalyticsPrivacyControllerActions | AnalyticsPrivacyServiceActions | { type: 'AnalyticsController:getState'; handler: () => AnalyticsControllerState }, + | AnalyticsPrivacyControllerActions + | AnalyticsPrivacyServiceActions + | { + type: 'AnalyticsController:getState'; + handler: () => AnalyticsControllerState; + }, AnalyticsPrivacyControllerEvents, typeof rootMessenger >({ @@ -372,10 +442,13 @@ describe('AnalyticsPrivacyController', () => { parent: rootMessenger, }); - rootMessenger.registerActionHandler('AnalyticsController:getState', () => ({ - analyticsId: 'test-analytics-id', - optedIn: true, - })); + rootMessenger.registerActionHandler( + 'AnalyticsController:getState', + () => ({ + analyticsId: 'test-analytics-id', + optedIn: true, + }), + ); // Empty string is falsy, so condition fails and we don't enter the block // But this tests the edge case @@ -408,16 +481,26 @@ describe('AnalyticsPrivacyController', () => { expect(controller.state.deleteRegulationId).toBeNull(); }); - it('handles null deleteRegulationDate in status', async () => { + it('handles null deleteRegulationTimestamp in status', async () => { const rootMessenger = new Messenger< MockAnyNamespace, - AnalyticsPrivacyControllerActions | AnalyticsPrivacyServiceActions | { type: 'AnalyticsController:getState'; handler: () => AnalyticsControllerState }, + | AnalyticsPrivacyControllerActions + | AnalyticsPrivacyServiceActions + | { + type: 'AnalyticsController:getState'; + handler: () => AnalyticsControllerState; + }, AnalyticsPrivacyControllerEvents >({ namespace: MOCK_ANY_NAMESPACE }); const analyticsPrivacyControllerMessenger = new Messenger< 'AnalyticsPrivacyController', - AnalyticsPrivacyControllerActions | AnalyticsPrivacyServiceActions | { type: 'AnalyticsController:getState'; handler: () => AnalyticsControllerState }, + | AnalyticsPrivacyControllerActions + | AnalyticsPrivacyServiceActions + | { + type: 'AnalyticsController:getState'; + handler: () => AnalyticsControllerState; + }, AnalyticsPrivacyControllerEvents, typeof rootMessenger >({ @@ -425,10 +508,13 @@ describe('AnalyticsPrivacyController', () => { parent: rootMessenger, }); - rootMessenger.registerActionHandler('AnalyticsController:getState', () => ({ - analyticsId: 'test-analytics-id', - optedIn: true, - })); + rootMessenger.registerActionHandler( + 'AnalyticsController:getState', + () => ({ + analyticsId: 'test-analytics-id', + optedIn: true, + }), + ); // Mock a response where regulateId is explicitly undefined (to test ?? null) rootMessenger.registerActionHandler( @@ -463,13 +549,23 @@ describe('AnalyticsPrivacyController', () => { it('returns error if AnalyticsController:getState fails', async () => { const rootMessenger = new Messenger< MockAnyNamespace, - AnalyticsPrivacyControllerActions | AnalyticsPrivacyServiceActions | { type: 'AnalyticsController:getState'; handler: () => { analyticsId: string; optedIn: boolean } }, + | AnalyticsPrivacyControllerActions + | AnalyticsPrivacyServiceActions + | { + type: 'AnalyticsController:getState'; + handler: () => { analyticsId: string; optedIn: boolean }; + }, AnalyticsPrivacyControllerEvents >({ namespace: MOCK_ANY_NAMESPACE }); const analyticsPrivacyControllerMessenger = new Messenger< 'AnalyticsPrivacyController', - AnalyticsPrivacyControllerActions | AnalyticsPrivacyServiceActions | { type: 'AnalyticsController:getState'; handler: () => { analyticsId: string; optedIn: boolean } }, + | AnalyticsPrivacyControllerActions + | AnalyticsPrivacyServiceActions + | { + type: 'AnalyticsController:getState'; + handler: () => { analyticsId: string; optedIn: boolean }; + }, AnalyticsPrivacyControllerEvents, typeof rootMessenger >({ @@ -477,9 +573,12 @@ describe('AnalyticsPrivacyController', () => { parent: rootMessenger, }); - rootMessenger.registerActionHandler('AnalyticsController:getState', () => { - throw new Error('Analytics ID not found'); - }); + rootMessenger.registerActionHandler( + 'AnalyticsController:getState', + () => { + throw new Error('Analytics ID not found'); + }, + ); rootMessenger.registerActionHandler( 'AnalyticsPrivacyService:createDataDeletionTask', @@ -489,7 +588,17 @@ describe('AnalyticsPrivacyController', () => { }), ); - const controller = new AnalyticsPrivacyController({ + rootMessenger.delegate({ + messenger: analyticsPrivacyControllerMessenger, + actions: [ + 'AnalyticsPrivacyService:createDataDeletionTask', + 'AnalyticsController:getState', + ], + }); + + // Controller is instantiated to register action handlers + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const _controller = new AnalyticsPrivacyController({ messenger: analyticsPrivacyControllerMessenger, }); @@ -498,20 +607,29 @@ describe('AnalyticsPrivacyController', () => { ); expect(response.status).toBe(DataDeleteResponseStatus.error); - expect(response.error).toBe('Analytics Deletion Task Error'); - + expect(response.error).toBe('Analytics ID not found'); }); it('returns error if service call fails', async () => { const rootMessenger = new Messenger< MockAnyNamespace, - AnalyticsPrivacyControllerActions | AnalyticsPrivacyServiceActions | { type: 'AnalyticsController:getState'; handler: () => { analyticsId: string; optedIn: boolean } }, + | AnalyticsPrivacyControllerActions + | AnalyticsPrivacyServiceActions + | { + type: 'AnalyticsController:getState'; + handler: () => { analyticsId: string; optedIn: boolean }; + }, AnalyticsPrivacyControllerEvents >({ namespace: MOCK_ANY_NAMESPACE }); const analyticsPrivacyControllerMessenger = new Messenger< 'AnalyticsPrivacyController', - AnalyticsPrivacyControllerActions | AnalyticsPrivacyServiceActions | { type: 'AnalyticsController:getState'; handler: () => { analyticsId: string; optedIn: boolean } }, + | AnalyticsPrivacyControllerActions + | AnalyticsPrivacyServiceActions + | { + type: 'AnalyticsController:getState'; + handler: () => { analyticsId: string; optedIn: boolean }; + }, AnalyticsPrivacyControllerEvents, typeof rootMessenger >({ @@ -519,10 +637,13 @@ describe('AnalyticsPrivacyController', () => { parent: rootMessenger, }); - rootMessenger.registerActionHandler('AnalyticsController:getState', () => ({ - analyticsId: 'test-analytics-id', - optedIn: true, - })); + rootMessenger.registerActionHandler( + 'AnalyticsController:getState', + () => ({ + analyticsId: 'test-analytics-id', + optedIn: true, + }), + ); rootMessenger.registerActionHandler( 'AnalyticsPrivacyService:createDataDeletionTask', @@ -532,7 +653,17 @@ describe('AnalyticsPrivacyController', () => { }), ); - const controller = new AnalyticsPrivacyController({ + rootMessenger.delegate({ + messenger: analyticsPrivacyControllerMessenger, + actions: [ + 'AnalyticsPrivacyService:createDataDeletionTask', + 'AnalyticsController:getState', + ], + }); + + // Controller is instantiated to register action handlers + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const _controller = new AnalyticsPrivacyController({ messenger: analyticsPrivacyControllerMessenger, }); @@ -541,19 +672,29 @@ describe('AnalyticsPrivacyController', () => { ); expect(response.status).toBe(DataDeleteResponseStatus.error); - expect(response.error).toBe('Analytics Deletion Task Error'); + expect(response.error).toBe('Service error'); }); it('does not update state if service returns error', async () => { const rootMessenger = new Messenger< MockAnyNamespace, - AnalyticsPrivacyControllerActions | AnalyticsPrivacyServiceActions | { type: 'AnalyticsController:getState'; handler: () => { analyticsId: string; optedIn: boolean } }, + | AnalyticsPrivacyControllerActions + | AnalyticsPrivacyServiceActions + | { + type: 'AnalyticsController:getState'; + handler: () => { analyticsId: string; optedIn: boolean }; + }, AnalyticsPrivacyControllerEvents >({ namespace: MOCK_ANY_NAMESPACE }); const analyticsPrivacyControllerMessenger = new Messenger< 'AnalyticsPrivacyController', - AnalyticsPrivacyControllerActions | AnalyticsPrivacyServiceActions | { type: 'AnalyticsController:getState'; handler: () => { analyticsId: string; optedIn: boolean } }, + | AnalyticsPrivacyControllerActions + | AnalyticsPrivacyServiceActions + | { + type: 'AnalyticsController:getState'; + handler: () => { analyticsId: string; optedIn: boolean }; + }, AnalyticsPrivacyControllerEvents, typeof rootMessenger >({ @@ -561,10 +702,13 @@ describe('AnalyticsPrivacyController', () => { parent: rootMessenger, }); - rootMessenger.registerActionHandler('AnalyticsController:getState', () => ({ - analyticsId: 'test-analytics-id', - optedIn: true, - })); + rootMessenger.registerActionHandler( + 'AnalyticsController:getState', + () => ({ + analyticsId: 'test-analytics-id', + optedIn: true, + }), + ); rootMessenger.registerActionHandler( 'AnalyticsPrivacyService:createDataDeletionTask', @@ -589,10 +733,11 @@ describe('AnalyticsPrivacyController', () => { describe('AnalyticsPrivacyController:checkDataDeleteStatus', () => { it('returns status with all fields when regulationId exists', async () => { - const { controller, rootMessenger } = setupController({ + const testTimestamp = new Date('2026-01-15T12:00:00Z').getTime(); + const { rootMessenger } = setupController({ state: { deleteRegulationId: 'test-regulation-id', - deleteRegulationDate: '15/01/2024', + deleteRegulationTimestamp: testTimestamp, dataRecorded: true, }, }); @@ -602,7 +747,7 @@ describe('AnalyticsPrivacyController', () => { ); expect(status).toStrictEqual({ - deletionRequestDate: '15/01/2024', + deletionRequestTimestamp: testTimestamp, dataDeletionRequestStatus: DataDeleteStatus.finished, hasCollectedDataSinceDeletionRequest: true, }); @@ -616,22 +761,32 @@ describe('AnalyticsPrivacyController', () => { ); expect(status).toStrictEqual({ - deletionRequestDate: undefined, + deletionRequestTimestamp: undefined, dataDeletionRequestStatus: DataDeleteStatus.unknown, hasCollectedDataSinceDeletionRequest: false, }); }); - it('handles null deleteRegulationDate in status', async () => { + it('handles null deleteRegulationTimestamp in status', async () => { const rootMessenger = new Messenger< MockAnyNamespace, - AnalyticsPrivacyControllerActions | AnalyticsPrivacyServiceActions | { type: 'AnalyticsController:getState'; handler: () => AnalyticsControllerState }, + | AnalyticsPrivacyControllerActions + | AnalyticsPrivacyServiceActions + | { + type: 'AnalyticsController:getState'; + handler: () => AnalyticsControllerState; + }, AnalyticsPrivacyControllerEvents >({ namespace: MOCK_ANY_NAMESPACE }); const analyticsPrivacyControllerMessenger = new Messenger< 'AnalyticsPrivacyController', - AnalyticsPrivacyControllerActions | AnalyticsPrivacyServiceActions | { type: 'AnalyticsController:getState'; handler: () => AnalyticsControllerState }, + | AnalyticsPrivacyControllerActions + | AnalyticsPrivacyServiceActions + | { + type: 'AnalyticsController:getState'; + handler: () => AnalyticsControllerState; + }, AnalyticsPrivacyControllerEvents, typeof rootMessenger >({ @@ -639,10 +794,13 @@ describe('AnalyticsPrivacyController', () => { parent: rootMessenger, }); - rootMessenger.registerActionHandler('AnalyticsController:getState', () => ({ - analyticsId: 'test-analytics-id', - optedIn: true, - })); + rootMessenger.registerActionHandler( + 'AnalyticsController:getState', + () => ({ + analyticsId: 'test-analytics-id', + optedIn: true, + }), + ); rootMessenger.registerActionHandler( 'AnalyticsPrivacyService:checkDataDeleteStatus', @@ -660,11 +818,12 @@ describe('AnalyticsPrivacyController', () => { ], }); - const controller = new AnalyticsPrivacyController({ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const _controller = new AnalyticsPrivacyController({ messenger: analyticsPrivacyControllerMessenger, state: { deleteRegulationId: 'test-regulation-id', - deleteRegulationDate: null, // null date + deleteRegulationTimestamp: null, // null timestamp dataRecorded: false, }, }); @@ -673,20 +832,30 @@ describe('AnalyticsPrivacyController', () => { 'AnalyticsPrivacyController:checkDataDeleteStatus', ); - expect(status.deletionRequestDate).toBeUndefined(); + expect(status.deletionRequestTimestamp).toBeUndefined(); expect(status.dataDeletionRequestStatus).toBe(DataDeleteStatus.finished); }); it('handles service errors gracefully', async () => { const rootMessenger = new Messenger< MockAnyNamespace, - AnalyticsPrivacyControllerActions | AnalyticsPrivacyServiceActions | { type: 'AnalyticsController:getState'; handler: () => { analyticsId: string; optedIn: boolean } }, + | AnalyticsPrivacyControllerActions + | AnalyticsPrivacyServiceActions + | { + type: 'AnalyticsController:getState'; + handler: () => { analyticsId: string; optedIn: boolean }; + }, AnalyticsPrivacyControllerEvents >({ namespace: MOCK_ANY_NAMESPACE }); const analyticsPrivacyControllerMessenger = new Messenger< 'AnalyticsPrivacyController', - AnalyticsPrivacyControllerActions | AnalyticsPrivacyServiceActions | { type: 'AnalyticsController:getState'; handler: () => { analyticsId: string; optedIn: boolean } }, + | AnalyticsPrivacyControllerActions + | AnalyticsPrivacyServiceActions + | { + type: 'AnalyticsController:getState'; + handler: () => { analyticsId: string; optedIn: boolean }; + }, AnalyticsPrivacyControllerEvents, typeof rootMessenger >({ @@ -694,21 +863,26 @@ describe('AnalyticsPrivacyController', () => { parent: rootMessenger, }); - rootMessenger.registerActionHandler('AnalyticsController:getState', () => ({ - analyticsId: 'test-analytics-id', - optedIn: true, - })); + rootMessenger.registerActionHandler( + 'AnalyticsController:getState', + () => ({ + analyticsId: 'test-analytics-id', + optedIn: true, + }), + ); rootMessenger.registerActionHandler( 'AnalyticsPrivacyService:checkDataDeleteStatus', jest.fn().mockRejectedValue(new Error('Service error')), ); - const controller = new AnalyticsPrivacyController({ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const testTimestamp = new Date('2026-01-15T12:00:00Z').getTime(); + const _controller = new AnalyticsPrivacyController({ messenger: analyticsPrivacyControllerMessenger, state: { deleteRegulationId: 'test-regulation-id', - deleteRegulationDate: '15/01/2024', + deleteRegulationTimestamp: testTimestamp, dataRecorded: false, }, }); @@ -718,34 +892,35 @@ describe('AnalyticsPrivacyController', () => { ); expect(status.dataDeletionRequestStatus).toBe(DataDeleteStatus.unknown); - expect(status.deletionRequestDate).toBe('15/01/2024'); + expect(status.deletionRequestTimestamp).toBe(testTimestamp); expect(status.hasCollectedDataSinceDeletionRequest).toBe(false); }); }); - describe('AnalyticsPrivacyController:getDeleteRegulationCreationDate', () => { - it('returns the deletion date when set', () => { - const { controller, rootMessenger } = setupController({ + describe('AnalyticsPrivacyController:getDeleteRegulationCreationTimestamp', () => { + it('returns the deletion timestamp when set', () => { + const testTimestamp = new Date('2026-01-15T12:00:00Z').getTime(); + const { rootMessenger } = setupController({ state: { - deleteRegulationDate: '15/01/2024', + deleteRegulationTimestamp: testTimestamp, }, }); - const date = rootMessenger.call( - 'AnalyticsPrivacyController:getDeleteRegulationCreationDate', + const timestamp = rootMessenger.call( + 'AnalyticsPrivacyController:getDeleteRegulationCreationTimestamp', ); - expect(date).toBe('15/01/2024'); + expect(timestamp).toBe(testTimestamp); }); - it('returns undefined when deletion date is not set', () => { + it('returns undefined when deletion timestamp is not set', () => { const { rootMessenger } = setupController(); - const date = rootMessenger.call( - 'AnalyticsPrivacyController:getDeleteRegulationCreationDate', + const timestamp = rootMessenger.call( + 'AnalyticsPrivacyController:getDeleteRegulationCreationTimestamp', ); - expect(date).toBeUndefined(); + expect(timestamp).toBeUndefined(); }); }); @@ -854,9 +1029,7 @@ describe('AnalyticsPrivacyController', () => { }, }); - rootMessenger.call( - 'AnalyticsPrivacyController:updateDataRecordingFlag', - ); + rootMessenger.call('AnalyticsPrivacyController:updateDataRecordingFlag'); expect(controller.state.dataRecorded).toBe(true); }); @@ -906,7 +1079,7 @@ describe('AnalyticsPrivacyController', () => { describe('stateChange event', () => { it('emits stateChange event when state is updated', () => { - const { controller, rootMessenger, messenger } = setupController(); + const { rootMessenger, messenger } = setupController(); const eventListener = jest.fn(); messenger.subscribe( diff --git a/packages/analytics-privacy-controller/src/AnalyticsPrivacyController.ts b/packages/analytics-privacy-controller/src/AnalyticsPrivacyController.ts index d72f960c272..4c5ac93e649 100644 --- a/packages/analytics-privacy-controller/src/AnalyticsPrivacyController.ts +++ b/packages/analytics-privacy-controller/src/AnalyticsPrivacyController.ts @@ -1,3 +1,4 @@ +import type { AnalyticsControllerGetStateAction } from '@metamask/analytics-controller'; import type { ControllerGetStateAction, ControllerStateChangeEvent, @@ -6,15 +7,13 @@ import type { import { BaseController } from '@metamask/base-controller'; import type { Messenger } from '@metamask/messenger'; -import type { AnalyticsControllerGetStateAction } from '@metamask/analytics-controller'; -import type { AnalyticsPrivacyServiceActions } from './AnalyticsPrivacyService'; -import { projectLogger as log } from './AnalyticsPrivacyLogger'; import type { AnalyticsPrivacyControllerMethodActions } from './AnalyticsPrivacyController-method-action-types'; -import { - DataDeleteResponseStatus, - DataDeleteStatus, - type IDeleteRegulationResponse, - type IDeleteRegulationStatus, +import { projectLogger as log } from './AnalyticsPrivacyLogger'; +import type { AnalyticsPrivacyServiceActions } from './AnalyticsPrivacyService'; +import { DataDeleteResponseStatus, DataDeleteStatus } from './types'; +import type { + IDeleteRegulationResponse, + IDeleteRegulationStatus, } from './types'; // === GENERAL === @@ -44,10 +43,10 @@ export type AnalyticsPrivacyControllerState = { deleteRegulationId: string | null; /** - * Segment's data deletion regulation creation date. - * The date when the deletion request was created, in DD/MM/YYYY format. + * Segment's data deletion regulation creation timestamp. + * The timestamp (in milliseconds since epoch) when the deletion request was created. */ - deleteRegulationDate: string | null; + deleteRegulationTimestamp: number | null; }; /** @@ -59,7 +58,7 @@ export function getDefaultAnalyticsPrivacyControllerState(): AnalyticsPrivacyCon return { dataRecorded: false, deleteRegulationId: null, - deleteRegulationDate: null, + deleteRegulationTimestamp: null, }; } @@ -79,7 +78,7 @@ const analyticsPrivacyControllerMetadata = { includeInDebugSnapshot: true, usedInUi: true, }, - deleteRegulationDate: { + deleteRegulationTimestamp: { includeInStateLogs: true, persist: true, includeInDebugSnapshot: true, @@ -92,7 +91,7 @@ const analyticsPrivacyControllerMetadata = { const MESSENGER_EXPOSED_METHODS = [ 'createDataDeletionTask', 'checkDataDeleteStatus', - 'getDeleteRegulationCreationDate', + 'getDeleteRegulationCreationTimestamp', 'getDeleteRegulationId', 'isDataRecorded', 'updateDataRecordingFlag', @@ -101,11 +100,10 @@ const MESSENGER_EXPOSED_METHODS = [ /** * Returns the state of the {@link AnalyticsPrivacyController}. */ -export type AnalyticsPrivacyControllerGetStateAction = - ControllerGetStateAction< - typeof controllerName, - AnalyticsPrivacyControllerState - >; +export type AnalyticsPrivacyControllerGetStateAction = ControllerGetStateAction< + typeof controllerName, + AnalyticsPrivacyControllerState +>; /** * Actions that {@link AnalyticsPrivacyControllerMessenger} exposes to other consumers. @@ -204,10 +202,7 @@ export class AnalyticsPrivacyController extends BaseController< * @param options.state - Initial controller state. Use `getDefaultAnalyticsPrivacyControllerState()` for defaults. * @param options.messenger - Messenger used to communicate with BaseController */ - constructor({ - state = {}, - messenger, - }: AnalyticsPrivacyControllerOptions) { + constructor({ state = {}, messenger }: AnalyticsPrivacyControllerOptions) { const initialState: AnalyticsPrivacyControllerState = { ...getDefaultAnalyticsPrivacyControllerState(), ...state, @@ -227,8 +222,8 @@ export class AnalyticsPrivacyController extends BaseController< log('AnalyticsPrivacyController initialized', { dataRecorded: this.state.dataRecorded, - hasDeleteRegulationId: !!this.state.deleteRegulationId, - deleteRegulationDate: this.state.deleteRegulationDate, + hasDeleteRegulationId: Boolean(this.state.deleteRegulationId), + deleteRegulationTimestamp: this.state.deleteRegulationTimestamp, }); } @@ -243,10 +238,11 @@ export class AnalyticsPrivacyController extends BaseController< const analyticsControllerState = await this.messenger.call( 'AnalyticsController:getState', ); - const analyticsId = analyticsControllerState.analyticsId; + const { analyticsId } = analyticsControllerState; if (!analyticsId || analyticsId.trim() === '') { - log('Analytics Deletion Task Error', new Error('Analytics ID not found')); + const error = new Error('Analytics ID not found'); + log('Analytics Deletion Task Error', error); return { status: DataDeleteResponseStatus.error, error: 'Analytics ID not found', @@ -264,15 +260,13 @@ export class AnalyticsPrivacyController extends BaseController< typeof response.regulateId === 'string' && response.regulateId.trim() !== '' ) { - const currentDate = new Date(); - const day = currentDate.getUTCDate().toString().padStart(2, '0'); - const month = (currentDate.getUTCMonth() + 1).toString().padStart(2, '0'); - const year = currentDate.getUTCFullYear(); - const deletionDate = `${day}/${month}/${year}`; + const deletionTimestamp = Date.now(); + // Already validated as non-empty string above + const regulateId = response.regulateId; this.update((state) => { - state.deleteRegulationId = response.regulateId as string; - state.deleteRegulationDate = deletionDate; + state.deleteRegulationId = regulateId; + state.deleteRegulationTimestamp = deletionTimestamp; state.dataRecorded = false; }); @@ -285,9 +279,13 @@ export class AnalyticsPrivacyController extends BaseController< return response; } catch (error) { log('Analytics Deletion Task Error', error); + const errorMessage = + error instanceof Error + ? error.message + : 'Analytics Deletion Task Error'; return { status: DataDeleteResponseStatus.error, - error: 'Analytics Deletion Task Error', + error: errorMessage, }; } } @@ -295,11 +293,11 @@ export class AnalyticsPrivacyController extends BaseController< /** * Check the latest delete regulation status. * - * @returns Promise containing the date, delete status and collected data flag + * @returns Promise containing the timestamp, delete status and collected data flag */ async checkDataDeleteStatus(): Promise { const status: IDeleteRegulationStatus = { - deletionRequestDate: undefined, + deletionRequestTimestamp: undefined, dataDeletionRequestStatus: DataDeleteStatus.unknown, hasCollectedDataSinceDeletionRequest: false, }; @@ -321,19 +319,19 @@ export class AnalyticsPrivacyController extends BaseController< status.dataDeletionRequestStatus = DataDeleteStatus.unknown; } - status.deletionRequestDate = this.state.deleteRegulationDate ?? undefined; + status.deletionRequestTimestamp = this.state.deleteRegulationTimestamp ?? undefined; status.hasCollectedDataSinceDeletionRequest = this.state.dataRecorded; return status; } /** - * Get the latest delete regulation request date. + * Get the latest delete regulation request timestamp. * - * @returns The date as a DD/MM/YYYY string, or undefined + * @returns The timestamp (in milliseconds since epoch), or undefined */ - getDeleteRegulationCreationDate(): string | undefined { - return this.state.deleteRegulationDate ?? undefined; + getDeleteRegulationCreationTimestamp(): number | undefined { + return this.state.deleteRegulationTimestamp ?? undefined; } /** diff --git a/packages/analytics-privacy-controller/src/AnalyticsPrivacyLogger.ts b/packages/analytics-privacy-controller/src/AnalyticsPrivacyLogger.ts index 0aa9c90f65f..e17f25aaea6 100644 --- a/packages/analytics-privacy-controller/src/AnalyticsPrivacyLogger.ts +++ b/packages/analytics-privacy-controller/src/AnalyticsPrivacyLogger.ts @@ -2,6 +2,8 @@ import { createProjectLogger, createModuleLogger } from '@metamask/utils'; -export const projectLogger = createProjectLogger('analytics-privacy-controller'); +export const projectLogger = createProjectLogger( + 'analytics-privacy-controller', +); export { createModuleLogger }; diff --git a/packages/analytics-privacy-controller/src/AnalyticsPrivacyService.test.ts b/packages/analytics-privacy-controller/src/AnalyticsPrivacyService.test.ts index 1c1a5b5400a..172a38bf4ed 100644 --- a/packages/analytics-privacy-controller/src/AnalyticsPrivacyService.test.ts +++ b/packages/analytics-privacy-controller/src/AnalyticsPrivacyService.test.ts @@ -1,11 +1,10 @@ -import { HttpError } from '@metamask/controller-utils'; import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; import type { MockAnyNamespace, MessengerActions, MessengerEvents, } from '@metamask/messenger'; -import nock from 'nock'; +import nock, { cleanAll, disableNetConnect, enableNetConnect } from 'nock'; import { useFakeTimers } from 'sinon'; import type { SinonFakeTimers } from 'sinon'; @@ -20,14 +19,14 @@ describe('AnalyticsPrivacyService', () => { beforeEach(() => { clock = useFakeTimers(); - nock.cleanAll(); - nock.disableNetConnect(); + cleanAll(); + disableNetConnect(); }); afterEach(() => { clock.restore(); - nock.cleanAll(); - nock.enableNetConnect(); + cleanAll(); + enableNetConnect(); }); describe('AnalyticsPrivacyService:createDataDeletionTask', () => { @@ -155,7 +154,7 @@ describe('AnalyticsPrivacyService', () => { const regulateId = 'test-regulate-id'; const scope = nock(segmentRegulationsEndpoint) - .post(`/regulations/sources/${segmentSourceId}`, (body) => { + .post(`/regulations/sources/${segmentSourceId}`, (body: unknown) => { const parsedBody = typeof body === 'string' ? JSON.parse(body) : body; return ( parsedBody.regulationType === 'DELETE_ONLY' && @@ -383,12 +382,12 @@ describe('AnalyticsPrivacyService', () => { onRetryListener(); }); - await expect( - rootMessenger.call( + expect( + await rootMessenger.call( 'AnalyticsPrivacyService:createDataDeletionTask', 'test-analytics-id', ), - ).resolves.toMatchObject({ + ).toMatchObject({ status: DataDeleteResponseStatus.error, }); @@ -413,23 +412,23 @@ describe('AnalyticsPrivacyService', () => { // Make 3 failed requests to trigger circuit breaker for (let i = 0; i < 3; i++) { - await expect( - rootMessenger.call( + expect( + await rootMessenger.call( 'AnalyticsPrivacyService:createDataDeletionTask', 'test-analytics-id', ), - ).resolves.toMatchObject({ + ).toMatchObject({ status: DataDeleteResponseStatus.error, }); } // 4th request should trigger circuit breaker - service catches and returns error - await expect( - rootMessenger.call( + expect( + await rootMessenger.call( 'AnalyticsPrivacyService:createDataDeletionTask', 'test-analytics-id', ), - ).resolves.toMatchObject({ + ).toMatchObject({ status: DataDeleteResponseStatus.error, }); @@ -477,12 +476,12 @@ describe('AnalyticsPrivacyService', () => { const onDegradedListener = jest.fn(); service.onDegraded(onDegradedListener); - await expect( - rootMessenger.call( + expect( + await rootMessenger.call( 'AnalyticsPrivacyService:createDataDeletionTask', 'test-analytics-id', ), - ).resolves.toMatchObject({ + ).toMatchObject({ status: DataDeleteResponseStatus.error, }); @@ -539,9 +538,7 @@ function getMessenger( function getService({ options = {}, }: { - options?: Partial< - ConstructorParameters[0] - >; + options?: Partial[0]>; } = {}): { service: AnalyticsPrivacyService; rootMessenger: RootMessenger; @@ -551,12 +548,13 @@ function getService({ const messenger = getMessenger(rootMessenger); const defaultSegmentSourceId = 'test-source-id'; const defaultSegmentRegulationsEndpoint = 'https://proxy.example.com/v1beta'; - + const service = new AnalyticsPrivacyService({ fetch, messenger, segmentSourceId: options.segmentSourceId ?? defaultSegmentSourceId, - segmentRegulationsEndpoint: options.segmentRegulationsEndpoint ?? defaultSegmentRegulationsEndpoint, + segmentRegulationsEndpoint: + options.segmentRegulationsEndpoint ?? defaultSegmentRegulationsEndpoint, ...options, }); diff --git a/packages/analytics-privacy-controller/src/AnalyticsPrivacyService.ts b/packages/analytics-privacy-controller/src/AnalyticsPrivacyService.ts index b824f548f5f..ccec72d11f7 100644 --- a/packages/analytics-privacy-controller/src/AnalyticsPrivacyService.ts +++ b/packages/analytics-privacy-controller/src/AnalyticsPrivacyService.ts @@ -2,26 +2,22 @@ import type { CreateServicePolicyOptions, ServicePolicy, } from '@metamask/controller-utils'; -import { - createServicePolicy, - HttpError, -} from '@metamask/controller-utils'; +import { createServicePolicy, HttpError } from '@metamask/controller-utils'; import type { Messenger } from '@metamask/messenger'; import type { IDisposable } from 'cockatiel'; import { projectLogger as log } from './AnalyticsPrivacyLogger'; -import { - DataDeleteResponseStatus, - DataDeleteStatus, - type IDeleteRegulationResponse, - type IDeleteRegulationStatusResponse, -} from './types'; import type { AnalyticsPrivacyServiceMethodActions } from './AnalyticsPrivacyService-method-action-types'; import { SEGMENT_REGULATION_TYPE_DELETE_ONLY, SEGMENT_SUBJECT_TYPE_USER_ID, SEGMENT_CONTENT_TYPE, } from './constants'; +import { DataDeleteResponseStatus, DataDeleteStatus } from './types'; +import type { + IDeleteRegulationResponse, + IDeleteRegulationStatusResponse, +} from './types'; // === GENERAL === @@ -318,7 +314,10 @@ export class AnalyticsPrivacyService { }; } - log('Analytics Deletion Task Error', new Error('Malformed response from Segment API')); + log( + 'Analytics Deletion Task Error', + new Error('Malformed response from Segment API'), + ); return { status: DataDeleteResponseStatus.error, error: 'Analytics Deletion Task Error', @@ -341,6 +340,7 @@ export class AnalyticsPrivacyService { async checkDataDeleteStatus( regulationId: string, ): Promise { + // Early return if regulationId is missing (cannot check status) or endpoint is not configured if (!regulationId || !this.#segmentRegulationsEndpoint) { return { status: DataDeleteResponseStatus.error, diff --git a/packages/analytics-privacy-controller/src/constants.ts b/packages/analytics-privacy-controller/src/constants.ts index c717d8b56f1..1a40262b881 100644 --- a/packages/analytics-privacy-controller/src/constants.ts +++ b/packages/analytics-privacy-controller/src/constants.ts @@ -2,11 +2,6 @@ * Constants used by the analytics privacy controller and service. */ -/** - * Date format string for deletion regulation creation date (DD/MM/YYYY). - */ -export const DATE_FORMAT_DD_MM_YYYY = 'DD/MM/YYYY'; - /** * Segment API regulation type for DELETE_ONLY operations. */ diff --git a/packages/analytics-privacy-controller/src/selectors.test.ts b/packages/analytics-privacy-controller/src/selectors.test.ts index 481457eb269..9fa852e50a5 100644 --- a/packages/analytics-privacy-controller/src/selectors.test.ts +++ b/packages/analytics-privacy-controller/src/selectors.test.ts @@ -53,26 +53,27 @@ describe('analyticsPrivacyControllerSelectors', () => { }); }); - describe('selectDeleteRegulationDate', () => { - it('returns the deleteRegulationDate when set', () => { + describe('selectDeleteRegulationTimestamp', () => { + it('returns the deleteRegulationTimestamp when set', () => { + const testTimestamp = new Date('2026-01-15T12:00:00Z').getTime(); const state: AnalyticsPrivacyControllerState = { ...getDefaultAnalyticsPrivacyControllerState(), - deleteRegulationDate: '15/01/2024', + deleteRegulationTimestamp: testTimestamp, }; expect( - analyticsPrivacyControllerSelectors.selectDeleteRegulationDate(state), - ).toBe('15/01/2024'); + analyticsPrivacyControllerSelectors.selectDeleteRegulationTimestamp(state), + ).toBe(testTimestamp); }); - it('returns undefined when deleteRegulationDate is null', () => { + it('returns undefined when deleteRegulationTimestamp is null', () => { const state: AnalyticsPrivacyControllerState = { ...getDefaultAnalyticsPrivacyControllerState(), - deleteRegulationDate: null, + deleteRegulationTimestamp: null, }; expect( - analyticsPrivacyControllerSelectors.selectDeleteRegulationDate(state), + analyticsPrivacyControllerSelectors.selectDeleteRegulationTimestamp(state), ).toBeUndefined(); }); }); diff --git a/packages/analytics-privacy-controller/src/selectors.ts b/packages/analytics-privacy-controller/src/selectors.ts index c7098df6d63..adfb2e7fdfe 100644 --- a/packages/analytics-privacy-controller/src/selectors.ts +++ b/packages/analytics-privacy-controller/src/selectors.ts @@ -6,9 +6,8 @@ import type { AnalyticsPrivacyControllerState } from './AnalyticsPrivacyControll * @param state - The controller state * @returns Whether data has been recorded since the last deletion request */ -const selectDataRecorded = ( - state: AnalyticsPrivacyControllerState, -): boolean => state.dataRecorded; +const selectDataRecorded = (state: AnalyticsPrivacyControllerState): boolean => + state.dataRecorded; /** * Selects the delete regulation ID from the controller state. @@ -21,14 +20,14 @@ const selectDeleteRegulationId = ( ): string | undefined => state.deleteRegulationId ?? undefined; /** - * Selects the delete regulation creation date from the controller state. + * Selects the delete regulation creation timestamp from the controller state. * * @param state - The controller state - * @returns The deletion date in DD/MM/YYYY format, or undefined if not set + * @returns The deletion timestamp (in milliseconds since epoch), or undefined if not set */ -const selectDeleteRegulationDate = ( +const selectDeleteRegulationTimestamp = ( state: AnalyticsPrivacyControllerState, -): string | undefined => state.deleteRegulationDate ?? undefined; +): number | undefined => state.deleteRegulationTimestamp ?? undefined; /** * Selectors for the AnalyticsPrivacyController state. @@ -37,5 +36,5 @@ const selectDeleteRegulationDate = ( export const analyticsPrivacyControllerSelectors = { selectDataRecorded, selectDeleteRegulationId, - selectDeleteRegulationDate, + selectDeleteRegulationTimestamp, }; diff --git a/packages/analytics-privacy-controller/src/types.ts b/packages/analytics-privacy-controller/src/types.ts index 8812b1f59e5..c5aad6795ca 100644 --- a/packages/analytics-privacy-controller/src/types.ts +++ b/packages/analytics-privacy-controller/src/types.ts @@ -1,6 +1,8 @@ /** * Status values for data deletion requests from Segment API. + * Enum member names match Segment API response values exactly. */ +/* eslint-disable @typescript-eslint/naming-convention */ export enum DataDeleteStatus { failed = 'FAILED', finished = 'FINISHED', @@ -11,45 +13,49 @@ export enum DataDeleteStatus { running = 'RUNNING', unknown = 'UNKNOWN', } +/* eslint-enable @typescript-eslint/naming-convention */ /** * Response status for deletion regulation operations. + * Enum member names match API response values exactly. */ +/* eslint-disable @typescript-eslint/naming-convention */ export enum DataDeleteResponseStatus { ok = 'ok', error = 'error', } +/* eslint-enable @typescript-eslint/naming-convention */ /** * Response from creating a data deletion task. */ -export interface IDeleteRegulationResponse { +export type IDeleteRegulationResponse = { status: DataDeleteResponseStatus; regulateId?: string; // Using exact API field name from Segment API response error?: string; -} +}; /** * Status information for a data deletion request. */ -export interface IDeleteRegulationStatus { - deletionRequestDate?: string; +export type IDeleteRegulationStatus = { + deletionRequestTimestamp?: number; hasCollectedDataSinceDeletionRequest: boolean; dataDeletionRequestStatus: DataDeleteStatus; -} +}; /** * Response from checking data deletion status. */ -export interface IDeleteRegulationStatusResponse { +export type IDeleteRegulationStatusResponse = { status: DataDeleteResponseStatus; dataDeleteStatus: DataDeleteStatus; -} +}; /** - * Date format for deletion regulation creation date (DD/MM/YYYY). + * Timestamp for deletion regulation creation (milliseconds since epoch). */ -export type DataDeleteDate = string | undefined; +export type DataDeleteTimestamp = number | undefined; /** * Regulation ID from Segment API. diff --git a/yarn.lock b/yarn.lock index d5d470a9d54..f95e66befcd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2564,6 +2564,8 @@ __metadata: "@types/jest": "npm:^27.4.1" deepmerge: "npm:^4.2.2" jest: "npm:^27.5.1" + nock: "npm:^13.3.1" + sinon: "npm:^9.2.4" ts-jest: "npm:^27.1.4" typedoc: "npm:^0.24.8" typedoc-plugin-missing-exports: "npm:^2.0.0" From 59e270195b66cd09ba8da98a51de47b7d9deccd0 Mon Sep 17 00:00:00 2001 From: Nicolas MASSART Date: Thu, 15 Jan 2026 18:31:55 +0100 Subject: [PATCH 03/12] fix: format and lint --- .../src/AnalyticsPrivacyController.test.ts | 3 ++- .../src/AnalyticsPrivacyController.ts | 7 ++++--- .../analytics-privacy-controller/src/selectors.test.ts | 8 ++++++-- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/packages/analytics-privacy-controller/src/AnalyticsPrivacyController.test.ts b/packages/analytics-privacy-controller/src/AnalyticsPrivacyController.test.ts index 146a29ce2e7..5ad0fe2b2e7 100644 --- a/packages/analytics-privacy-controller/src/AnalyticsPrivacyController.test.ts +++ b/packages/analytics-privacy-controller/src/AnalyticsPrivacyController.test.ts @@ -876,8 +876,9 @@ describe('AnalyticsPrivacyController', () => { jest.fn().mockRejectedValue(new Error('Service error')), ); - // eslint-disable-next-line @typescript-eslint/no-unused-vars const testTimestamp = new Date('2026-01-15T12:00:00Z').getTime(); + // Controller is instantiated to register action handlers + // eslint-disable-next-line @typescript-eslint/no-unused-vars const _controller = new AnalyticsPrivacyController({ messenger: analyticsPrivacyControllerMessenger, state: { diff --git a/packages/analytics-privacy-controller/src/AnalyticsPrivacyController.ts b/packages/analytics-privacy-controller/src/AnalyticsPrivacyController.ts index 4c5ac93e649..f046d51abf9 100644 --- a/packages/analytics-privacy-controller/src/AnalyticsPrivacyController.ts +++ b/packages/analytics-privacy-controller/src/AnalyticsPrivacyController.ts @@ -235,7 +235,7 @@ export class AnalyticsPrivacyController extends BaseController< */ async createDataDeletionTask(): Promise { try { - const analyticsControllerState = await this.messenger.call( + const analyticsControllerState = this.messenger.call( 'AnalyticsController:getState', ); const { analyticsId } = analyticsControllerState; @@ -262,7 +262,7 @@ export class AnalyticsPrivacyController extends BaseController< ) { const deletionTimestamp = Date.now(); // Already validated as non-empty string above - const regulateId = response.regulateId; + const { regulateId } = response; this.update((state) => { state.deleteRegulationId = regulateId; @@ -319,7 +319,8 @@ export class AnalyticsPrivacyController extends BaseController< status.dataDeletionRequestStatus = DataDeleteStatus.unknown; } - status.deletionRequestTimestamp = this.state.deleteRegulationTimestamp ?? undefined; + status.deletionRequestTimestamp = + this.state.deleteRegulationTimestamp ?? undefined; status.hasCollectedDataSinceDeletionRequest = this.state.dataRecorded; return status; diff --git a/packages/analytics-privacy-controller/src/selectors.test.ts b/packages/analytics-privacy-controller/src/selectors.test.ts index 9fa852e50a5..bcdda4080aa 100644 --- a/packages/analytics-privacy-controller/src/selectors.test.ts +++ b/packages/analytics-privacy-controller/src/selectors.test.ts @@ -62,7 +62,9 @@ describe('analyticsPrivacyControllerSelectors', () => { }; expect( - analyticsPrivacyControllerSelectors.selectDeleteRegulationTimestamp(state), + analyticsPrivacyControllerSelectors.selectDeleteRegulationTimestamp( + state, + ), ).toBe(testTimestamp); }); @@ -73,7 +75,9 @@ describe('analyticsPrivacyControllerSelectors', () => { }; expect( - analyticsPrivacyControllerSelectors.selectDeleteRegulationTimestamp(state), + analyticsPrivacyControllerSelectors.selectDeleteRegulationTimestamp( + state, + ), ).toBeUndefined(); }); }); From 9a658bae723bca5543b6c3264246d9cd8a266637 Mon Sep 17 00:00:00 2001 From: Nicolas MASSART Date: Fri, 16 Jan 2026 12:38:41 +0100 Subject: [PATCH 04/12] fix: update DataDeleteResponseStatus and DataDeleteStatus enums to use PascalCase - Refactored enum values in `DataDeleteResponseStatus` and `DataDeleteStatus` to follow PascalCase naming convention. - Updated all references in the codebase and tests to ensure consistency with the new enum values. - This change enhances code readability and aligns with common TypeScript practices. --- .../src/AnalyticsPrivacyController.test.ts | 52 +++++++++---------- .../src/AnalyticsPrivacyController.ts | 10 ++-- .../src/AnalyticsPrivacyService.test.ts | 40 +++++++------- .../src/AnalyticsPrivacyService.ts | 29 ++++++----- .../analytics-privacy-controller/src/types.ts | 28 +++++----- 5 files changed, 79 insertions(+), 80 deletions(-) diff --git a/packages/analytics-privacy-controller/src/AnalyticsPrivacyController.test.ts b/packages/analytics-privacy-controller/src/AnalyticsPrivacyController.test.ts index 5ad0fe2b2e7..645a9afe776 100644 --- a/packages/analytics-privacy-controller/src/AnalyticsPrivacyController.test.ts +++ b/packages/analytics-privacy-controller/src/AnalyticsPrivacyController.test.ts @@ -82,7 +82,7 @@ function setupController( rootMessenger.registerActionHandler( 'AnalyticsPrivacyService:createDataDeletionTask', jest.fn().mockResolvedValue({ - status: DataDeleteResponseStatus.ok, + status: DataDeleteResponseStatus.Ok, regulateId: 'test-regulate-id', }), ); @@ -90,8 +90,8 @@ function setupController( rootMessenger.registerActionHandler( 'AnalyticsPrivacyService:checkDataDeleteStatus', jest.fn().mockResolvedValue({ - status: DataDeleteResponseStatus.ok, - dataDeleteStatus: DataDeleteStatus.finished, + status: DataDeleteResponseStatus.Ok, + dataDeleteStatus: DataDeleteStatus.Finished, }), ); @@ -183,7 +183,7 @@ describe('AnalyticsPrivacyController', () => { 'AnalyticsPrivacyController:createDataDeletionTask', ); - expect(response.status).toBe(DataDeleteResponseStatus.ok); + expect(response.status).toBe(DataDeleteResponseStatus.Ok); expect(response.regulateId).toBe('test-regulate-id'); expect(controller.state.deleteRegulationId).toBe('test-regulate-id'); expect(controller.state.deleteRegulationTimestamp).toBe(fixedTimestamp); @@ -230,7 +230,7 @@ describe('AnalyticsPrivacyController', () => { rootMessenger.registerActionHandler( 'AnalyticsPrivacyService:createDataDeletionTask', jest.fn().mockResolvedValue({ - status: DataDeleteResponseStatus.ok, + status: DataDeleteResponseStatus.Ok, regulateId: 'test-regulate-id', }), ); @@ -275,12 +275,12 @@ describe('AnalyticsPrivacyController', () => { ); // Verify the response is correct first - expect(response.status).toBe(DataDeleteResponseStatus.ok); + expect(response.status).toBe(DataDeleteResponseStatus.Ok); expect(response.regulateId).toBe('test-regulate-id'); // Then verify the event was emitted expect(eventListener).toHaveBeenCalledWith({ - status: DataDeleteResponseStatus.ok, + status: DataDeleteResponseStatus.Ok, regulateId: 'test-regulate-id', }); }); @@ -323,7 +323,7 @@ describe('AnalyticsPrivacyController', () => { rootMessenger.registerActionHandler( 'AnalyticsPrivacyService:createDataDeletionTask', jest.fn().mockResolvedValue({ - status: DataDeleteResponseStatus.ok, + status: DataDeleteResponseStatus.Ok, regulateId: 'test-regulate-id', }), ); @@ -346,7 +346,7 @@ describe('AnalyticsPrivacyController', () => { 'AnalyticsPrivacyController:createDataDeletionTask', ); - expect(response.status).toBe(DataDeleteResponseStatus.error); + expect(response.status).toBe(DataDeleteResponseStatus.Error); expect(response.error).toBe('Analytics ID not found'); }); @@ -388,7 +388,7 @@ describe('AnalyticsPrivacyController', () => { rootMessenger.registerActionHandler( 'AnalyticsPrivacyService:createDataDeletionTask', jest.fn().mockResolvedValue({ - status: DataDeleteResponseStatus.ok, + status: DataDeleteResponseStatus.Ok, // regulateId is undefined }), ); @@ -411,7 +411,7 @@ describe('AnalyticsPrivacyController', () => { 'AnalyticsPrivacyController:createDataDeletionTask', ); - expect(response.status).toBe(DataDeleteResponseStatus.ok); + expect(response.status).toBe(DataDeleteResponseStatus.Ok); expect(response.regulateId).toBeUndefined(); }); @@ -455,7 +455,7 @@ describe('AnalyticsPrivacyController', () => { rootMessenger.registerActionHandler( 'AnalyticsPrivacyService:createDataDeletionTask', jest.fn().mockResolvedValue({ - status: DataDeleteResponseStatus.ok, + status: DataDeleteResponseStatus.Ok, regulateId: '', // Empty string is falsy }), ); @@ -476,7 +476,7 @@ describe('AnalyticsPrivacyController', () => { 'AnalyticsPrivacyController:createDataDeletionTask', ); - expect(response.status).toBe(DataDeleteResponseStatus.ok); + expect(response.status).toBe(DataDeleteResponseStatus.Ok); // Empty string is falsy, so condition fails and state is not updated expect(controller.state.deleteRegulationId).toBeNull(); }); @@ -520,7 +520,7 @@ describe('AnalyticsPrivacyController', () => { rootMessenger.registerActionHandler( 'AnalyticsPrivacyService:createDataDeletionTask', jest.fn().mockResolvedValue({ - status: DataDeleteResponseStatus.ok, + status: DataDeleteResponseStatus.Ok, regulateId: undefined as string | undefined, }), ); @@ -541,7 +541,7 @@ describe('AnalyticsPrivacyController', () => { 'AnalyticsPrivacyController:createDataDeletionTask', ); - expect(response.status).toBe(DataDeleteResponseStatus.ok); + expect(response.status).toBe(DataDeleteResponseStatus.Ok); // When regulateId is undefined, the condition fails, so state is not updated expect(controller.state.deleteRegulationId).toBeNull(); }); @@ -583,7 +583,7 @@ describe('AnalyticsPrivacyController', () => { rootMessenger.registerActionHandler( 'AnalyticsPrivacyService:createDataDeletionTask', jest.fn().mockResolvedValue({ - status: DataDeleteResponseStatus.ok, + status: DataDeleteResponseStatus.Ok, regulateId: 'test-regulate-id', }), ); @@ -606,7 +606,7 @@ describe('AnalyticsPrivacyController', () => { 'AnalyticsPrivacyController:createDataDeletionTask', ); - expect(response.status).toBe(DataDeleteResponseStatus.error); + expect(response.status).toBe(DataDeleteResponseStatus.Error); expect(response.error).toBe('Analytics ID not found'); }); @@ -648,7 +648,7 @@ describe('AnalyticsPrivacyController', () => { rootMessenger.registerActionHandler( 'AnalyticsPrivacyService:createDataDeletionTask', jest.fn().mockResolvedValue({ - status: DataDeleteResponseStatus.error, + status: DataDeleteResponseStatus.Error, error: 'Service error', }), ); @@ -671,7 +671,7 @@ describe('AnalyticsPrivacyController', () => { 'AnalyticsPrivacyController:createDataDeletionTask', ); - expect(response.status).toBe(DataDeleteResponseStatus.error); + expect(response.status).toBe(DataDeleteResponseStatus.Error); expect(response.error).toBe('Service error'); }); @@ -713,7 +713,7 @@ describe('AnalyticsPrivacyController', () => { rootMessenger.registerActionHandler( 'AnalyticsPrivacyService:createDataDeletionTask', jest.fn().mockResolvedValue({ - status: DataDeleteResponseStatus.error, + status: DataDeleteResponseStatus.Error, error: 'Service error', }), ); @@ -748,7 +748,7 @@ describe('AnalyticsPrivacyController', () => { expect(status).toStrictEqual({ deletionRequestTimestamp: testTimestamp, - dataDeletionRequestStatus: DataDeleteStatus.finished, + dataDeletionRequestStatus: DataDeleteStatus.Finished, hasCollectedDataSinceDeletionRequest: true, }); }); @@ -762,7 +762,7 @@ describe('AnalyticsPrivacyController', () => { expect(status).toStrictEqual({ deletionRequestTimestamp: undefined, - dataDeletionRequestStatus: DataDeleteStatus.unknown, + dataDeletionRequestStatus: DataDeleteStatus.Unknown, hasCollectedDataSinceDeletionRequest: false, }); }); @@ -805,8 +805,8 @@ describe('AnalyticsPrivacyController', () => { rootMessenger.registerActionHandler( 'AnalyticsPrivacyService:checkDataDeleteStatus', jest.fn().mockResolvedValue({ - status: DataDeleteResponseStatus.ok, - dataDeleteStatus: DataDeleteStatus.finished, + status: DataDeleteResponseStatus.Ok, + dataDeleteStatus: DataDeleteStatus.Finished, }), ); @@ -833,7 +833,7 @@ describe('AnalyticsPrivacyController', () => { ); expect(status.deletionRequestTimestamp).toBeUndefined(); - expect(status.dataDeletionRequestStatus).toBe(DataDeleteStatus.finished); + expect(status.dataDeletionRequestStatus).toBe(DataDeleteStatus.Finished); }); it('handles service errors gracefully', async () => { @@ -892,7 +892,7 @@ describe('AnalyticsPrivacyController', () => { 'AnalyticsPrivacyController:checkDataDeleteStatus', ); - expect(status.dataDeletionRequestStatus).toBe(DataDeleteStatus.unknown); + expect(status.dataDeletionRequestStatus).toBe(DataDeleteStatus.Unknown); expect(status.deletionRequestTimestamp).toBe(testTimestamp); expect(status.hasCollectedDataSinceDeletionRequest).toBe(false); }); diff --git a/packages/analytics-privacy-controller/src/AnalyticsPrivacyController.ts b/packages/analytics-privacy-controller/src/AnalyticsPrivacyController.ts index f046d51abf9..af10a1b8d66 100644 --- a/packages/analytics-privacy-controller/src/AnalyticsPrivacyController.ts +++ b/packages/analytics-privacy-controller/src/AnalyticsPrivacyController.ts @@ -244,7 +244,7 @@ export class AnalyticsPrivacyController extends BaseController< const error = new Error('Analytics ID not found'); log('Analytics Deletion Task Error', error); return { - status: DataDeleteResponseStatus.error, + status: DataDeleteResponseStatus.Error, error: 'Analytics ID not found', }; } @@ -255,7 +255,7 @@ export class AnalyticsPrivacyController extends BaseController< ); if ( - response.status === DataDeleteResponseStatus.ok && + response.status === DataDeleteResponseStatus.Ok && response.regulateId && typeof response.regulateId === 'string' && response.regulateId.trim() !== '' @@ -284,7 +284,7 @@ export class AnalyticsPrivacyController extends BaseController< ? error.message : 'Analytics Deletion Task Error'; return { - status: DataDeleteResponseStatus.error, + status: DataDeleteResponseStatus.Error, error: errorMessage, }; } @@ -298,7 +298,7 @@ export class AnalyticsPrivacyController extends BaseController< async checkDataDeleteStatus(): Promise { const status: IDeleteRegulationStatus = { deletionRequestTimestamp: undefined, - dataDeletionRequestStatus: DataDeleteStatus.unknown, + dataDeletionRequestStatus: DataDeleteStatus.Unknown, hasCollectedDataSinceDeletionRequest: false, }; @@ -316,7 +316,7 @@ export class AnalyticsPrivacyController extends BaseController< dataDeletionTaskStatus.dataDeleteStatus; } catch (error) { log('Error checkDataDeleteStatus', error); - status.dataDeletionRequestStatus = DataDeleteStatus.unknown; + status.dataDeletionRequestStatus = DataDeleteStatus.Unknown; } status.deletionRequestTimestamp = diff --git a/packages/analytics-privacy-controller/src/AnalyticsPrivacyService.test.ts b/packages/analytics-privacy-controller/src/AnalyticsPrivacyService.test.ts index 172a38bf4ed..d2220d38339 100644 --- a/packages/analytics-privacy-controller/src/AnalyticsPrivacyService.test.ts +++ b/packages/analytics-privacy-controller/src/AnalyticsPrivacyService.test.ts @@ -52,7 +52,7 @@ describe('AnalyticsPrivacyService', () => { ); expect(response).toStrictEqual({ - status: DataDeleteResponseStatus.ok, + status: DataDeleteResponseStatus.Ok, regulateId, }); }); @@ -73,7 +73,7 @@ describe('AnalyticsPrivacyService', () => { ); expect(response).toStrictEqual({ - status: DataDeleteResponseStatus.error, + status: DataDeleteResponseStatus.Error, error: 'Segment API source ID or endpoint not found', }); }); @@ -94,7 +94,7 @@ describe('AnalyticsPrivacyService', () => { ); expect(response).toStrictEqual({ - status: DataDeleteResponseStatus.error, + status: DataDeleteResponseStatus.Error, error: 'Segment API source ID or endpoint not found', }); }); @@ -120,7 +120,7 @@ describe('AnalyticsPrivacyService', () => { ); expect(response).toStrictEqual({ - status: DataDeleteResponseStatus.error, + status: DataDeleteResponseStatus.Error, error: 'Analytics Deletion Task Error', }); }); @@ -144,7 +144,7 @@ describe('AnalyticsPrivacyService', () => { ); expect(response).toStrictEqual({ - status: DataDeleteResponseStatus.error, + status: DataDeleteResponseStatus.Error, error: 'Analytics Deletion Task Error', }); }); @@ -214,7 +214,7 @@ describe('AnalyticsPrivacyService', () => { describe('AnalyticsPrivacyService:checkDataDeleteStatus', () => { it('checks data deletion status and returns the status', async () => { const regulationId = 'test-regulation-id'; - const status = DataDeleteStatus.finished; + const status = DataDeleteStatus.Finished; nock(segmentRegulationsEndpoint) .get(`/regulations/${regulationId}`) @@ -236,7 +236,7 @@ describe('AnalyticsPrivacyService', () => { ); expect(response).toStrictEqual({ - status: DataDeleteResponseStatus.ok, + status: DataDeleteResponseStatus.Ok, dataDeleteStatus: status, }); }); @@ -250,8 +250,8 @@ describe('AnalyticsPrivacyService', () => { ); expect(response).toStrictEqual({ - status: DataDeleteResponseStatus.error, - dataDeleteStatus: DataDeleteStatus.unknown, + status: DataDeleteResponseStatus.Error, + dataDeleteStatus: DataDeleteStatus.Unknown, }); }); @@ -271,8 +271,8 @@ describe('AnalyticsPrivacyService', () => { ); expect(response).toStrictEqual({ - status: DataDeleteResponseStatus.error, - dataDeleteStatus: DataDeleteStatus.unknown, + status: DataDeleteResponseStatus.Error, + dataDeleteStatus: DataDeleteStatus.Unknown, }); }); @@ -297,8 +297,8 @@ describe('AnalyticsPrivacyService', () => { ); expect(response).toStrictEqual({ - status: DataDeleteResponseStatus.error, - dataDeleteStatus: DataDeleteStatus.unknown, + status: DataDeleteResponseStatus.Error, + dataDeleteStatus: DataDeleteStatus.Unknown, }); }); @@ -325,14 +325,14 @@ describe('AnalyticsPrivacyService', () => { ); expect(response).toStrictEqual({ - status: DataDeleteResponseStatus.ok, - dataDeleteStatus: DataDeleteStatus.unknown, + status: DataDeleteResponseStatus.Ok, + dataDeleteStatus: DataDeleteStatus.Unknown, }); }); it('sends correct Content-Type header', async () => { const regulationId = 'test-regulation-id'; - const status = DataDeleteStatus.running; + const status = DataDeleteStatus.Running; const scope = nock(segmentRegulationsEndpoint, { reqheaders: { @@ -388,7 +388,7 @@ describe('AnalyticsPrivacyService', () => { 'test-analytics-id', ), ).toMatchObject({ - status: DataDeleteResponseStatus.error, + status: DataDeleteResponseStatus.Error, }); expect(onRetryListener).toHaveBeenCalled(); @@ -418,7 +418,7 @@ describe('AnalyticsPrivacyService', () => { 'test-analytics-id', ), ).toMatchObject({ - status: DataDeleteResponseStatus.error, + status: DataDeleteResponseStatus.Error, }); } @@ -429,7 +429,7 @@ describe('AnalyticsPrivacyService', () => { 'test-analytics-id', ), ).toMatchObject({ - status: DataDeleteResponseStatus.error, + status: DataDeleteResponseStatus.Error, }); expect(onBreakListener).toHaveBeenCalled(); @@ -482,7 +482,7 @@ describe('AnalyticsPrivacyService', () => { 'test-analytics-id', ), ).toMatchObject({ - status: DataDeleteResponseStatus.error, + status: DataDeleteResponseStatus.Error, }); expect(onDegradedListener).toHaveBeenCalled(); diff --git a/packages/analytics-privacy-controller/src/AnalyticsPrivacyService.ts b/packages/analytics-privacy-controller/src/AnalyticsPrivacyService.ts index ccec72d11f7..1efb280560b 100644 --- a/packages/analytics-privacy-controller/src/AnalyticsPrivacyService.ts +++ b/packages/analytics-privacy-controller/src/AnalyticsPrivacyService.ts @@ -270,7 +270,7 @@ export class AnalyticsPrivacyService { ): Promise { if (!this.#segmentSourceId || !this.#segmentRegulationsEndpoint) { return { - status: DataDeleteResponseStatus.error, + status: DataDeleteResponseStatus.Error, error: 'Segment API source ID or endpoint not found', }; } @@ -309,7 +309,7 @@ export class AnalyticsPrivacyService { typeof jsonResponse.data.data.regulateId === 'string' ) { return { - status: DataDeleteResponseStatus.ok, + status: DataDeleteResponseStatus.Ok, regulateId: jsonResponse.data.data.regulateId, }; } @@ -319,13 +319,13 @@ export class AnalyticsPrivacyService { new Error('Malformed response from Segment API'), ); return { - status: DataDeleteResponseStatus.error, + status: DataDeleteResponseStatus.Error, error: 'Analytics Deletion Task Error', }; } catch (error) { log('Analytics Deletion Task Error', error); return { - status: DataDeleteResponseStatus.error, + status: DataDeleteResponseStatus.Error, error: 'Analytics Deletion Task Error', }; } @@ -343,8 +343,8 @@ export class AnalyticsPrivacyService { // Early return if regulationId is missing (cannot check status) or endpoint is not configured if (!regulationId || !this.#segmentRegulationsEndpoint) { return { - status: DataDeleteResponseStatus.error, - dataDeleteStatus: DataDeleteStatus.unknown, + status: DataDeleteResponseStatus.Error, + dataDeleteStatus: DataDeleteStatus.Unknown, }; } @@ -372,19 +372,22 @@ export class AnalyticsPrivacyService { const jsonResponse = (await response.json()) as GetRegulationStatusResponse; - const status = - jsonResponse?.data?.data?.regulation?.overallStatus || - DataDeleteStatus.unknown; + const rawStatus = jsonResponse?.data?.data?.regulation?.overallStatus; + const dataDeleteStatus = Object.values(DataDeleteStatus).includes( + rawStatus as DataDeleteStatus, + ) + ? (rawStatus as DataDeleteStatus) + : DataDeleteStatus.Unknown; return { - status: DataDeleteResponseStatus.ok, - dataDeleteStatus: status as DataDeleteStatus, + status: DataDeleteResponseStatus.Ok, + dataDeleteStatus, }; } catch (error) { log('Analytics Deletion Task Check Error', error); return { - status: DataDeleteResponseStatus.error, - dataDeleteStatus: DataDeleteStatus.unknown, + status: DataDeleteResponseStatus.Error, + dataDeleteStatus: DataDeleteStatus.Unknown, }; } } diff --git a/packages/analytics-privacy-controller/src/types.ts b/packages/analytics-privacy-controller/src/types.ts index c5aad6795ca..4a69154428d 100644 --- a/packages/analytics-privacy-controller/src/types.ts +++ b/packages/analytics-privacy-controller/src/types.ts @@ -1,30 +1,26 @@ /** * Status values for data deletion requests from Segment API. - * Enum member names match Segment API response values exactly. + * Enum values match Segment API response values exactly. */ -/* eslint-disable @typescript-eslint/naming-convention */ export enum DataDeleteStatus { - failed = 'FAILED', - finished = 'FINISHED', - initialized = 'INITIALIZED', - invalid = 'INVALID', - notSupported = 'NOT_SUPPORTED', - partialSuccess = 'PARTIAL_SUCCESS', - running = 'RUNNING', - unknown = 'UNKNOWN', + Failed = 'FAILED', + Finished = 'FINISHED', + Initialized = 'INITIALIZED', + Invalid = 'INVALID', + NotSupported = 'NOT_SUPPORTED', + PartialSuccess = 'PARTIAL_SUCCESS', + Running = 'RUNNING', + Unknown = 'UNKNOWN', } -/* eslint-enable @typescript-eslint/naming-convention */ /** * Response status for deletion regulation operations. - * Enum member names match API response values exactly. + * Enum values match API response values exactly. */ -/* eslint-disable @typescript-eslint/naming-convention */ export enum DataDeleteResponseStatus { - ok = 'ok', - error = 'error', + Ok = 'ok', + Error = 'error', } -/* eslint-enable @typescript-eslint/naming-convention */ /** * Response from creating a data deletion task. From c552c6611a0195c5eb283b13b9e9a260fec22763 Mon Sep 17 00:00:00 2001 From: Nicolas MASSART Date: Fri, 16 Jan 2026 13:37:02 +0100 Subject: [PATCH 05/12] refactor: improve test descriptions for clarity and consistency --- .../src/AnalyticsPrivacyController.test.ts | 114 +++++++++++++----- .../src/AnalyticsPrivacyService.test.ts | 34 +++--- .../src/selectors.test.ts | 12 +- 3 files changed, 109 insertions(+), 51 deletions(-) diff --git a/packages/analytics-privacy-controller/src/AnalyticsPrivacyController.test.ts b/packages/analytics-privacy-controller/src/AnalyticsPrivacyController.test.ts index 645a9afe776..0e765695ee9 100644 --- a/packages/analytics-privacy-controller/src/AnalyticsPrivacyController.test.ts +++ b/packages/analytics-privacy-controller/src/AnalyticsPrivacyController.test.ts @@ -119,7 +119,7 @@ function setupController( describe('AnalyticsPrivacyController', () => { describe('getDefaultAnalyticsPrivacyControllerState', () => { - it('returns default state with all fields undefined/false', () => { + it('returns default state with dataRecorded false and null regulation fields', () => { const defaults = getDefaultAnalyticsPrivacyControllerState(); expect(defaults).toStrictEqual({ @@ -129,7 +129,7 @@ describe('AnalyticsPrivacyController', () => { }); }); - it('returns the same values on each call (deterministic)', () => { + it('returns identical values on each call', () => { const defaults1 = getDefaultAnalyticsPrivacyControllerState(); const defaults2 = getDefaultAnalyticsPrivacyControllerState(); @@ -158,7 +158,7 @@ describe('AnalyticsPrivacyController', () => { expect(controller.state).toStrictEqual(initialState); }); - it('merges provided state with defaults', () => { + it('merges provided partial state with default values', () => { const partialState = { dataRecorded: true, }; @@ -172,7 +172,7 @@ describe('AnalyticsPrivacyController', () => { }); describe('AnalyticsPrivacyController:createDataDeletionTask', () => { - it('creates a data deletion task and updates state', async () => { + it('creates data deletion task and updates state with regulation ID and timestamp', async () => { const { controller, rootMessenger } = setupController(); const fixedTimestamp = new Date('2026-01-15T12:00:00Z').getTime(); @@ -192,7 +192,7 @@ describe('AnalyticsPrivacyController', () => { jest.useRealTimers(); }); - it('stores deletion timestamp correctly', async () => { + it('stores deletion timestamp as number when task is created', async () => { const rootMessenger = new Messenger< MockAnyNamespace, | AnalyticsPrivacyControllerActions @@ -261,7 +261,7 @@ describe('AnalyticsPrivacyController', () => { jest.useRealTimers(); }); - it('emits dataDeletionTaskCreated event', async () => { + it('emits dataDeletionTaskCreated event with response payload', async () => { const { rootMessenger, messenger } = setupController(); const eventListener = jest.fn(); @@ -274,18 +274,16 @@ describe('AnalyticsPrivacyController', () => { 'AnalyticsPrivacyController:createDataDeletionTask', ); - // Verify the response is correct first expect(response.status).toBe(DataDeleteResponseStatus.Ok); expect(response.regulateId).toBe('test-regulate-id'); - // Then verify the event was emitted expect(eventListener).toHaveBeenCalledWith({ status: DataDeleteResponseStatus.Ok, regulateId: 'test-regulate-id', }); }); - it('returns error if analyticsId is missing from AnalyticsController state', async () => { + it('returns error response when analyticsId is empty string', async () => { const rootMessenger = new Messenger< MockAnyNamespace, | AnalyticsPrivacyControllerActions @@ -350,7 +348,7 @@ describe('AnalyticsPrivacyController', () => { expect(response.error).toBe('Analytics ID not found'); }); - it('handles service response with undefined regulateId', async () => { + it('returns response without updating state when regulateId is undefined', async () => { const rootMessenger = new Messenger< MockAnyNamespace, | AnalyticsPrivacyControllerActions @@ -415,7 +413,7 @@ describe('AnalyticsPrivacyController', () => { expect(response.regulateId).toBeUndefined(); }); - it('handles empty string regulateId (falsy but not null/undefined)', async () => { + it('returns response without updating state when regulateId is empty string', async () => { const rootMessenger = new Messenger< MockAnyNamespace, | AnalyticsPrivacyControllerActions @@ -481,7 +479,7 @@ describe('AnalyticsPrivacyController', () => { expect(controller.state.deleteRegulationId).toBeNull(); }); - it('handles null deleteRegulationTimestamp in status', async () => { + it('returns response without updating state when regulateId is undefined', async () => { const rootMessenger = new Messenger< MockAnyNamespace, | AnalyticsPrivacyControllerActions @@ -516,7 +514,6 @@ describe('AnalyticsPrivacyController', () => { }), ); - // Mock a response where regulateId is explicitly undefined (to test ?? null) rootMessenger.registerActionHandler( 'AnalyticsPrivacyService:createDataDeletionTask', jest.fn().mockResolvedValue({ @@ -542,11 +539,10 @@ describe('AnalyticsPrivacyController', () => { ); expect(response.status).toBe(DataDeleteResponseStatus.Ok); - // When regulateId is undefined, the condition fails, so state is not updated expect(controller.state.deleteRegulationId).toBeNull(); }); - it('returns error if AnalyticsController:getState fails', async () => { + it('returns error response when AnalyticsController:getState throws Error', async () => { const rootMessenger = new Messenger< MockAnyNamespace, | AnalyticsPrivacyControllerActions @@ -610,7 +606,69 @@ describe('AnalyticsPrivacyController', () => { expect(response.error).toBe('Analytics ID not found'); }); - it('returns error if service call fails', async () => { + it('returns error response with default message when service throws non-Error value', async () => { + const rootMessenger = new Messenger< + MockAnyNamespace, + | AnalyticsPrivacyControllerActions + | AnalyticsPrivacyServiceActions + | { + type: 'AnalyticsController:getState'; + handler: () => { analyticsId: string; optedIn: boolean }; + }, + AnalyticsPrivacyControllerEvents + >({ namespace: MOCK_ANY_NAMESPACE }); + + const analyticsPrivacyControllerMessenger = new Messenger< + 'AnalyticsPrivacyController', + | AnalyticsPrivacyControllerActions + | AnalyticsPrivacyServiceActions + | { + type: 'AnalyticsController:getState'; + handler: () => { analyticsId: string; optedIn: boolean }; + }, + AnalyticsPrivacyControllerEvents, + typeof rootMessenger + >({ + namespace: 'AnalyticsPrivacyController', + parent: rootMessenger, + }); + + rootMessenger.registerActionHandler( + 'AnalyticsController:getState', + () => ({ + analyticsId: 'test-analytics-id', + optedIn: true, + }), + ); + + rootMessenger.registerActionHandler( + 'AnalyticsPrivacyService:createDataDeletionTask', + jest.fn().mockRejectedValue('String error'), + ); + + rootMessenger.delegate({ + messenger: analyticsPrivacyControllerMessenger, + actions: [ + 'AnalyticsPrivacyService:createDataDeletionTask', + 'AnalyticsController:getState', + ], + }); + + // Controller is instantiated to register action handlers + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const _controller = new AnalyticsPrivacyController({ + messenger: analyticsPrivacyControllerMessenger, + }); + + const response = await rootMessenger.call( + 'AnalyticsPrivacyController:createDataDeletionTask', + ); + + expect(response.status).toBe(DataDeleteResponseStatus.Error); + expect(response.error).toBe('Analytics Deletion Task Error'); + }); + + it('returns error response when service returns error status', async () => { const rootMessenger = new Messenger< MockAnyNamespace, | AnalyticsPrivacyControllerActions @@ -675,7 +733,7 @@ describe('AnalyticsPrivacyController', () => { expect(response.error).toBe('Service error'); }); - it('does not update state if service returns error', async () => { + it('preserves initial state when service returns error status', async () => { const rootMessenger = new Messenger< MockAnyNamespace, | AnalyticsPrivacyControllerActions @@ -732,7 +790,7 @@ describe('AnalyticsPrivacyController', () => { }); describe('AnalyticsPrivacyController:checkDataDeleteStatus', () => { - it('returns status with all fields when regulationId exists', async () => { + it('returns status with timestamp, deletion status, and data recorded flag when regulationId exists', async () => { const testTimestamp = new Date('2026-01-15T12:00:00Z').getTime(); const { rootMessenger } = setupController({ state: { @@ -753,7 +811,7 @@ describe('AnalyticsPrivacyController', () => { }); }); - it('returns unknown status when regulationId is missing', async () => { + it('returns status with unknown deletion status when regulationId is null', async () => { const { rootMessenger } = setupController(); const status = await rootMessenger.call( @@ -767,7 +825,7 @@ describe('AnalyticsPrivacyController', () => { }); }); - it('handles null deleteRegulationTimestamp in status', async () => { + it('returns undefined timestamp when deleteRegulationTimestamp is null', async () => { const rootMessenger = new Messenger< MockAnyNamespace, | AnalyticsPrivacyControllerActions @@ -836,7 +894,7 @@ describe('AnalyticsPrivacyController', () => { expect(status.dataDeletionRequestStatus).toBe(DataDeleteStatus.Finished); }); - it('handles service errors gracefully', async () => { + it('returns unknown deletion status when service throws Error', async () => { const rootMessenger = new Messenger< MockAnyNamespace, | AnalyticsPrivacyControllerActions @@ -978,7 +1036,7 @@ describe('AnalyticsPrivacyController', () => { }); describe('AnalyticsPrivacyController:updateDataRecordingFlag', () => { - it('updates dataRecorded to true when saveDataRecording is true', () => { + it('sets dataRecorded to true when saveDataRecording is true', () => { const { controller, rootMessenger } = setupController({ state: { dataRecorded: false, @@ -993,7 +1051,7 @@ describe('AnalyticsPrivacyController', () => { expect(controller.state.dataRecorded).toBe(true); }); - it('does not update when saveDataRecording is false', () => { + it('preserves dataRecorded value when saveDataRecording is false', () => { const { controller, rootMessenger } = setupController({ state: { dataRecorded: false, @@ -1008,7 +1066,7 @@ describe('AnalyticsPrivacyController', () => { expect(controller.state.dataRecorded).toBe(false); }); - it('does not update when dataRecorded is already true', () => { + it('preserves dataRecorded value when already true', () => { const { controller, rootMessenger } = setupController({ state: { dataRecorded: true, @@ -1023,7 +1081,7 @@ describe('AnalyticsPrivacyController', () => { expect(controller.state.dataRecorded).toBe(true); }); - it('defaults saveDataRecording to true', () => { + it('sets dataRecorded to true when saveDataRecording is omitted', () => { const { controller, rootMessenger } = setupController({ state: { dataRecorded: false, @@ -1035,7 +1093,7 @@ describe('AnalyticsPrivacyController', () => { expect(controller.state.dataRecorded).toBe(true); }); - it('emits dataRecordingFlagUpdated event when flag is updated', () => { + it('emits dataRecordingFlagUpdated event with true when dataRecorded changes from false to true', () => { const { rootMessenger } = setupController({ state: { dataRecorded: false, @@ -1056,7 +1114,7 @@ describe('AnalyticsPrivacyController', () => { expect(eventListener).toHaveBeenCalledWith(true); }); - it('does not emit event when flag is not updated', () => { + it('does not emit event when saveDataRecording is false', () => { const { rootMessenger } = setupController({ state: { dataRecorded: false, @@ -1079,7 +1137,7 @@ describe('AnalyticsPrivacyController', () => { }); describe('stateChange event', () => { - it('emits stateChange event when state is updated', () => { + it('emits stateChange event with new state when dataRecorded is updated', () => { const { rootMessenger, messenger } = setupController(); const eventListener = jest.fn(); diff --git a/packages/analytics-privacy-controller/src/AnalyticsPrivacyService.test.ts b/packages/analytics-privacy-controller/src/AnalyticsPrivacyService.test.ts index d2220d38339..611724e6726 100644 --- a/packages/analytics-privacy-controller/src/AnalyticsPrivacyService.test.ts +++ b/packages/analytics-privacy-controller/src/AnalyticsPrivacyService.test.ts @@ -30,7 +30,7 @@ describe('AnalyticsPrivacyService', () => { }); describe('AnalyticsPrivacyService:createDataDeletionTask', () => { - it('creates a data deletion task and returns the regulateId', async () => { + it('returns regulateId when deletion task is created', async () => { const analyticsId = 'test-analytics-id'; const regulateId = 'test-regulate-id'; @@ -57,7 +57,7 @@ describe('AnalyticsPrivacyService', () => { }); }); - it('returns error if segmentSourceId is missing', async () => { + it('returns error response when segmentSourceId is empty string', async () => { const analyticsId = 'test-analytics-id'; const { rootMessenger } = getService({ @@ -78,7 +78,7 @@ describe('AnalyticsPrivacyService', () => { }); }); - it('returns error if segmentRegulationsEndpoint is missing', async () => { + it('returns error response when segmentRegulationsEndpoint is empty string', async () => { const analyticsId = 'test-analytics-id'; const { rootMessenger } = getService({ @@ -99,7 +99,7 @@ describe('AnalyticsPrivacyService', () => { }); }); - it('returns error if API returns non-200 status', async () => { + it('returns error response when API returns 500 status', async () => { const analyticsId = 'test-analytics-id'; nock(segmentRegulationsEndpoint) @@ -125,7 +125,7 @@ describe('AnalyticsPrivacyService', () => { }); }); - it('returns error if API returns malformed response', async () => { + it('returns error response when API response is missing regulateId', async () => { const analyticsId = 'test-analytics-id'; nock(segmentRegulationsEndpoint) @@ -149,7 +149,7 @@ describe('AnalyticsPrivacyService', () => { }); }); - it('sends correct request body with DELETE_ONLY regulation type', async () => { + it('sends request body with DELETE_ONLY regulation type and analyticsId in subjectIds', async () => { const analyticsId = 'test-analytics-id'; const regulateId = 'test-regulate-id'; @@ -182,7 +182,7 @@ describe('AnalyticsPrivacyService', () => { expect(scope.isDone()).toBe(true); }); - it('sends correct Content-Type header', async () => { + it('sends POST request with application/vnd.segment.v1+json Content-Type header', async () => { const analyticsId = 'test-analytics-id'; const regulateId = 'test-regulate-id'; @@ -212,7 +212,7 @@ describe('AnalyticsPrivacyService', () => { }); describe('AnalyticsPrivacyService:checkDataDeleteStatus', () => { - it('checks data deletion status and returns the status', async () => { + it('returns dataDeleteStatus when regulation status is retrieved', async () => { const regulationId = 'test-regulation-id'; const status = DataDeleteStatus.Finished; @@ -241,7 +241,7 @@ describe('AnalyticsPrivacyService', () => { }); }); - it('returns unknown status if regulationId is missing', async () => { + it('returns unknown status when regulationId is empty string', async () => { const { rootMessenger } = getService(); const response = await rootMessenger.call( @@ -255,7 +255,7 @@ describe('AnalyticsPrivacyService', () => { }); }); - it('returns unknown status if segmentRegulationsEndpoint is missing', async () => { + it('returns unknown status when segmentRegulationsEndpoint is empty string', async () => { const regulationId = 'test-regulation-id'; const { rootMessenger } = getService({ @@ -276,7 +276,7 @@ describe('AnalyticsPrivacyService', () => { }); }); - it('returns unknown status if API returns non-200 status', async () => { + it('returns unknown status when API returns 500 status', async () => { const regulationId = 'test-regulation-id'; nock(segmentRegulationsEndpoint) @@ -302,7 +302,7 @@ describe('AnalyticsPrivacyService', () => { }); }); - it('returns unknown status if API response is missing overallStatus', async () => { + it('returns unknown status when API response is missing overallStatus', async () => { const regulationId = 'test-regulation-id'; nock(segmentRegulationsEndpoint) @@ -330,7 +330,7 @@ describe('AnalyticsPrivacyService', () => { }); }); - it('sends correct Content-Type header', async () => { + it('sends GET request with application/vnd.segment.v1+json Content-Type header', async () => { const regulationId = 'test-regulation-id'; const status = DataDeleteStatus.Running; @@ -362,7 +362,7 @@ describe('AnalyticsPrivacyService', () => { }); describe('onRetry', () => { - it('registers and calls retry listeners', async () => { + it('calls retry listener when request is retried', async () => { nock(segmentRegulationsEndpoint) .post(`/regulations/sources/${segmentSourceId}`) .times(2) @@ -396,7 +396,7 @@ describe('AnalyticsPrivacyService', () => { }); describe('onBreak', () => { - it('registers and calls break listeners when circuit breaker opens', async () => { + it('calls break listener when circuit breaker opens after multiple failures', async () => { nock(segmentRegulationsEndpoint) .post(`/regulations/sources/${segmentSourceId}`) .times(12) @@ -437,7 +437,7 @@ describe('AnalyticsPrivacyService', () => { }); describe('onDegraded', () => { - it('calls onDegraded listeners if the request takes longer than 5 seconds to resolve', async () => { + it('calls onDegraded listener when request takes longer than 5 seconds', async () => { nock(segmentRegulationsEndpoint) .post(`/regulations/sources/${segmentSourceId}`) .reply(200, () => { @@ -463,7 +463,7 @@ describe('AnalyticsPrivacyService', () => { expect(onDegradedListener).toHaveBeenCalled(); }); - it('calls onDegraded listeners when the maximum number of retries is exceeded', async () => { + it('calls onDegraded listener when maximum number of retries is exceeded', async () => { nock(segmentRegulationsEndpoint) .post(`/regulations/sources/${segmentSourceId}`) .times(4) diff --git a/packages/analytics-privacy-controller/src/selectors.test.ts b/packages/analytics-privacy-controller/src/selectors.test.ts index bcdda4080aa..bdced4a2c71 100644 --- a/packages/analytics-privacy-controller/src/selectors.test.ts +++ b/packages/analytics-privacy-controller/src/selectors.test.ts @@ -6,7 +6,7 @@ import type { AnalyticsPrivacyControllerState } from './AnalyticsPrivacyControll describe('analyticsPrivacyControllerSelectors', () => { describe('selectDataRecorded', () => { - it('returns the dataRecorded flag from state', () => { + it('returns true when dataRecorded is true in state', () => { const state: AnalyticsPrivacyControllerState = { ...getDefaultAnalyticsPrivacyControllerState(), dataRecorded: true, @@ -17,7 +17,7 @@ describe('analyticsPrivacyControllerSelectors', () => { ).toBe(true); }); - it('returns false when dataRecorded is false', () => { + it('returns false when dataRecorded is false in state', () => { const state: AnalyticsPrivacyControllerState = { ...getDefaultAnalyticsPrivacyControllerState(), dataRecorded: false, @@ -30,7 +30,7 @@ describe('analyticsPrivacyControllerSelectors', () => { }); describe('selectDeleteRegulationId', () => { - it('returns the deleteRegulationId when set', () => { + it('returns deleteRegulationId string when set in state', () => { const state: AnalyticsPrivacyControllerState = { ...getDefaultAnalyticsPrivacyControllerState(), deleteRegulationId: 'test-regulation-id', @@ -41,7 +41,7 @@ describe('analyticsPrivacyControllerSelectors', () => { ).toBe('test-regulation-id'); }); - it('returns undefined when deleteRegulationId is null', () => { + it('returns undefined when deleteRegulationId is null in state', () => { const state: AnalyticsPrivacyControllerState = { ...getDefaultAnalyticsPrivacyControllerState(), deleteRegulationId: null, @@ -54,7 +54,7 @@ describe('analyticsPrivacyControllerSelectors', () => { }); describe('selectDeleteRegulationTimestamp', () => { - it('returns the deleteRegulationTimestamp when set', () => { + it('returns deleteRegulationTimestamp number when set in state', () => { const testTimestamp = new Date('2026-01-15T12:00:00Z').getTime(); const state: AnalyticsPrivacyControllerState = { ...getDefaultAnalyticsPrivacyControllerState(), @@ -68,7 +68,7 @@ describe('analyticsPrivacyControllerSelectors', () => { ).toBe(testTimestamp); }); - it('returns undefined when deleteRegulationTimestamp is null', () => { + it('returns undefined when deleteRegulationTimestamp is null in state', () => { const state: AnalyticsPrivacyControllerState = { ...getDefaultAnalyticsPrivacyControllerState(), deleteRegulationTimestamp: null, From 2cf89424b6779f352e8710a68ca44052975352f7 Mon Sep 17 00:00:00 2001 From: Nicolas MASSART Date: Fri, 16 Jan 2026 13:56:10 +0100 Subject: [PATCH 06/12] fix: rename DataDeleteResponseStatus enum values and fix duplicate test title - Rename Error to Failure (and Ok to Success) for clearer naming - Fix duplicate test title in AnalyticsPrivacyController.test.ts - Update all references across the codebase --- .../src/AnalyticsPrivacyController.test.ts | 44 +++++++++---------- .../src/AnalyticsPrivacyController.ts | 6 +-- .../src/AnalyticsPrivacyService.test.ts | 28 ++++++------ .../src/AnalyticsPrivacyService.ts | 14 +++--- .../analytics-privacy-controller/src/types.ts | 5 +-- 5 files changed, 48 insertions(+), 49 deletions(-) diff --git a/packages/analytics-privacy-controller/src/AnalyticsPrivacyController.test.ts b/packages/analytics-privacy-controller/src/AnalyticsPrivacyController.test.ts index 0e765695ee9..5982438af78 100644 --- a/packages/analytics-privacy-controller/src/AnalyticsPrivacyController.test.ts +++ b/packages/analytics-privacy-controller/src/AnalyticsPrivacyController.test.ts @@ -82,7 +82,7 @@ function setupController( rootMessenger.registerActionHandler( 'AnalyticsPrivacyService:createDataDeletionTask', jest.fn().mockResolvedValue({ - status: DataDeleteResponseStatus.Ok, + status: DataDeleteResponseStatus.Success, regulateId: 'test-regulate-id', }), ); @@ -90,7 +90,7 @@ function setupController( rootMessenger.registerActionHandler( 'AnalyticsPrivacyService:checkDataDeleteStatus', jest.fn().mockResolvedValue({ - status: DataDeleteResponseStatus.Ok, + status: DataDeleteResponseStatus.Success, dataDeleteStatus: DataDeleteStatus.Finished, }), ); @@ -183,7 +183,7 @@ describe('AnalyticsPrivacyController', () => { 'AnalyticsPrivacyController:createDataDeletionTask', ); - expect(response.status).toBe(DataDeleteResponseStatus.Ok); + expect(response.status).toBe(DataDeleteResponseStatus.Success); expect(response.regulateId).toBe('test-regulate-id'); expect(controller.state.deleteRegulationId).toBe('test-regulate-id'); expect(controller.state.deleteRegulationTimestamp).toBe(fixedTimestamp); @@ -230,7 +230,7 @@ describe('AnalyticsPrivacyController', () => { rootMessenger.registerActionHandler( 'AnalyticsPrivacyService:createDataDeletionTask', jest.fn().mockResolvedValue({ - status: DataDeleteResponseStatus.Ok, + status: DataDeleteResponseStatus.Success, regulateId: 'test-regulate-id', }), ); @@ -274,11 +274,11 @@ describe('AnalyticsPrivacyController', () => { 'AnalyticsPrivacyController:createDataDeletionTask', ); - expect(response.status).toBe(DataDeleteResponseStatus.Ok); + expect(response.status).toBe(DataDeleteResponseStatus.Success); expect(response.regulateId).toBe('test-regulate-id'); expect(eventListener).toHaveBeenCalledWith({ - status: DataDeleteResponseStatus.Ok, + status: DataDeleteResponseStatus.Success, regulateId: 'test-regulate-id', }); }); @@ -321,7 +321,7 @@ describe('AnalyticsPrivacyController', () => { rootMessenger.registerActionHandler( 'AnalyticsPrivacyService:createDataDeletionTask', jest.fn().mockResolvedValue({ - status: DataDeleteResponseStatus.Ok, + status: DataDeleteResponseStatus.Success, regulateId: 'test-regulate-id', }), ); @@ -344,7 +344,7 @@ describe('AnalyticsPrivacyController', () => { 'AnalyticsPrivacyController:createDataDeletionTask', ); - expect(response.status).toBe(DataDeleteResponseStatus.Error); + expect(response.status).toBe(DataDeleteResponseStatus.Failure); expect(response.error).toBe('Analytics ID not found'); }); @@ -386,7 +386,7 @@ describe('AnalyticsPrivacyController', () => { rootMessenger.registerActionHandler( 'AnalyticsPrivacyService:createDataDeletionTask', jest.fn().mockResolvedValue({ - status: DataDeleteResponseStatus.Ok, + status: DataDeleteResponseStatus.Success, // regulateId is undefined }), ); @@ -409,7 +409,7 @@ describe('AnalyticsPrivacyController', () => { 'AnalyticsPrivacyController:createDataDeletionTask', ); - expect(response.status).toBe(DataDeleteResponseStatus.Ok); + expect(response.status).toBe(DataDeleteResponseStatus.Success); expect(response.regulateId).toBeUndefined(); }); @@ -453,7 +453,7 @@ describe('AnalyticsPrivacyController', () => { rootMessenger.registerActionHandler( 'AnalyticsPrivacyService:createDataDeletionTask', jest.fn().mockResolvedValue({ - status: DataDeleteResponseStatus.Ok, + status: DataDeleteResponseStatus.Success, regulateId: '', // Empty string is falsy }), ); @@ -474,12 +474,12 @@ describe('AnalyticsPrivacyController', () => { 'AnalyticsPrivacyController:createDataDeletionTask', ); - expect(response.status).toBe(DataDeleteResponseStatus.Ok); + expect(response.status).toBe(DataDeleteResponseStatus.Success); // Empty string is falsy, so condition fails and state is not updated expect(controller.state.deleteRegulationId).toBeNull(); }); - it('returns response without updating state when regulateId is undefined', async () => { + it('does not update state when regulateId is undefined', async () => { const rootMessenger = new Messenger< MockAnyNamespace, | AnalyticsPrivacyControllerActions @@ -517,7 +517,7 @@ describe('AnalyticsPrivacyController', () => { rootMessenger.registerActionHandler( 'AnalyticsPrivacyService:createDataDeletionTask', jest.fn().mockResolvedValue({ - status: DataDeleteResponseStatus.Ok, + status: DataDeleteResponseStatus.Success, regulateId: undefined as string | undefined, }), ); @@ -538,7 +538,7 @@ describe('AnalyticsPrivacyController', () => { 'AnalyticsPrivacyController:createDataDeletionTask', ); - expect(response.status).toBe(DataDeleteResponseStatus.Ok); + expect(response.status).toBe(DataDeleteResponseStatus.Success); expect(controller.state.deleteRegulationId).toBeNull(); }); @@ -579,7 +579,7 @@ describe('AnalyticsPrivacyController', () => { rootMessenger.registerActionHandler( 'AnalyticsPrivacyService:createDataDeletionTask', jest.fn().mockResolvedValue({ - status: DataDeleteResponseStatus.Ok, + status: DataDeleteResponseStatus.Success, regulateId: 'test-regulate-id', }), ); @@ -602,7 +602,7 @@ describe('AnalyticsPrivacyController', () => { 'AnalyticsPrivacyController:createDataDeletionTask', ); - expect(response.status).toBe(DataDeleteResponseStatus.Error); + expect(response.status).toBe(DataDeleteResponseStatus.Failure); expect(response.error).toBe('Analytics ID not found'); }); @@ -664,7 +664,7 @@ describe('AnalyticsPrivacyController', () => { 'AnalyticsPrivacyController:createDataDeletionTask', ); - expect(response.status).toBe(DataDeleteResponseStatus.Error); + expect(response.status).toBe(DataDeleteResponseStatus.Failure); expect(response.error).toBe('Analytics Deletion Task Error'); }); @@ -706,7 +706,7 @@ describe('AnalyticsPrivacyController', () => { rootMessenger.registerActionHandler( 'AnalyticsPrivacyService:createDataDeletionTask', jest.fn().mockResolvedValue({ - status: DataDeleteResponseStatus.Error, + status: DataDeleteResponseStatus.Failure, error: 'Service error', }), ); @@ -729,7 +729,7 @@ describe('AnalyticsPrivacyController', () => { 'AnalyticsPrivacyController:createDataDeletionTask', ); - expect(response.status).toBe(DataDeleteResponseStatus.Error); + expect(response.status).toBe(DataDeleteResponseStatus.Failure); expect(response.error).toBe('Service error'); }); @@ -771,7 +771,7 @@ describe('AnalyticsPrivacyController', () => { rootMessenger.registerActionHandler( 'AnalyticsPrivacyService:createDataDeletionTask', jest.fn().mockResolvedValue({ - status: DataDeleteResponseStatus.Error, + status: DataDeleteResponseStatus.Failure, error: 'Service error', }), ); @@ -863,7 +863,7 @@ describe('AnalyticsPrivacyController', () => { rootMessenger.registerActionHandler( 'AnalyticsPrivacyService:checkDataDeleteStatus', jest.fn().mockResolvedValue({ - status: DataDeleteResponseStatus.Ok, + status: DataDeleteResponseStatus.Success, dataDeleteStatus: DataDeleteStatus.Finished, }), ); diff --git a/packages/analytics-privacy-controller/src/AnalyticsPrivacyController.ts b/packages/analytics-privacy-controller/src/AnalyticsPrivacyController.ts index af10a1b8d66..0215c3c26ce 100644 --- a/packages/analytics-privacy-controller/src/AnalyticsPrivacyController.ts +++ b/packages/analytics-privacy-controller/src/AnalyticsPrivacyController.ts @@ -244,7 +244,7 @@ export class AnalyticsPrivacyController extends BaseController< const error = new Error('Analytics ID not found'); log('Analytics Deletion Task Error', error); return { - status: DataDeleteResponseStatus.Error, + status: DataDeleteResponseStatus.Failure, error: 'Analytics ID not found', }; } @@ -255,7 +255,7 @@ export class AnalyticsPrivacyController extends BaseController< ); if ( - response.status === DataDeleteResponseStatus.Ok && + response.status === DataDeleteResponseStatus.Success && response.regulateId && typeof response.regulateId === 'string' && response.regulateId.trim() !== '' @@ -284,7 +284,7 @@ export class AnalyticsPrivacyController extends BaseController< ? error.message : 'Analytics Deletion Task Error'; return { - status: DataDeleteResponseStatus.Error, + status: DataDeleteResponseStatus.Failure, error: errorMessage, }; } diff --git a/packages/analytics-privacy-controller/src/AnalyticsPrivacyService.test.ts b/packages/analytics-privacy-controller/src/AnalyticsPrivacyService.test.ts index 611724e6726..9ea9c79b4e9 100644 --- a/packages/analytics-privacy-controller/src/AnalyticsPrivacyService.test.ts +++ b/packages/analytics-privacy-controller/src/AnalyticsPrivacyService.test.ts @@ -52,7 +52,7 @@ describe('AnalyticsPrivacyService', () => { ); expect(response).toStrictEqual({ - status: DataDeleteResponseStatus.Ok, + status: DataDeleteResponseStatus.Success, regulateId, }); }); @@ -73,7 +73,7 @@ describe('AnalyticsPrivacyService', () => { ); expect(response).toStrictEqual({ - status: DataDeleteResponseStatus.Error, + status: DataDeleteResponseStatus.Failure, error: 'Segment API source ID or endpoint not found', }); }); @@ -94,7 +94,7 @@ describe('AnalyticsPrivacyService', () => { ); expect(response).toStrictEqual({ - status: DataDeleteResponseStatus.Error, + status: DataDeleteResponseStatus.Failure, error: 'Segment API source ID or endpoint not found', }); }); @@ -120,7 +120,7 @@ describe('AnalyticsPrivacyService', () => { ); expect(response).toStrictEqual({ - status: DataDeleteResponseStatus.Error, + status: DataDeleteResponseStatus.Failure, error: 'Analytics Deletion Task Error', }); }); @@ -144,7 +144,7 @@ describe('AnalyticsPrivacyService', () => { ); expect(response).toStrictEqual({ - status: DataDeleteResponseStatus.Error, + status: DataDeleteResponseStatus.Failure, error: 'Analytics Deletion Task Error', }); }); @@ -236,7 +236,7 @@ describe('AnalyticsPrivacyService', () => { ); expect(response).toStrictEqual({ - status: DataDeleteResponseStatus.Ok, + status: DataDeleteResponseStatus.Success, dataDeleteStatus: status, }); }); @@ -250,7 +250,7 @@ describe('AnalyticsPrivacyService', () => { ); expect(response).toStrictEqual({ - status: DataDeleteResponseStatus.Error, + status: DataDeleteResponseStatus.Failure, dataDeleteStatus: DataDeleteStatus.Unknown, }); }); @@ -271,7 +271,7 @@ describe('AnalyticsPrivacyService', () => { ); expect(response).toStrictEqual({ - status: DataDeleteResponseStatus.Error, + status: DataDeleteResponseStatus.Failure, dataDeleteStatus: DataDeleteStatus.Unknown, }); }); @@ -297,7 +297,7 @@ describe('AnalyticsPrivacyService', () => { ); expect(response).toStrictEqual({ - status: DataDeleteResponseStatus.Error, + status: DataDeleteResponseStatus.Failure, dataDeleteStatus: DataDeleteStatus.Unknown, }); }); @@ -325,7 +325,7 @@ describe('AnalyticsPrivacyService', () => { ); expect(response).toStrictEqual({ - status: DataDeleteResponseStatus.Ok, + status: DataDeleteResponseStatus.Success, dataDeleteStatus: DataDeleteStatus.Unknown, }); }); @@ -388,7 +388,7 @@ describe('AnalyticsPrivacyService', () => { 'test-analytics-id', ), ).toMatchObject({ - status: DataDeleteResponseStatus.Error, + status: DataDeleteResponseStatus.Failure, }); expect(onRetryListener).toHaveBeenCalled(); @@ -418,7 +418,7 @@ describe('AnalyticsPrivacyService', () => { 'test-analytics-id', ), ).toMatchObject({ - status: DataDeleteResponseStatus.Error, + status: DataDeleteResponseStatus.Failure, }); } @@ -429,7 +429,7 @@ describe('AnalyticsPrivacyService', () => { 'test-analytics-id', ), ).toMatchObject({ - status: DataDeleteResponseStatus.Error, + status: DataDeleteResponseStatus.Failure, }); expect(onBreakListener).toHaveBeenCalled(); @@ -482,7 +482,7 @@ describe('AnalyticsPrivacyService', () => { 'test-analytics-id', ), ).toMatchObject({ - status: DataDeleteResponseStatus.Error, + status: DataDeleteResponseStatus.Failure, }); expect(onDegradedListener).toHaveBeenCalled(); diff --git a/packages/analytics-privacy-controller/src/AnalyticsPrivacyService.ts b/packages/analytics-privacy-controller/src/AnalyticsPrivacyService.ts index 1efb280560b..ca67666e43b 100644 --- a/packages/analytics-privacy-controller/src/AnalyticsPrivacyService.ts +++ b/packages/analytics-privacy-controller/src/AnalyticsPrivacyService.ts @@ -270,7 +270,7 @@ export class AnalyticsPrivacyService { ): Promise { if (!this.#segmentSourceId || !this.#segmentRegulationsEndpoint) { return { - status: DataDeleteResponseStatus.Error, + status: DataDeleteResponseStatus.Failure, error: 'Segment API source ID or endpoint not found', }; } @@ -309,7 +309,7 @@ export class AnalyticsPrivacyService { typeof jsonResponse.data.data.regulateId === 'string' ) { return { - status: DataDeleteResponseStatus.Ok, + status: DataDeleteResponseStatus.Success, regulateId: jsonResponse.data.data.regulateId, }; } @@ -319,13 +319,13 @@ export class AnalyticsPrivacyService { new Error('Malformed response from Segment API'), ); return { - status: DataDeleteResponseStatus.Error, + status: DataDeleteResponseStatus.Failure, error: 'Analytics Deletion Task Error', }; } catch (error) { log('Analytics Deletion Task Error', error); return { - status: DataDeleteResponseStatus.Error, + status: DataDeleteResponseStatus.Failure, error: 'Analytics Deletion Task Error', }; } @@ -343,7 +343,7 @@ export class AnalyticsPrivacyService { // Early return if regulationId is missing (cannot check status) or endpoint is not configured if (!regulationId || !this.#segmentRegulationsEndpoint) { return { - status: DataDeleteResponseStatus.Error, + status: DataDeleteResponseStatus.Failure, dataDeleteStatus: DataDeleteStatus.Unknown, }; } @@ -380,13 +380,13 @@ export class AnalyticsPrivacyService { : DataDeleteStatus.Unknown; return { - status: DataDeleteResponseStatus.Ok, + status: DataDeleteResponseStatus.Success, dataDeleteStatus, }; } catch (error) { log('Analytics Deletion Task Check Error', error); return { - status: DataDeleteResponseStatus.Error, + status: DataDeleteResponseStatus.Failure, dataDeleteStatus: DataDeleteStatus.Unknown, }; } diff --git a/packages/analytics-privacy-controller/src/types.ts b/packages/analytics-privacy-controller/src/types.ts index 4a69154428d..2b3cac75be3 100644 --- a/packages/analytics-privacy-controller/src/types.ts +++ b/packages/analytics-privacy-controller/src/types.ts @@ -15,11 +15,10 @@ export enum DataDeleteStatus { /** * Response status for deletion regulation operations. - * Enum values match API response values exactly. */ export enum DataDeleteResponseStatus { - Ok = 'ok', - Error = 'error', + Success = 'ok', + Failure = 'error', } /** From e594c7cf638a744db72394170cbd21851ec9c760 Mon Sep 17 00:00:00 2001 From: Nicolas MASSART Date: Fri, 16 Jan 2026 15:07:00 +0100 Subject: [PATCH 07/12] format: revert line end of file removed by IDE --- eslint-suppressions.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 0d755579341..081170617f6 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -2060,4 +2060,4 @@ "count": 1 } } -} \ No newline at end of file +} From 89948a24e1ce9041db9e16ea863631c71a6f0b5d Mon Sep 17 00:00:00 2001 From: Nicolas MASSART Date: Fri, 16 Jan 2026 15:08:45 +0100 Subject: [PATCH 08/12] feat: add analytics-privacy-controller to team ownership and update CODEOWNERS --- .github/CODEOWNERS | 3 +++ teams.json | 1 + 2 files changed, 4 insertions(+) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index f97fef01a56..a003f4cdeaf 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -56,6 +56,7 @@ ## Mobile Platform Team /packages/app-metadata-controller @MetaMask/mobile-platform /packages/analytics-controller @MetaMask/mobile-platform @MetaMask/extension-platform +/packages/analytics-privacy-controller @MetaMask/mobile-platform @MetaMask/extension-platform ## Wallet Integrations Team /packages/chain-agnostic-permission @MetaMask/wallet-integrations @@ -109,6 +110,8 @@ /packages/accounts-controller/package.json @MetaMask/accounts-engineers @MetaMask/core-platform /packages/analytics-controller/package.json @MetaMask/mobile-platform @MetaMask/extension-platform @MetaMask/core-platform /packages/analytics-controller/CHANGELOG.md @MetaMask/mobile-platform @MetaMask/extension-platform @MetaMask/core-platform +/packages/analytics-privacy-controller/package.json @MetaMask/mobile-platform @MetaMask/extension-platform @MetaMask/core-platform +/packages/analytics-privacy-controller/CHANGELOG.md @MetaMask/mobile-platform @MetaMask/extension-platform @MetaMask/core-platform /packages/accounts-controller/CHANGELOG.md @MetaMask/accounts-engineers @MetaMask/core-platform /packages/address-book-controller/package.json @MetaMask/confirmations @MetaMask/core-platform /packages/address-book-controller/CHANGELOG.md @MetaMask/confirmations @MetaMask/core-platform diff --git a/teams.json b/teams.json index dc7dc9d2f93..e73cc6130a7 100644 --- a/teams.json +++ b/teams.json @@ -64,6 +64,7 @@ "metamask/permission-controller": "team-wallet-integrations,team-core-platform", "metamask/permission-log-controller": "team-wallet-integrations,team-core-platform", "metamask/analytics-controller": "team-extension-platform,team-mobile-platform", + "metamask/analytics-privacy-controller": "team-extension-platform,team-mobile-platform", "metamask/remote-feature-flag-controller": "team-extension-platform,team-mobile-platform", "metamask/storage-service": "team-extension-platform,team-mobile-platform" } From 9398fc140c87e4aab9fd16c2236b54c5a91e1afe Mon Sep 17 00:00:00 2001 From: Nicolas MASSART Date: Fri, 16 Jan 2026 15:12:35 +0100 Subject: [PATCH 09/12] fix: update @metamask/controller-utils --- packages/analytics-privacy-controller/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/analytics-privacy-controller/package.json b/packages/analytics-privacy-controller/package.json index 76a28528125..bcdbb6149e4 100644 --- a/packages/analytics-privacy-controller/package.json +++ b/packages/analytics-privacy-controller/package.json @@ -50,7 +50,7 @@ "dependencies": { "@metamask/analytics-controller": "^1.0.0", "@metamask/base-controller": "^9.0.0", - "@metamask/controller-utils": "^7.0.0", + "@metamask/controller-utils": "^11.18.0", "@metamask/messenger": "^0.3.0", "@metamask/utils": "^11.9.0" }, From c32cd48088b516e31411257b1d2a3960821a5b3f Mon Sep 17 00:00:00 2001 From: Nicolas MASSART Date: Fri, 16 Jan 2026 15:20:14 +0100 Subject: [PATCH 10/12] feat: extend AnalyticsPrivacyService exports with new action and event types --- packages/analytics-privacy-controller/src/index.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/analytics-privacy-controller/src/index.ts b/packages/analytics-privacy-controller/src/index.ts index 173dfb63254..e90f5270bf9 100644 --- a/packages/analytics-privacy-controller/src/index.ts +++ b/packages/analytics-privacy-controller/src/index.ts @@ -5,7 +5,11 @@ export { export type { AnalyticsPrivacyControllerOptions } from './AnalyticsPrivacyController'; export { AnalyticsPrivacyService } from './AnalyticsPrivacyService'; -export type { AnalyticsPrivacyServiceOptions } from './AnalyticsPrivacyService'; +export type { + AnalyticsPrivacyServiceActions, + AnalyticsPrivacyServiceEvents, + AnalyticsPrivacyServiceOptions, +} from './AnalyticsPrivacyService'; export type { DataDeleteStatus, From 4bdc42b76f23954aff7971ca177e8e9832a8dc70 Mon Sep 17 00:00:00 2001 From: Nicolas MASSART Date: Fri, 16 Jan 2026 15:22:15 +0100 Subject: [PATCH 11/12] fix(analytics-privacy-controller): export enums as runtime values instead of type-only --- packages/analytics-privacy-controller/src/index.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/analytics-privacy-controller/src/index.ts b/packages/analytics-privacy-controller/src/index.ts index e90f5270bf9..43a07d92ed3 100644 --- a/packages/analytics-privacy-controller/src/index.ts +++ b/packages/analytics-privacy-controller/src/index.ts @@ -11,9 +11,8 @@ export type { AnalyticsPrivacyServiceOptions, } from './AnalyticsPrivacyService'; +export { DataDeleteStatus, DataDeleteResponseStatus } from './types'; export type { - DataDeleteStatus, - DataDeleteResponseStatus, IDeleteRegulationResponse, IDeleteRegulationStatus, IDeleteRegulationStatusResponse, From fa81dffb4727634270cc116e44542c48ec069cfb Mon Sep 17 00:00:00 2001 From: Nicolas MASSART Date: Fri, 16 Jan 2026 15:25:03 +0100 Subject: [PATCH 12/12] fix: update lock file --- yarn.lock | 57 +++---------------------------------------------------- 1 file changed, 3 insertions(+), 54 deletions(-) diff --git a/yarn.lock b/yarn.lock index dda24c71df0..9c9bf1b5f35 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2557,7 +2557,7 @@ __metadata: "@metamask/analytics-controller": "npm:^1.0.0" "@metamask/auto-changelog": "npm:^3.4.4" "@metamask/base-controller": "npm:^9.0.0" - "@metamask/controller-utils": "npm:^7.0.0" + "@metamask/controller-utils": "npm:^11.18.0" "@metamask/messenger": "npm:^0.3.0" "@metamask/utils": "npm:^11.9.0" "@ts-bridge/cli": "npm:^0.6.4" @@ -3046,21 +3046,6 @@ __metadata: languageName: unknown linkType: soft -"@metamask/controller-utils@npm:^7.0.0": - version: 7.0.0 - resolution: "@metamask/controller-utils@npm:7.0.0" - dependencies: - "@metamask/eth-query": "npm:^4.0.0" - "@metamask/ethjs-unit": "npm:^0.2.1" - "@metamask/utils": "npm:^8.2.0" - "@spruceid/siwe-parser": "npm:1.1.3" - eth-ens-namehash: "npm:^2.0.8" - ethereumjs-util: "npm:^7.0.10" - fast-deep-equal: "npm:^3.1.3" - checksum: 10/405b23bf7066ce410f5e8a09bbf63056fa36ebfa7c8450ef83e76b5ce3eecd160d2d8ad2e4023d79bf611b1855f45f1eb834cb51028e7f87964cc3b322ae708d - languageName: node - linkType: hard - "@metamask/core-backend@npm:^5.0.0, @metamask/core-backend@workspace:packages/core-backend": version: 0.0.0-use.local resolution: "@metamask/core-backend@workspace:packages/core-backend" @@ -3706,16 +3691,6 @@ __metadata: languageName: node linkType: hard -"@metamask/ethjs-unit@npm:^0.2.1": - version: 0.2.1 - resolution: "@metamask/ethjs-unit@npm:0.2.1" - dependencies: - bn.js: "npm:4.11.6" - number-to-bn: "npm:1.7.0" - checksum: 10/a67792099e316c102d640782a538359b30937db5d9f3b796e3dc1a03415063632765828cfe1f6b0c37ed8584a3a92f3f1522a2ced40ba0a96766114036db21f3 - languageName: node - linkType: hard - "@metamask/ethjs-unit@npm:^0.3.0": version: 0.3.0 resolution: "@metamask/ethjs-unit@npm:0.3.0" @@ -5054,7 +5029,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/superstruct@npm:^3.0.0, @metamask/superstruct@npm:^3.1.0, @metamask/superstruct@npm:^3.2.1": +"@metamask/superstruct@npm:^3.1.0, @metamask/superstruct@npm:^3.2.1": version: 3.2.1 resolution: "@metamask/superstruct@npm:3.2.1" checksum: 10/9e29380f2cf8b129283ccb2b568296d92682b705109ba62dbd7739ffd6a1982fe38c7228cdcf3cbee94dbcdd5fcc1c846ab9d1dd3582167154f914422fcff547 @@ -5250,23 +5225,6 @@ __metadata: languageName: node linkType: hard -"@metamask/utils@npm:^8.2.0": - version: 8.5.0 - resolution: "@metamask/utils@npm:8.5.0" - dependencies: - "@ethereumjs/tx": "npm:^4.2.0" - "@metamask/superstruct": "npm:^3.0.0" - "@noble/hashes": "npm:^1.3.1" - "@scure/base": "npm:^1.1.3" - "@types/debug": "npm:^4.1.7" - debug: "npm:^4.3.4" - pony-cause: "npm:^2.1.10" - semver: "npm:^7.5.4" - uuid: "npm:^9.0.1" - checksum: 10/68a42a55f7dc750b75467fb7c05a496c20dac073a2753e0f4d9642c4d8dcb3f9ddf51a09d30337e11637f1777f3dfe22e15b5159dbafb0fdb7bd8c9236056153 - languageName: node - linkType: hard - "@metamask/utils@npm:^9.0.0": version: 9.3.0 resolution: "@metamask/utils@npm:9.3.0" @@ -5739,15 +5697,6 @@ __metadata: languageName: node linkType: hard -"@spruceid/siwe-parser@npm:1.1.3": - version: 1.1.3 - resolution: "@spruceid/siwe-parser@npm:1.1.3" - dependencies: - apg-js: "npm:^4.1.1" - checksum: 10/c953fa1e79c633a92f030b68a44225b28c71396553dc5eb8d4d5b263e8b2e5b988131720170df2eaf202ee5251d4369ccff99c130b691a1accca2a1ff93b1111 - languageName: node - linkType: hard - "@spruceid/siwe-parser@npm:2.1.0": version: 2.1.0 resolution: "@spruceid/siwe-parser@npm:2.1.0" @@ -8760,7 +8709,7 @@ __metadata: languageName: node linkType: hard -"ethereumjs-util@npm:^7.0.10, ethereumjs-util@npm:^7.1.2": +"ethereumjs-util@npm:^7.1.2": version: 7.1.5 resolution: "ethereumjs-util@npm:7.1.5" dependencies: