From bc4da885403690e447e0986935bd8be8da4c9ea5 Mon Sep 17 00:00:00 2001 From: Nicolas MASSART Date: Thu, 15 Jan 2026 14:21:42 +0100 Subject: [PATCH 01/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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: From 843d18b7ce35faedbe398ea8c120ace289f040b7 Mon Sep 17 00:00:00 2001 From: Nicolas MASSART Date: Mon, 26 Jan 2026 17:34:16 +0100 Subject: [PATCH 13/16] refactor(pr feedback): update analytics privacy controller to improve data recording handling --- ...csPrivacyController-method-action-types.ts | 11 +- .../src/AnalyticsPrivacyController.test.ts | 175 ++++++++------- .../src/AnalyticsPrivacyController.ts | 84 ++++---- .../src/AnalyticsPrivacyService.test.ts | 164 ++++++-------- .../src/AnalyticsPrivacyService.ts | 203 +++++++++--------- .../src/constants.ts | 18 -- .../analytics-privacy-controller/src/index.ts | 7 +- .../{AnalyticsPrivacyLogger.ts => logger.ts} | 0 .../src/selectors.test.ts | 18 +- .../src/selectors.ts | 7 +- .../analytics-privacy-controller/src/types.ts | 46 ++-- 11 files changed, 369 insertions(+), 364 deletions(-) delete mode 100644 packages/analytics-privacy-controller/src/constants.ts rename packages/analytics-privacy-controller/src/{AnalyticsPrivacyLogger.ts => logger.ts} (100%) 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 8e44106c441..496e08f7de9 100644 --- a/packages/analytics-privacy-controller/src/AnalyticsPrivacyController-method-action-types.ts +++ b/packages/analytics-privacy-controller/src/AnalyticsPrivacyController-method-action-types.ts @@ -62,7 +62,16 @@ export type AnalyticsPrivacyControllerIsDataRecordedAction = { * 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) + * The flag can only be set to `true` (indicating data has been collected). + * It cannot be explicitly set to `false` - it is only reset to `false` when + * a new deletion task is created via `createDataDeletionTask`. + * + * If `saveDataRecording` is `false` or the flag is already `true`, this method + * does nothing. This design ensures the flag only moves from `false` to `true` + * and cannot be manually reset, maintaining data integrity for compliance tracking. + * + * @param saveDataRecording - Whether to save the data recording flag (default: true). + * When `false`, this method is a no-op regardless of current state. */ export type AnalyticsPrivacyControllerUpdateDataRecordingFlagAction = { type: `AnalyticsPrivacyController:updateDataRecordingFlag`; diff --git a/packages/analytics-privacy-controller/src/AnalyticsPrivacyController.test.ts b/packages/analytics-privacy-controller/src/AnalyticsPrivacyController.test.ts index 5982438af78..12569f1d746 100644 --- a/packages/analytics-privacy-controller/src/AnalyticsPrivacyController.test.ts +++ b/packages/analytics-privacy-controller/src/AnalyticsPrivacyController.test.ts @@ -13,7 +13,7 @@ import type { AnalyticsPrivacyControllerState, } from '.'; import type { AnalyticsPrivacyServiceActions } from './AnalyticsPrivacyService'; -import { DataDeleteResponseStatus, DataDeleteStatus } from './types'; +import { DATA_DELETE_RESPONSE_STATUSES, DATA_DELETE_STATUSES } from './types'; type SetupControllerOptions = { state?: Partial; @@ -82,7 +82,7 @@ function setupController( rootMessenger.registerActionHandler( 'AnalyticsPrivacyService:createDataDeletionTask', jest.fn().mockResolvedValue({ - status: DataDeleteResponseStatus.Success, + status: DATA_DELETE_RESPONSE_STATUSES.Success, regulateId: 'test-regulate-id', }), ); @@ -90,8 +90,8 @@ function setupController( rootMessenger.registerActionHandler( 'AnalyticsPrivacyService:checkDataDeleteStatus', jest.fn().mockResolvedValue({ - status: DataDeleteResponseStatus.Success, - dataDeleteStatus: DataDeleteStatus.Finished, + status: DATA_DELETE_RESPONSE_STATUSES.Success, + dataDeleteStatus: DATA_DELETE_STATUSES.Finished, }), ); @@ -119,11 +119,11 @@ function setupController( describe('AnalyticsPrivacyController', () => { describe('getDefaultAnalyticsPrivacyControllerState', () => { - it('returns default state with dataRecorded false and null regulation fields', () => { + it('returns default state with hasCollectedDataSinceDeletionRequest false and null regulation fields', () => { const defaults = getDefaultAnalyticsPrivacyControllerState(); expect(defaults).toStrictEqual({ - dataRecorded: false, + hasCollectedDataSinceDeletionRequest: false, deleteRegulationId: null, deleteRegulationTimestamp: null, }); @@ -148,7 +148,7 @@ describe('AnalyticsPrivacyController', () => { it('initializes with provided state', () => { const initialState = { - dataRecorded: true, + hasCollectedDataSinceDeletionRequest: true, deleteRegulationId: 'existing-id', deleteRegulationTimestamp: new Date('2026-01-15T12:00:00Z').getTime(), }; @@ -160,12 +160,12 @@ describe('AnalyticsPrivacyController', () => { it('merges provided partial state with default values', () => { const partialState = { - dataRecorded: true, + hasCollectedDataSinceDeletionRequest: true, }; const { controller } = setupController({ state: partialState }); - expect(controller.state.dataRecorded).toBe(true); + expect(controller.state.hasCollectedDataSinceDeletionRequest).toBe(true); expect(controller.state.deleteRegulationId).toBeNull(); expect(controller.state.deleteRegulationTimestamp).toBeNull(); }); @@ -183,11 +183,11 @@ describe('AnalyticsPrivacyController', () => { 'AnalyticsPrivacyController:createDataDeletionTask', ); - expect(response.status).toBe(DataDeleteResponseStatus.Success); + expect(response.status).toBe(DATA_DELETE_RESPONSE_STATUSES.Success); expect(response.regulateId).toBe('test-regulate-id'); expect(controller.state.deleteRegulationId).toBe('test-regulate-id'); expect(controller.state.deleteRegulationTimestamp).toBe(fixedTimestamp); - expect(controller.state.dataRecorded).toBe(false); + expect(controller.state.hasCollectedDataSinceDeletionRequest).toBe(false); jest.useRealTimers(); }); @@ -230,7 +230,7 @@ describe('AnalyticsPrivacyController', () => { rootMessenger.registerActionHandler( 'AnalyticsPrivacyService:createDataDeletionTask', jest.fn().mockResolvedValue({ - status: DataDeleteResponseStatus.Success, + status: DATA_DELETE_RESPONSE_STATUSES.Success, regulateId: 'test-regulate-id', }), ); @@ -274,11 +274,11 @@ describe('AnalyticsPrivacyController', () => { 'AnalyticsPrivacyController:createDataDeletionTask', ); - expect(response.status).toBe(DataDeleteResponseStatus.Success); + expect(response.status).toBe(DATA_DELETE_RESPONSE_STATUSES.Success); expect(response.regulateId).toBe('test-regulate-id'); expect(eventListener).toHaveBeenCalledWith({ - status: DataDeleteResponseStatus.Success, + status: DATA_DELETE_RESPONSE_STATUSES.Success, regulateId: 'test-regulate-id', }); }); @@ -321,7 +321,7 @@ describe('AnalyticsPrivacyController', () => { rootMessenger.registerActionHandler( 'AnalyticsPrivacyService:createDataDeletionTask', jest.fn().mockResolvedValue({ - status: DataDeleteResponseStatus.Success, + status: DATA_DELETE_RESPONSE_STATUSES.Success, regulateId: 'test-regulate-id', }), ); @@ -344,11 +344,13 @@ describe('AnalyticsPrivacyController', () => { 'AnalyticsPrivacyController:createDataDeletionTask', ); - expect(response.status).toBe(DataDeleteResponseStatus.Failure); - expect(response.error).toBe('Analytics ID not found'); + expect(response.status).toBe(DATA_DELETE_RESPONSE_STATUSES.Failure); + expect(response.error).toBe( + 'Analytics ID not found. You need to set up AnalyticsController with an analytics ID. You can do this by initializing the AnalyticsController with a valid analytics ID before creating a data deletion task.', + ); }); - it('returns response without updating state when regulateId is undefined', async () => { + it('returns error response without updating state when service throws error for missing regulateId', async () => { const rootMessenger = new Messenger< MockAnyNamespace, | AnalyticsPrivacyControllerActions @@ -385,10 +387,13 @@ describe('AnalyticsPrivacyController', () => { rootMessenger.registerActionHandler( 'AnalyticsPrivacyService:createDataDeletionTask', - jest.fn().mockResolvedValue({ - status: DataDeleteResponseStatus.Success, - // regulateId is undefined - }), + jest + .fn() + .mockRejectedValue( + new Error( + 'Malformed response from Segment API: missing or invalid regulateId', + ), + ), ); rootMessenger.delegate({ @@ -409,11 +414,13 @@ describe('AnalyticsPrivacyController', () => { 'AnalyticsPrivacyController:createDataDeletionTask', ); - expect(response.status).toBe(DataDeleteResponseStatus.Success); - expect(response.regulateId).toBeUndefined(); + expect(response.status).toBe(DATA_DELETE_RESPONSE_STATUSES.Failure); + expect(response.error).toBe( + 'Malformed response from Segment API: missing or invalid regulateId', + ); }); - it('returns response without updating state when regulateId is empty string', async () => { + it('returns error response without updating state when service throws error for empty regulateId', async () => { const rootMessenger = new Messenger< MockAnyNamespace, | AnalyticsPrivacyControllerActions @@ -448,14 +455,15 @@ describe('AnalyticsPrivacyController', () => { }), ); - // 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.Success, - regulateId: '', // Empty string is falsy - }), + jest + .fn() + .mockRejectedValue( + new Error( + 'Malformed response from Segment API: missing or invalid regulateId', + ), + ), ); rootMessenger.delegate({ @@ -474,12 +482,15 @@ describe('AnalyticsPrivacyController', () => { 'AnalyticsPrivacyController:createDataDeletionTask', ); - expect(response.status).toBe(DataDeleteResponseStatus.Success); - // Empty string is falsy, so condition fails and state is not updated + expect(response.status).toBe(DATA_DELETE_RESPONSE_STATUSES.Failure); + expect(response.error).toBe( + 'Malformed response from Segment API: missing or invalid regulateId', + ); + // State should not be updated when service throws error expect(controller.state.deleteRegulationId).toBeNull(); }); - it('does not update state when regulateId is undefined', async () => { + it('returns error response and does not update state when service throws error for undefined regulateId', async () => { const rootMessenger = new Messenger< MockAnyNamespace, | AnalyticsPrivacyControllerActions @@ -516,10 +527,13 @@ describe('AnalyticsPrivacyController', () => { rootMessenger.registerActionHandler( 'AnalyticsPrivacyService:createDataDeletionTask', - jest.fn().mockResolvedValue({ - status: DataDeleteResponseStatus.Success, - regulateId: undefined as string | undefined, - }), + jest + .fn() + .mockRejectedValue( + new Error( + 'Malformed response from Segment API: missing or invalid regulateId', + ), + ), ); rootMessenger.delegate({ @@ -538,7 +552,10 @@ describe('AnalyticsPrivacyController', () => { 'AnalyticsPrivacyController:createDataDeletionTask', ); - expect(response.status).toBe(DataDeleteResponseStatus.Success); + expect(response.status).toBe(DATA_DELETE_RESPONSE_STATUSES.Failure); + expect(response.error).toBe( + 'Malformed response from Segment API: missing or invalid regulateId', + ); expect(controller.state.deleteRegulationId).toBeNull(); }); @@ -579,7 +596,7 @@ describe('AnalyticsPrivacyController', () => { rootMessenger.registerActionHandler( 'AnalyticsPrivacyService:createDataDeletionTask', jest.fn().mockResolvedValue({ - status: DataDeleteResponseStatus.Success, + status: DATA_DELETE_RESPONSE_STATUSES.Success, regulateId: 'test-regulate-id', }), ); @@ -602,7 +619,7 @@ describe('AnalyticsPrivacyController', () => { 'AnalyticsPrivacyController:createDataDeletionTask', ); - expect(response.status).toBe(DataDeleteResponseStatus.Failure); + expect(response.status).toBe(DATA_DELETE_RESPONSE_STATUSES.Failure); expect(response.error).toBe('Analytics ID not found'); }); @@ -664,11 +681,11 @@ describe('AnalyticsPrivacyController', () => { 'AnalyticsPrivacyController:createDataDeletionTask', ); - expect(response.status).toBe(DataDeleteResponseStatus.Failure); + expect(response.status).toBe(DATA_DELETE_RESPONSE_STATUSES.Failure); expect(response.error).toBe('Analytics Deletion Task Error'); }); - it('returns error response when service returns error status', async () => { + it('returns error response when service throws error', async () => { const rootMessenger = new Messenger< MockAnyNamespace, | AnalyticsPrivacyControllerActions @@ -705,10 +722,7 @@ describe('AnalyticsPrivacyController', () => { rootMessenger.registerActionHandler( 'AnalyticsPrivacyService:createDataDeletionTask', - jest.fn().mockResolvedValue({ - status: DataDeleteResponseStatus.Failure, - error: 'Service error', - }), + jest.fn().mockRejectedValue(new Error('Service error')), ); rootMessenger.delegate({ @@ -729,11 +743,11 @@ describe('AnalyticsPrivacyController', () => { 'AnalyticsPrivacyController:createDataDeletionTask', ); - expect(response.status).toBe(DataDeleteResponseStatus.Failure); + expect(response.status).toBe(DATA_DELETE_RESPONSE_STATUSES.Failure); expect(response.error).toBe('Service error'); }); - it('preserves initial state when service returns error status', async () => { + it('preserves initial state when service throws error', async () => { const rootMessenger = new Messenger< MockAnyNamespace, | AnalyticsPrivacyControllerActions @@ -770,10 +784,7 @@ describe('AnalyticsPrivacyController', () => { rootMessenger.registerActionHandler( 'AnalyticsPrivacyService:createDataDeletionTask', - jest.fn().mockResolvedValue({ - status: DataDeleteResponseStatus.Failure, - error: 'Service error', - }), + jest.fn().mockRejectedValue(new Error('Service error')), ); const controller = new AnalyticsPrivacyController({ @@ -796,7 +807,7 @@ describe('AnalyticsPrivacyController', () => { state: { deleteRegulationId: 'test-regulation-id', deleteRegulationTimestamp: testTimestamp, - dataRecorded: true, + hasCollectedDataSinceDeletionRequest: true, }, }); @@ -806,7 +817,7 @@ describe('AnalyticsPrivacyController', () => { expect(status).toStrictEqual({ deletionRequestTimestamp: testTimestamp, - dataDeletionRequestStatus: DataDeleteStatus.Finished, + dataDeletionRequestStatus: DATA_DELETE_STATUSES.Finished, hasCollectedDataSinceDeletionRequest: true, }); }); @@ -820,7 +831,7 @@ describe('AnalyticsPrivacyController', () => { expect(status).toStrictEqual({ deletionRequestTimestamp: undefined, - dataDeletionRequestStatus: DataDeleteStatus.Unknown, + dataDeletionRequestStatus: DATA_DELETE_STATUSES.Unknown, hasCollectedDataSinceDeletionRequest: false, }); }); @@ -863,8 +874,8 @@ describe('AnalyticsPrivacyController', () => { rootMessenger.registerActionHandler( 'AnalyticsPrivacyService:checkDataDeleteStatus', jest.fn().mockResolvedValue({ - status: DataDeleteResponseStatus.Success, - dataDeleteStatus: DataDeleteStatus.Finished, + status: DATA_DELETE_RESPONSE_STATUSES.Success, + dataDeleteStatus: DATA_DELETE_STATUSES.Finished, }), ); @@ -882,7 +893,7 @@ describe('AnalyticsPrivacyController', () => { state: { deleteRegulationId: 'test-regulation-id', deleteRegulationTimestamp: null, // null timestamp - dataRecorded: false, + hasCollectedDataSinceDeletionRequest: false, }, }); @@ -891,7 +902,9 @@ describe('AnalyticsPrivacyController', () => { ); expect(status.deletionRequestTimestamp).toBeUndefined(); - expect(status.dataDeletionRequestStatus).toBe(DataDeleteStatus.Finished); + expect(status.dataDeletionRequestStatus).toBe( + DATA_DELETE_STATUSES.Finished, + ); }); it('returns unknown deletion status when service throws Error', async () => { @@ -942,7 +955,7 @@ describe('AnalyticsPrivacyController', () => { state: { deleteRegulationId: 'test-regulation-id', deleteRegulationTimestamp: testTimestamp, - dataRecorded: false, + hasCollectedDataSinceDeletionRequest: false, }, }); @@ -950,7 +963,9 @@ describe('AnalyticsPrivacyController', () => { 'AnalyticsPrivacyController:checkDataDeleteStatus', ); - expect(status.dataDeletionRequestStatus).toBe(DataDeleteStatus.Unknown); + expect(status.dataDeletionRequestStatus).toBe( + DATA_DELETE_STATUSES.Unknown, + ); expect(status.deletionRequestTimestamp).toBe(testTimestamp); expect(status.hasCollectedDataSinceDeletionRequest).toBe(false); }); @@ -1013,7 +1028,7 @@ describe('AnalyticsPrivacyController', () => { it('returns true when data has been recorded', () => { const { rootMessenger } = setupController({ state: { - dataRecorded: true, + hasCollectedDataSinceDeletionRequest: true, }, }); @@ -1036,10 +1051,10 @@ describe('AnalyticsPrivacyController', () => { }); describe('AnalyticsPrivacyController:updateDataRecordingFlag', () => { - it('sets dataRecorded to true when saveDataRecording is true', () => { + it('sets hasCollectedDataSinceDeletionRequest to true when saveDataRecording is true', () => { const { controller, rootMessenger } = setupController({ state: { - dataRecorded: false, + hasCollectedDataSinceDeletionRequest: false, }, }); @@ -1048,13 +1063,13 @@ describe('AnalyticsPrivacyController', () => { true, ); - expect(controller.state.dataRecorded).toBe(true); + expect(controller.state.hasCollectedDataSinceDeletionRequest).toBe(true); }); - it('preserves dataRecorded value when saveDataRecording is false', () => { + it('preserves hasCollectedDataSinceDeletionRequest value when saveDataRecording is false', () => { const { controller, rootMessenger } = setupController({ state: { - dataRecorded: false, + hasCollectedDataSinceDeletionRequest: false, }, }); @@ -1063,13 +1078,13 @@ describe('AnalyticsPrivacyController', () => { false, ); - expect(controller.state.dataRecorded).toBe(false); + expect(controller.state.hasCollectedDataSinceDeletionRequest).toBe(false); }); - it('preserves dataRecorded value when already true', () => { + it('preserves hasCollectedDataSinceDeletionRequest value when already true', () => { const { controller, rootMessenger } = setupController({ state: { - dataRecorded: true, + hasCollectedDataSinceDeletionRequest: true, }, }); @@ -1078,25 +1093,25 @@ describe('AnalyticsPrivacyController', () => { true, ); - expect(controller.state.dataRecorded).toBe(true); + expect(controller.state.hasCollectedDataSinceDeletionRequest).toBe(true); }); - it('sets dataRecorded to true when saveDataRecording is omitted', () => { + it('sets hasCollectedDataSinceDeletionRequest to true when saveDataRecording is omitted', () => { const { controller, rootMessenger } = setupController({ state: { - dataRecorded: false, + hasCollectedDataSinceDeletionRequest: false, }, }); rootMessenger.call('AnalyticsPrivacyController:updateDataRecordingFlag'); - expect(controller.state.dataRecorded).toBe(true); + expect(controller.state.hasCollectedDataSinceDeletionRequest).toBe(true); }); - it('emits dataRecordingFlagUpdated event with true when dataRecorded changes from false to true', () => { + it('emits dataRecordingFlagUpdated event with true when hasCollectedDataSinceDeletionRequest changes from false to true', () => { const { rootMessenger } = setupController({ state: { - dataRecorded: false, + hasCollectedDataSinceDeletionRequest: false, }, }); @@ -1117,7 +1132,7 @@ describe('AnalyticsPrivacyController', () => { it('does not emit event when saveDataRecording is false', () => { const { rootMessenger } = setupController({ state: { - dataRecorded: false, + hasCollectedDataSinceDeletionRequest: false, }, }); @@ -1137,7 +1152,7 @@ describe('AnalyticsPrivacyController', () => { }); describe('stateChange event', () => { - it('emits stateChange event with new state when dataRecorded is updated', () => { + it('emits stateChange event with new state when hasCollectedDataSinceDeletionRequest is updated', () => { const { rootMessenger, messenger } = setupController(); const eventListener = jest.fn(); @@ -1153,7 +1168,7 @@ describe('AnalyticsPrivacyController', () => { expect(eventListener).toHaveBeenCalled(); const [newState] = eventListener.mock.calls[0]; - expect(newState.dataRecorded).toBe(true); + expect(newState.hasCollectedDataSinceDeletionRequest).toBe(true); }); }); }); diff --git a/packages/analytics-privacy-controller/src/AnalyticsPrivacyController.ts b/packages/analytics-privacy-controller/src/AnalyticsPrivacyController.ts index 0215c3c26ce..8be79ecb613 100644 --- a/packages/analytics-privacy-controller/src/AnalyticsPrivacyController.ts +++ b/packages/analytics-privacy-controller/src/AnalyticsPrivacyController.ts @@ -8,9 +8,9 @@ import { BaseController } from '@metamask/base-controller'; import type { Messenger } from '@metamask/messenger'; import type { AnalyticsPrivacyControllerMethodActions } from './AnalyticsPrivacyController-method-action-types'; -import { projectLogger as log } from './AnalyticsPrivacyLogger'; import type { AnalyticsPrivacyServiceActions } from './AnalyticsPrivacyService'; -import { DataDeleteResponseStatus, DataDeleteStatus } from './types'; +import { projectLogger as log } from './logger'; +import { DATA_DELETE_RESPONSE_STATUSES, DATA_DELETE_STATUSES } from './types'; import type { IDeleteRegulationResponse, IDeleteRegulationStatus, @@ -34,7 +34,7 @@ export type AnalyticsPrivacyControllerState = { /** * Indicates if data has been recorded since the last deletion request. */ - dataRecorded: boolean; + hasCollectedDataSinceDeletionRequest: boolean; /** * Segment's data deletion regulation ID. @@ -56,7 +56,7 @@ export type AnalyticsPrivacyControllerState = { */ export function getDefaultAnalyticsPrivacyControllerState(): AnalyticsPrivacyControllerState { return { - dataRecorded: false, + hasCollectedDataSinceDeletionRequest: false, deleteRegulationId: null, deleteRegulationTimestamp: null, }; @@ -66,7 +66,7 @@ export function getDefaultAnalyticsPrivacyControllerState(): AnalyticsPrivacyCon * The metadata for each property in {@link AnalyticsPrivacyControllerState}. */ const analyticsPrivacyControllerMetadata = { - dataRecorded: { + hasCollectedDataSinceDeletionRequest: { includeInStateLogs: true, persist: true, includeInDebugSnapshot: true, @@ -221,7 +221,8 @@ export class AnalyticsPrivacyController extends BaseController< ); log('AnalyticsPrivacyController initialized', { - dataRecorded: this.state.dataRecorded, + hasCollectedDataSinceDeletionRequest: + this.state.hasCollectedDataSinceDeletionRequest, hasDeleteRegulationId: Boolean(this.state.deleteRegulationId), deleteRegulationTimestamp: this.state.deleteRegulationTimestamp, }); @@ -241,11 +242,13 @@ export class AnalyticsPrivacyController extends BaseController< const { analyticsId } = analyticsControllerState; if (!analyticsId || analyticsId.trim() === '') { - const error = new Error('Analytics ID not found'); + const error = new Error( + 'Analytics ID not found. You need to set up AnalyticsController with an analytics ID. You can do this by initializing the AnalyticsController with a valid analytics ID before creating a data deletion task.', + ); log('Analytics Deletion Task Error', error); return { - status: DataDeleteResponseStatus.Failure, - error: 'Analytics ID not found', + status: DATA_DELETE_RESPONSE_STATUSES.Failure, + error: error.message, }; } @@ -254,27 +257,22 @@ export class AnalyticsPrivacyController extends BaseController< analyticsId, ); - if ( - response.status === DataDeleteResponseStatus.Success && - response.regulateId && - typeof response.regulateId === 'string' && - response.regulateId.trim() !== '' - ) { - const deletionTimestamp = Date.now(); - // Already validated as non-empty string above - const { regulateId } = response; - - this.update((state) => { - state.deleteRegulationId = regulateId; - state.deleteRegulationTimestamp = deletionTimestamp; - state.dataRecorded = false; - }); - - this.messenger.publish( - `${controllerName}:dataDeletionTaskCreated`, - response, - ); - } + const deletionTimestamp = Date.now(); + // Service validates and throws if regulateId is missing, so it's always defined here + const { regulateId } = response; + // Type assertion is safe because service throws if regulateId is missing + const validRegulateId: string = regulateId as string; + + this.update((state) => { + state.deleteRegulationId = validRegulateId; + state.deleteRegulationTimestamp = deletionTimestamp; + state.hasCollectedDataSinceDeletionRequest = false; + }); + + this.messenger.publish( + `${controllerName}:dataDeletionTaskCreated`, + response, + ); return response; } catch (error) { @@ -284,7 +282,7 @@ export class AnalyticsPrivacyController extends BaseController< ? error.message : 'Analytics Deletion Task Error'; return { - status: DataDeleteResponseStatus.Failure, + status: DATA_DELETE_RESPONSE_STATUSES.Failure, error: errorMessage, }; } @@ -298,7 +296,7 @@ export class AnalyticsPrivacyController extends BaseController< async checkDataDeleteStatus(): Promise { const status: IDeleteRegulationStatus = { deletionRequestTimestamp: undefined, - dataDeletionRequestStatus: DataDeleteStatus.Unknown, + dataDeletionRequestStatus: DATA_DELETE_STATUSES.Unknown, hasCollectedDataSinceDeletionRequest: false, }; @@ -316,12 +314,13 @@ export class AnalyticsPrivacyController extends BaseController< dataDeletionTaskStatus.dataDeleteStatus; } catch (error) { log('Error checkDataDeleteStatus', error); - status.dataDeletionRequestStatus = DataDeleteStatus.Unknown; + status.dataDeletionRequestStatus = DATA_DELETE_STATUSES.Unknown; } status.deletionRequestTimestamp = this.state.deleteRegulationTimestamp ?? undefined; - status.hasCollectedDataSinceDeletionRequest = this.state.dataRecorded; + status.hasCollectedDataSinceDeletionRequest = + this.state.hasCollectedDataSinceDeletionRequest; return status; } @@ -350,7 +349,7 @@ export class AnalyticsPrivacyController extends BaseController< * @returns true if events have been recorded since the last deletion request */ isDataRecorded(): boolean { - return this.state.dataRecorded; + return this.state.hasCollectedDataSinceDeletionRequest; } /** @@ -358,12 +357,21 @@ export class AnalyticsPrivacyController extends BaseController< * 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) + * The flag can only be set to `true` (indicating data has been collected). + * It cannot be explicitly set to `false` - it is only reset to `false` when + * a new deletion task is created via `createDataDeletionTask`. + * + * If `saveDataRecording` is `false` or the flag is already `true`, this method + * does nothing. This design ensures the flag only moves from `false` to `true` + * and cannot be manually reset, maintaining data integrity for compliance tracking. + * + * @param saveDataRecording - Whether to save the data recording flag (default: true). + * When `false`, this method is a no-op regardless of current state. */ updateDataRecordingFlag(saveDataRecording: boolean = true): void { - if (saveDataRecording && !this.state.dataRecorded) { + if (saveDataRecording && !this.state.hasCollectedDataSinceDeletionRequest) { this.update((state) => { - state.dataRecorded = true; + state.hasCollectedDataSinceDeletionRequest = true; }); this.messenger.publish( diff --git a/packages/analytics-privacy-controller/src/AnalyticsPrivacyService.test.ts b/packages/analytics-privacy-controller/src/AnalyticsPrivacyService.test.ts index 9ea9c79b4e9..fb07333c884 100644 --- a/packages/analytics-privacy-controller/src/AnalyticsPrivacyService.test.ts +++ b/packages/analytics-privacy-controller/src/AnalyticsPrivacyService.test.ts @@ -10,7 +10,7 @@ import type { SinonFakeTimers } from 'sinon'; import type { AnalyticsPrivacyServiceMessenger } from './AnalyticsPrivacyService'; import { AnalyticsPrivacyService } from './AnalyticsPrivacyService'; -import { DataDeleteResponseStatus, DataDeleteStatus } from './types'; +import { DATA_DELETE_RESPONSE_STATUSES, DATA_DELETE_STATUSES } from './types'; describe('AnalyticsPrivacyService', () => { let clock: SinonFakeTimers; @@ -52,12 +52,12 @@ describe('AnalyticsPrivacyService', () => { ); expect(response).toStrictEqual({ - status: DataDeleteResponseStatus.Success, + status: DATA_DELETE_RESPONSE_STATUSES.Success, regulateId, }); }); - it('returns error response when segmentSourceId is empty string', async () => { + it('throws error when segmentSourceId is empty string', async () => { const analyticsId = 'test-analytics-id'; const { rootMessenger } = getService({ @@ -67,18 +67,15 @@ describe('AnalyticsPrivacyService', () => { }, }); - const response = await rootMessenger.call( - 'AnalyticsPrivacyService:createDataDeletionTask', - analyticsId, - ); - - expect(response).toStrictEqual({ - status: DataDeleteResponseStatus.Failure, - error: 'Segment API source ID or endpoint not found', - }); + await expect( + rootMessenger.call( + 'AnalyticsPrivacyService:createDataDeletionTask', + analyticsId, + ), + ).rejects.toThrow('Segment API source ID or endpoint not found'); }); - it('returns error response when segmentRegulationsEndpoint is empty string', async () => { + it('throws error when segmentRegulationsEndpoint is empty string', async () => { const analyticsId = 'test-analytics-id'; const { rootMessenger } = getService({ @@ -88,18 +85,15 @@ describe('AnalyticsPrivacyService', () => { }, }); - const response = await rootMessenger.call( - 'AnalyticsPrivacyService:createDataDeletionTask', - analyticsId, - ); - - expect(response).toStrictEqual({ - status: DataDeleteResponseStatus.Failure, - error: 'Segment API source ID or endpoint not found', - }); + await expect( + rootMessenger.call( + 'AnalyticsPrivacyService:createDataDeletionTask', + analyticsId, + ), + ).rejects.toThrow('Segment API source ID or endpoint not found'); }); - it('returns error response when API returns 500 status', async () => { + it('throws error when API returns 500 status', async () => { const analyticsId = 'test-analytics-id'; nock(segmentRegulationsEndpoint) @@ -114,18 +108,15 @@ describe('AnalyticsPrivacyService', () => { }, }); - const response = await rootMessenger.call( - 'AnalyticsPrivacyService:createDataDeletionTask', - analyticsId, - ); - - expect(response).toStrictEqual({ - status: DataDeleteResponseStatus.Failure, - error: 'Analytics Deletion Task Error', - }); + await expect( + rootMessenger.call( + 'AnalyticsPrivacyService:createDataDeletionTask', + analyticsId, + ), + ).rejects.toThrow('Creating data deletion task failed'); }); - it('returns error response when API response is missing regulateId', async () => { + it('throws error when API response is missing regulateId', async () => { const analyticsId = 'test-analytics-id'; nock(segmentRegulationsEndpoint) @@ -138,15 +129,14 @@ describe('AnalyticsPrivacyService', () => { const { rootMessenger } = getService(); - const response = await rootMessenger.call( - 'AnalyticsPrivacyService:createDataDeletionTask', - analyticsId, + await expect( + rootMessenger.call( + 'AnalyticsPrivacyService:createDataDeletionTask', + analyticsId, + ), + ).rejects.toThrow( + 'Malformed response from Segment API: missing or invalid regulateId', ); - - expect(response).toStrictEqual({ - status: DataDeleteResponseStatus.Failure, - error: 'Analytics Deletion Task Error', - }); }); it('sends request body with DELETE_ONLY regulation type and analyticsId in subjectIds', async () => { @@ -214,7 +204,7 @@ describe('AnalyticsPrivacyService', () => { describe('AnalyticsPrivacyService:checkDataDeleteStatus', () => { it('returns dataDeleteStatus when regulation status is retrieved', async () => { const regulationId = 'test-regulation-id'; - const status = DataDeleteStatus.Finished; + const status = DATA_DELETE_STATUSES.Finished; nock(segmentRegulationsEndpoint) .get(`/regulations/${regulationId}`) @@ -236,26 +226,20 @@ describe('AnalyticsPrivacyService', () => { ); expect(response).toStrictEqual({ - status: DataDeleteResponseStatus.Success, + status: DATA_DELETE_RESPONSE_STATUSES.Success, dataDeleteStatus: status, }); }); - it('returns unknown status when regulationId is empty string', async () => { + it('throws error when regulationId is empty string', async () => { const { rootMessenger } = getService(); - const response = await rootMessenger.call( - 'AnalyticsPrivacyService:checkDataDeleteStatus', - '', - ); - - expect(response).toStrictEqual({ - status: DataDeleteResponseStatus.Failure, - dataDeleteStatus: DataDeleteStatus.Unknown, - }); + await expect( + rootMessenger.call('AnalyticsPrivacyService:checkDataDeleteStatus', ''), + ).rejects.toThrow('Regulation ID or endpoint not configured'); }); - it('returns unknown status when segmentRegulationsEndpoint is empty string', async () => { + it('throws error when segmentRegulationsEndpoint is empty string', async () => { const regulationId = 'test-regulation-id'; const { rootMessenger } = getService({ @@ -265,18 +249,15 @@ describe('AnalyticsPrivacyService', () => { }, }); - const response = await rootMessenger.call( - 'AnalyticsPrivacyService:checkDataDeleteStatus', - regulationId, - ); - - expect(response).toStrictEqual({ - status: DataDeleteResponseStatus.Failure, - dataDeleteStatus: DataDeleteStatus.Unknown, - }); + await expect( + rootMessenger.call( + 'AnalyticsPrivacyService:checkDataDeleteStatus', + regulationId, + ), + ).rejects.toThrow('Regulation ID or endpoint not configured'); }); - it('returns unknown status when API returns 500 status', async () => { + it('throws error when API returns 500 status', async () => { const regulationId = 'test-regulation-id'; nock(segmentRegulationsEndpoint) @@ -291,15 +272,12 @@ describe('AnalyticsPrivacyService', () => { }, }); - const response = await rootMessenger.call( - 'AnalyticsPrivacyService:checkDataDeleteStatus', - regulationId, - ); - - expect(response).toStrictEqual({ - status: DataDeleteResponseStatus.Failure, - dataDeleteStatus: DataDeleteStatus.Unknown, - }); + await expect( + rootMessenger.call( + 'AnalyticsPrivacyService:checkDataDeleteStatus', + regulationId, + ), + ).rejects.toThrow('Checking data deletion status failed'); }); it('returns unknown status when API response is missing overallStatus', async () => { @@ -325,14 +303,14 @@ describe('AnalyticsPrivacyService', () => { ); expect(response).toStrictEqual({ - status: DataDeleteResponseStatus.Success, - dataDeleteStatus: DataDeleteStatus.Unknown, + status: DATA_DELETE_RESPONSE_STATUSES.Success, + dataDeleteStatus: DATA_DELETE_STATUSES.Unknown, }); }); it('sends GET request with application/vnd.segment.v1+json Content-Type header', async () => { const regulationId = 'test-regulation-id'; - const status = DataDeleteStatus.Running; + const status = DATA_DELETE_STATUSES.Running; const scope = nock(segmentRegulationsEndpoint, { reqheaders: { @@ -382,14 +360,12 @@ describe('AnalyticsPrivacyService', () => { onRetryListener(); }); - expect( - await rootMessenger.call( + await expect( + rootMessenger.call( 'AnalyticsPrivacyService:createDataDeletionTask', 'test-analytics-id', ), - ).toMatchObject({ - status: DataDeleteResponseStatus.Failure, - }); + ).rejects.toThrow('Creating data deletion task failed'); expect(onRetryListener).toHaveBeenCalled(); }); @@ -412,25 +388,21 @@ describe('AnalyticsPrivacyService', () => { // Make 3 failed requests to trigger circuit breaker for (let i = 0; i < 3; i++) { - expect( - await rootMessenger.call( + await expect( + rootMessenger.call( 'AnalyticsPrivacyService:createDataDeletionTask', 'test-analytics-id', ), - ).toMatchObject({ - status: DataDeleteResponseStatus.Failure, - }); + ).rejects.toThrow('Creating data deletion task failed'); } - // 4th request should trigger circuit breaker - service catches and returns error - expect( - await rootMessenger.call( + // 4th request should trigger circuit breaker - service throws error + await expect( + rootMessenger.call( 'AnalyticsPrivacyService:createDataDeletionTask', 'test-analytics-id', ), - ).toMatchObject({ - status: DataDeleteResponseStatus.Failure, - }); + ).rejects.toThrow('Execution prevented because the circuit breaker is open'); expect(onBreakListener).toHaveBeenCalled(); }); @@ -476,14 +448,12 @@ describe('AnalyticsPrivacyService', () => { const onDegradedListener = jest.fn(); service.onDegraded(onDegradedListener); - expect( - await rootMessenger.call( + await expect( + rootMessenger.call( 'AnalyticsPrivacyService:createDataDeletionTask', 'test-analytics-id', ), - ).toMatchObject({ - status: DataDeleteResponseStatus.Failure, - }); + ).rejects.toThrow('Creating data deletion task failed'); expect(onDegradedListener).toHaveBeenCalled(); }); diff --git a/packages/analytics-privacy-controller/src/AnalyticsPrivacyService.ts b/packages/analytics-privacy-controller/src/AnalyticsPrivacyService.ts index ca67666e43b..91b3aa5a606 100644 --- a/packages/analytics-privacy-controller/src/AnalyticsPrivacyService.ts +++ b/packages/analytics-privacy-controller/src/AnalyticsPrivacyService.ts @@ -6,19 +6,29 @@ 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 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 { DATA_DELETE_RESPONSE_STATUSES, DATA_DELETE_STATUSES } from './types'; +import type { DataDeleteStatus } from './types'; import type { IDeleteRegulationResponse, IDeleteRegulationStatusResponse, } from './types'; +/** + * Segment API regulation type for DELETE_ONLY operations. + */ +const SEGMENT_REGULATION_TYPE_DELETE_ONLY = 'DELETE_ONLY'; + +/** + * Segment API subject type for user ID operations. + */ +const SEGMENT_SUBJECT_TYPE_USER_ID = 'USER_ID'; + +/** + * Segment API Content-Type header value. + */ +const SEGMENT_CONTENT_TYPE = 'application/vnd.segment.v1+json'; + // === GENERAL === /** @@ -111,8 +121,11 @@ export type AnalyticsPrivacyServiceOptions = { segmentSourceId: string; /** - * Base URL for the proxy endpoint (not Segment API directly). - * The proxy forwards requests to Segment API and adds authentication tokens. + * Base URL for the proxy endpoint that communicates with Segment's Regulations API. + * This is a proxy endpoint (not Segment API directly) that forwards requests to Segment's + * Regulations API and adds authentication tokens. The endpoint URL varies by environment + * (e.g., development, staging, production) and should be configured accordingly. + * Example: 'https://proxy.example.com/v1beta' */ segmentRegulationsEndpoint: string; @@ -122,6 +135,17 @@ export type AnalyticsPrivacyServiceOptions = { policyOptions?: CreateServicePolicyOptions; }; +/** + * Type guard to check if a value is a valid DataDeleteStatus. + * + * @param status - The value to check. + * @returns True if the value is a valid DataDeleteStatus. + */ +function isDataDeleteStatus(status: unknown): status is DataDeleteStatus { + const dataDeleteStatuses: string[] = Object.values(DATA_DELETE_STATUSES); + return dataDeleteStatuses.includes(status as string); +} + /** * This service object is responsible for making requests to the Segment Regulations API * via a proxy endpoint for GDPR/CCPA data deletion functionality. @@ -187,7 +211,8 @@ export class AnalyticsPrivacyService { readonly #segmentSourceId: string; /** - * Base URL for the proxy endpoint. + * Base URL for the proxy endpoint that communicates with Segment's Regulations API. + * This endpoint varies by environment and forwards requests to Segment API with authentication. */ readonly #segmentRegulationsEndpoint: string; @@ -269,66 +294,51 @@ export class AnalyticsPrivacyService { analyticsId: string, ): Promise { if (!this.#segmentSourceId || !this.#segmentRegulationsEndpoint) { - return { - status: DataDeleteResponseStatus.Failure, - error: 'Segment API source ID or endpoint not found', - }; + throw new 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 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, }); - 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; - }); + if (!localResponse.ok) { + throw new HttpError( + localResponse.status, + `Creating data deletion task failed with status '${localResponse.status}'`, + ); + } - const jsonResponse = (await response.json()) as CreateRegulationResponse; + return localResponse; + }); - if ( - jsonResponse?.data?.data?.regulateId && - typeof jsonResponse.data.data.regulateId === 'string' - ) { - return { - status: DataDeleteResponseStatus.Success, - regulateId: jsonResponse.data.data.regulateId, - }; - } + const jsonResponse = (await response.json()) as CreateRegulationResponse; - log( - 'Analytics Deletion Task Error', - new Error('Malformed response from Segment API'), + if ( + !jsonResponse?.data?.data?.regulateId || + typeof jsonResponse.data.data.regulateId !== 'string' || + jsonResponse.data.data.regulateId.trim() === '' + ) { + throw new Error( + 'Malformed response from Segment API: missing or invalid regulateId', ); - return { - status: DataDeleteResponseStatus.Failure, - error: 'Analytics Deletion Task Error', - }; - } catch (error) { - log('Analytics Deletion Task Error', error); - return { - status: DataDeleteResponseStatus.Failure, - error: 'Analytics Deletion Task Error', - }; } + + return { + status: DATA_DELETE_RESPONSE_STATUSES.Success, + regulateId: jsonResponse.data.data.regulateId, + }; } /** @@ -340,55 +350,40 @@ 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.Failure, - dataDeleteStatus: DataDeleteStatus.Unknown, - }; + throw new Error('Regulation ID or endpoint not configured'); } - 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 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, + }, }); - const jsonResponse = - (await response.json()) as GetRegulationStatusResponse; + if (!localResponse.ok) { + throw new HttpError( + localResponse.status, + `Checking data deletion status failed with status '${localResponse.status}'`, + ); + } - const rawStatus = jsonResponse?.data?.data?.regulation?.overallStatus; - const dataDeleteStatus = Object.values(DataDeleteStatus).includes( - rawStatus as DataDeleteStatus, - ) - ? (rawStatus as DataDeleteStatus) - : DataDeleteStatus.Unknown; + return localResponse; + }); - return { - status: DataDeleteResponseStatus.Success, - dataDeleteStatus, - }; - } catch (error) { - log('Analytics Deletion Task Check Error', error); - return { - status: DataDeleteResponseStatus.Failure, - dataDeleteStatus: DataDeleteStatus.Unknown, - }; - } + const jsonResponse = (await response.json()) as GetRegulationStatusResponse; + + const rawStatus = jsonResponse?.data?.data?.regulation?.overallStatus; + const dataDeleteStatus = isDataDeleteStatus(rawStatus) + ? rawStatus + : DATA_DELETE_STATUSES.Unknown; + + return { + status: DATA_DELETE_RESPONSE_STATUSES.Success, + dataDeleteStatus, + }; } } diff --git a/packages/analytics-privacy-controller/src/constants.ts b/packages/analytics-privacy-controller/src/constants.ts deleted file mode 100644 index 1a40262b881..00000000000 --- a/packages/analytics-privacy-controller/src/constants.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** - * Constants used by the analytics privacy controller and service. - */ - -/** - * 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 index 43a07d92ed3..8c28079ecba 100644 --- a/packages/analytics-privacy-controller/src/index.ts +++ b/packages/analytics-privacy-controller/src/index.ts @@ -11,7 +11,12 @@ export type { AnalyticsPrivacyServiceOptions, } from './AnalyticsPrivacyService'; -export { DataDeleteStatus, DataDeleteResponseStatus } from './types'; +export { + DATA_DELETE_STATUSES, + DATA_DELETE_RESPONSE_STATUSES, + type DataDeleteStatus, + type DataDeleteResponseStatus, +} from './types'; export type { IDeleteRegulationResponse, IDeleteRegulationStatus, diff --git a/packages/analytics-privacy-controller/src/AnalyticsPrivacyLogger.ts b/packages/analytics-privacy-controller/src/logger.ts similarity index 100% rename from packages/analytics-privacy-controller/src/AnalyticsPrivacyLogger.ts rename to packages/analytics-privacy-controller/src/logger.ts diff --git a/packages/analytics-privacy-controller/src/selectors.test.ts b/packages/analytics-privacy-controller/src/selectors.test.ts index bdced4a2c71..26a41fdae67 100644 --- a/packages/analytics-privacy-controller/src/selectors.test.ts +++ b/packages/analytics-privacy-controller/src/selectors.test.ts @@ -5,26 +5,30 @@ import { import type { AnalyticsPrivacyControllerState } from './AnalyticsPrivacyController'; describe('analyticsPrivacyControllerSelectors', () => { - describe('selectDataRecorded', () => { - it('returns true when dataRecorded is true in state', () => { + describe('selectHasCollectedDataSinceDeletionRequest', () => { + it('returns true when hasCollectedDataSinceDeletionRequest is true in state', () => { const state: AnalyticsPrivacyControllerState = { ...getDefaultAnalyticsPrivacyControllerState(), - dataRecorded: true, + hasCollectedDataSinceDeletionRequest: true, }; expect( - analyticsPrivacyControllerSelectors.selectDataRecorded(state), + analyticsPrivacyControllerSelectors.selectHasCollectedDataSinceDeletionRequest( + state, + ), ).toBe(true); }); - it('returns false when dataRecorded is false in state', () => { + it('returns false when hasCollectedDataSinceDeletionRequest is false in state', () => { const state: AnalyticsPrivacyControllerState = { ...getDefaultAnalyticsPrivacyControllerState(), - dataRecorded: false, + hasCollectedDataSinceDeletionRequest: false, }; expect( - analyticsPrivacyControllerSelectors.selectDataRecorded(state), + analyticsPrivacyControllerSelectors.selectHasCollectedDataSinceDeletionRequest( + state, + ), ).toBe(false); }); }); diff --git a/packages/analytics-privacy-controller/src/selectors.ts b/packages/analytics-privacy-controller/src/selectors.ts index adfb2e7fdfe..8e33259752c 100644 --- a/packages/analytics-privacy-controller/src/selectors.ts +++ b/packages/analytics-privacy-controller/src/selectors.ts @@ -6,8 +6,9 @@ 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 selectHasCollectedDataSinceDeletionRequest = ( + state: AnalyticsPrivacyControllerState, +): boolean => state.hasCollectedDataSinceDeletionRequest; /** * Selects the delete regulation ID from the controller state. @@ -34,7 +35,7 @@ const selectDeleteRegulationTimestamp = ( * These can be used with Redux or directly with controller state. */ export const analyticsPrivacyControllerSelectors = { - selectDataRecorded, + selectHasCollectedDataSinceDeletionRequest, selectDeleteRegulationId, selectDeleteRegulationTimestamp, }; diff --git a/packages/analytics-privacy-controller/src/types.ts b/packages/analytics-privacy-controller/src/types.ts index 2b3cac75be3..f91346817ce 100644 --- a/packages/analytics-privacy-controller/src/types.ts +++ b/packages/analytics-privacy-controller/src/types.ts @@ -1,25 +1,37 @@ /** * Status values for data deletion requests from Segment API. - * Enum values match Segment API response values exactly. + * Values match Segment API response values exactly. */ -export enum DataDeleteStatus { - Failed = 'FAILED', - Finished = 'FINISHED', - Initialized = 'INITIALIZED', - Invalid = 'INVALID', - NotSupported = 'NOT_SUPPORTED', - PartialSuccess = 'PARTIAL_SUCCESS', - Running = 'RUNNING', - Unknown = 'UNKNOWN', -} +export const DATA_DELETE_STATUSES = { + Failed: 'FAILED', + Finished: 'FINISHED', + Initialized: 'INITIALIZED', + Invalid: 'INVALID', + NotSupported: 'NOT_SUPPORTED', + PartialSuccess: 'PARTIAL_SUCCESS', + Running: 'RUNNING', + Unknown: 'UNKNOWN', +} as const; + +/** + * Type union for data deletion status values. + */ +export type DataDeleteStatus = + (typeof DATA_DELETE_STATUSES)[keyof typeof DATA_DELETE_STATUSES]; /** * Response status for deletion regulation operations. */ -export enum DataDeleteResponseStatus { - Success = 'ok', - Failure = 'error', -} +export const DATA_DELETE_RESPONSE_STATUSES = { + Success: 'ok', + Failure: 'error', +} as const; + +/** + * Type union for data deletion response status values. + */ +export type DataDeleteResponseStatus = + (typeof DATA_DELETE_RESPONSE_STATUSES)[keyof typeof DATA_DELETE_RESPONSE_STATUSES]; /** * Response from creating a data deletion task. @@ -54,5 +66,9 @@ export type DataDeleteTimestamp = number | undefined; /** * Regulation ID from Segment API. + * This type uses `undefined` (rather than `null`) to match the return type of selectors + * and getter methods that convert `null` state values to `undefined` for consistency + * with optional return types. The controller state stores `null`, but external APIs + * return `undefined` for optional values. */ export type DataDeleteRegulationId = string | undefined; From 6dd80c0b3f653b8bc20340bdf577da0b0f65a89b Mon Sep 17 00:00:00 2001 From: Nicolas MASSART Date: Mon, 26 Jan 2026 19:13:48 +0100 Subject: [PATCH 14/16] refactor(analytics-privacy-controller): remove dependency on AnalyticsController and streamline analytics ID handling - Eliminated the dependency on `@metamask/analytics-controller` from the `AnalyticsPrivacyController` and its tests. - Updated the `AnalyticsPrivacyController` to accept an `analyticsId` directly, improving clarity and reducing coupling. - Adjusted tests to reflect the removal of the `AnalyticsController:getState` action, ensuring they now validate the presence of a valid `analyticsId`. - Cleaned up related code to enhance maintainability and readability. --- .../analytics-privacy-controller/package.json | 1 - .../src/AnalyticsPrivacyController.test.ts | 372 +++--------------- .../src/AnalyticsPrivacyController.ts | 34 +- .../src/AnalyticsPrivacyService.test.ts | 4 +- yarn.lock | 3 +- 5 files changed, 72 insertions(+), 342 deletions(-) diff --git a/packages/analytics-privacy-controller/package.json b/packages/analytics-privacy-controller/package.json index bcdbb6149e4..f38814b8e52 100644 --- a/packages/analytics-privacy-controller/package.json +++ b/packages/analytics-privacy-controller/package.json @@ -48,7 +48,6 @@ "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": "^11.18.0", "@metamask/messenger": "^0.3.0", diff --git a/packages/analytics-privacy-controller/src/AnalyticsPrivacyController.test.ts b/packages/analytics-privacy-controller/src/AnalyticsPrivacyController.test.ts index 12569f1d746..669c6a89ee8 100644 --- a/packages/analytics-privacy-controller/src/AnalyticsPrivacyController.test.ts +++ b/packages/analytics-privacy-controller/src/AnalyticsPrivacyController.test.ts @@ -1,4 +1,3 @@ -import type { AnalyticsControllerState } from '@metamask/analytics-controller'; import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; import type { MockAnyNamespace } from '@metamask/messenger'; @@ -17,6 +16,7 @@ import { DATA_DELETE_RESPONSE_STATUSES, DATA_DELETE_STATUSES } from './types'; type SetupControllerOptions = { state?: Partial; + analyticsId?: string; }; type SetupControllerReturn = { @@ -24,12 +24,7 @@ type SetupControllerReturn = { messenger: AnalyticsPrivacyControllerMessenger; rootMessenger: Messenger< MockAnyNamespace, - | AnalyticsPrivacyControllerActions - | AnalyticsPrivacyServiceActions - | { - type: 'AnalyticsController:getState'; - handler: () => { analyticsId: string; optedIn: boolean }; - }, + AnalyticsPrivacyControllerActions | AnalyticsPrivacyServiceActions, AnalyticsPrivacyControllerEvents >; }; @@ -39,32 +34,23 @@ type SetupControllerReturn = { * * @param options - Controller options * @param options.state - Optional partial controller state + * @param options.analyticsId - Optional analytics ID (defaults to 'test-analytics-id') * @returns The controller, messenger, and root messenger */ function setupController( options: SetupControllerOptions = {}, ): SetupControllerReturn { - const { state = {} } = options; + const { state = {}, analyticsId = 'test-analytics-id' } = options; const rootMessenger = new Messenger< MockAnyNamespace, - | AnalyticsPrivacyControllerActions - | AnalyticsPrivacyServiceActions - | { - type: 'AnalyticsController:getState'; - handler: () => { analyticsId: string; optedIn: boolean }; - }, + AnalyticsPrivacyControllerActions | AnalyticsPrivacyServiceActions, AnalyticsPrivacyControllerEvents >({ namespace: MOCK_ANY_NAMESPACE }); const analyticsPrivacyControllerMessenger = new Messenger< 'AnalyticsPrivacyController', - | AnalyticsPrivacyControllerActions - | AnalyticsPrivacyServiceActions - | { - type: 'AnalyticsController:getState'; - handler: () => { analyticsId: string; optedIn: boolean }; - }, + AnalyticsPrivacyControllerActions | AnalyticsPrivacyServiceActions, AnalyticsPrivacyControllerEvents, typeof rootMessenger >({ @@ -72,12 +58,6 @@ function setupController( 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', @@ -95,19 +75,19 @@ function setupController( }), ); - // Delegate service actions and AnalyticsController actions to controller messenger + // Delegate service actions to controller messenger rootMessenger.delegate({ messenger: analyticsPrivacyControllerMessenger, actions: [ 'AnalyticsPrivacyService:createDataDeletionTask', 'AnalyticsPrivacyService:checkDataDeleteStatus', - 'AnalyticsController:getState', ], }); const controller = new AnalyticsPrivacyController({ messenger: analyticsPrivacyControllerMessenger, state, + analyticsId, }); return { @@ -195,23 +175,13 @@ describe('AnalyticsPrivacyController', () => { it('stores deletion timestamp as number when task is created', async () => { const rootMessenger = new Messenger< MockAnyNamespace, - | AnalyticsPrivacyControllerActions - | AnalyticsPrivacyServiceActions - | { - type: 'AnalyticsController:getState'; - handler: () => { analyticsId: string; optedIn: boolean }; - }, + AnalyticsPrivacyControllerActions | AnalyticsPrivacyServiceActions, AnalyticsPrivacyControllerEvents >({ namespace: MOCK_ANY_NAMESPACE }); const analyticsPrivacyControllerMessenger = new Messenger< 'AnalyticsPrivacyController', - | AnalyticsPrivacyControllerActions - | AnalyticsPrivacyServiceActions - | { - type: 'AnalyticsController:getState'; - handler: () => { analyticsId: string; optedIn: boolean }; - }, + AnalyticsPrivacyControllerActions | AnalyticsPrivacyServiceActions, AnalyticsPrivacyControllerEvents, typeof rootMessenger >({ @@ -219,14 +189,6 @@ describe('AnalyticsPrivacyController', () => { parent: rootMessenger, }); - rootMessenger.registerActionHandler( - 'AnalyticsController:getState', - () => ({ - analyticsId: 'test-analytics-id', - optedIn: true, - }), - ); - rootMessenger.registerActionHandler( 'AnalyticsPrivacyService:createDataDeletionTask', jest.fn().mockResolvedValue({ @@ -237,14 +199,12 @@ describe('AnalyticsPrivacyController', () => { rootMessenger.delegate({ messenger: analyticsPrivacyControllerMessenger, - actions: [ - 'AnalyticsPrivacyService:createDataDeletionTask', - 'AnalyticsController:getState', - ], + actions: ['AnalyticsPrivacyService:createDataDeletionTask'], }); const controller = new AnalyticsPrivacyController({ messenger: analyticsPrivacyControllerMessenger, + analyticsId: 'test-analytics-id', }); const fixedTimestamp = new Date('2026-01-15T12:00:00Z').getTime(); @@ -286,23 +246,13 @@ describe('AnalyticsPrivacyController', () => { it('returns error response when analyticsId is empty string', async () => { const rootMessenger = new Messenger< MockAnyNamespace, - | AnalyticsPrivacyControllerActions - | AnalyticsPrivacyServiceActions - | { - type: 'AnalyticsController:getState'; - handler: () => AnalyticsControllerState; - }, + AnalyticsPrivacyControllerActions | AnalyticsPrivacyServiceActions, AnalyticsPrivacyControllerEvents >({ namespace: MOCK_ANY_NAMESPACE }); const analyticsPrivacyControllerMessenger = new Messenger< 'AnalyticsPrivacyController', - | AnalyticsPrivacyControllerActions - | AnalyticsPrivacyServiceActions - | { - type: 'AnalyticsController:getState'; - handler: () => AnalyticsControllerState; - }, + AnalyticsPrivacyControllerActions | AnalyticsPrivacyServiceActions, AnalyticsPrivacyControllerEvents, typeof rootMessenger >({ @@ -310,14 +260,6 @@ describe('AnalyticsPrivacyController', () => { parent: rootMessenger, }); - rootMessenger.registerActionHandler( - 'AnalyticsController:getState', - () => ({ - analyticsId: '', // Empty string to test the !analyticsId check - optedIn: true, - }), - ); - rootMessenger.registerActionHandler( 'AnalyticsPrivacyService:createDataDeletionTask', jest.fn().mockResolvedValue({ @@ -328,16 +270,14 @@ describe('AnalyticsPrivacyController', () => { rootMessenger.delegate({ messenger: analyticsPrivacyControllerMessenger, - actions: [ - 'AnalyticsPrivacyService:createDataDeletionTask', - 'AnalyticsController:getState', - ], + actions: ['AnalyticsPrivacyService:createDataDeletionTask'], }); // Controller is instantiated to register action handlers // eslint-disable-next-line @typescript-eslint/no-unused-vars const _controller = new AnalyticsPrivacyController({ messenger: analyticsPrivacyControllerMessenger, + analyticsId: '', // Empty string to test the !analyticsId check }); const response = await rootMessenger.call( @@ -346,30 +286,20 @@ describe('AnalyticsPrivacyController', () => { expect(response.status).toBe(DATA_DELETE_RESPONSE_STATUSES.Failure); expect(response.error).toBe( - 'Analytics ID not found. You need to set up AnalyticsController with an analytics ID. You can do this by initializing the AnalyticsController with a valid analytics ID before creating a data deletion task.', + 'Analytics ID not found. You need to provide a valid analytics ID when initializing the AnalyticsPrivacyController.', ); }); it('returns error response without updating state when service throws error for missing regulateId', async () => { const rootMessenger = new Messenger< MockAnyNamespace, - | AnalyticsPrivacyControllerActions - | AnalyticsPrivacyServiceActions - | { - type: 'AnalyticsController:getState'; - handler: () => AnalyticsControllerState; - }, + AnalyticsPrivacyControllerActions | AnalyticsPrivacyServiceActions, AnalyticsPrivacyControllerEvents >({ namespace: MOCK_ANY_NAMESPACE }); const analyticsPrivacyControllerMessenger = new Messenger< 'AnalyticsPrivacyController', - | AnalyticsPrivacyControllerActions - | AnalyticsPrivacyServiceActions - | { - type: 'AnalyticsController:getState'; - handler: () => AnalyticsControllerState; - }, + AnalyticsPrivacyControllerActions | AnalyticsPrivacyServiceActions, AnalyticsPrivacyControllerEvents, typeof rootMessenger >({ @@ -377,14 +307,6 @@ describe('AnalyticsPrivacyController', () => { parent: rootMessenger, }); - rootMessenger.registerActionHandler( - 'AnalyticsController:getState', - () => ({ - analyticsId: 'test-analytics-id', - optedIn: true, - }), - ); - rootMessenger.registerActionHandler( 'AnalyticsPrivacyService:createDataDeletionTask', jest @@ -398,16 +320,14 @@ describe('AnalyticsPrivacyController', () => { rootMessenger.delegate({ messenger: analyticsPrivacyControllerMessenger, - actions: [ - 'AnalyticsPrivacyService:createDataDeletionTask', - 'AnalyticsController:getState', - ], + actions: ['AnalyticsPrivacyService:createDataDeletionTask'], }); // Controller is instantiated to register action handlers // eslint-disable-next-line @typescript-eslint/no-unused-vars const _controller = new AnalyticsPrivacyController({ messenger: analyticsPrivacyControllerMessenger, + analyticsId: 'test-analytics-id', }); const response = await rootMessenger.call( @@ -423,23 +343,13 @@ describe('AnalyticsPrivacyController', () => { it('returns error response without updating state when service throws error for empty regulateId', async () => { const rootMessenger = new Messenger< MockAnyNamespace, - | AnalyticsPrivacyControllerActions - | AnalyticsPrivacyServiceActions - | { - type: 'AnalyticsController:getState'; - handler: () => AnalyticsControllerState; - }, + AnalyticsPrivacyControllerActions | AnalyticsPrivacyServiceActions, AnalyticsPrivacyControllerEvents >({ namespace: MOCK_ANY_NAMESPACE }); const analyticsPrivacyControllerMessenger = new Messenger< 'AnalyticsPrivacyController', - | AnalyticsPrivacyControllerActions - | AnalyticsPrivacyServiceActions - | { - type: 'AnalyticsController:getState'; - handler: () => AnalyticsControllerState; - }, + AnalyticsPrivacyControllerActions | AnalyticsPrivacyServiceActions, AnalyticsPrivacyControllerEvents, typeof rootMessenger >({ @@ -447,14 +357,6 @@ describe('AnalyticsPrivacyController', () => { parent: rootMessenger, }); - rootMessenger.registerActionHandler( - 'AnalyticsController:getState', - () => ({ - analyticsId: 'test-analytics-id', - optedIn: true, - }), - ); - rootMessenger.registerActionHandler( 'AnalyticsPrivacyService:createDataDeletionTask', jest @@ -468,14 +370,12 @@ describe('AnalyticsPrivacyController', () => { rootMessenger.delegate({ messenger: analyticsPrivacyControllerMessenger, - actions: [ - 'AnalyticsPrivacyService:createDataDeletionTask', - 'AnalyticsController:getState', - ], + actions: ['AnalyticsPrivacyService:createDataDeletionTask'], }); const controller = new AnalyticsPrivacyController({ messenger: analyticsPrivacyControllerMessenger, + analyticsId: 'test-analytics-id', }); const response = await rootMessenger.call( @@ -493,23 +393,13 @@ describe('AnalyticsPrivacyController', () => { it('returns error response and does not update state when service throws error for undefined regulateId', async () => { const rootMessenger = new Messenger< MockAnyNamespace, - | AnalyticsPrivacyControllerActions - | AnalyticsPrivacyServiceActions - | { - type: 'AnalyticsController:getState'; - handler: () => AnalyticsControllerState; - }, + AnalyticsPrivacyControllerActions | AnalyticsPrivacyServiceActions, AnalyticsPrivacyControllerEvents >({ namespace: MOCK_ANY_NAMESPACE }); const analyticsPrivacyControllerMessenger = new Messenger< 'AnalyticsPrivacyController', - | AnalyticsPrivacyControllerActions - | AnalyticsPrivacyServiceActions - | { - type: 'AnalyticsController:getState'; - handler: () => AnalyticsControllerState; - }, + AnalyticsPrivacyControllerActions | AnalyticsPrivacyServiceActions, AnalyticsPrivacyControllerEvents, typeof rootMessenger >({ @@ -517,14 +407,6 @@ describe('AnalyticsPrivacyController', () => { parent: rootMessenger, }); - rootMessenger.registerActionHandler( - 'AnalyticsController:getState', - () => ({ - analyticsId: 'test-analytics-id', - optedIn: true, - }), - ); - rootMessenger.registerActionHandler( 'AnalyticsPrivacyService:createDataDeletionTask', jest @@ -538,14 +420,12 @@ describe('AnalyticsPrivacyController', () => { rootMessenger.delegate({ messenger: analyticsPrivacyControllerMessenger, - actions: [ - 'AnalyticsPrivacyService:createDataDeletionTask', - 'AnalyticsController:getState', - ], + actions: ['AnalyticsPrivacyService:createDataDeletionTask'], }); const controller = new AnalyticsPrivacyController({ messenger: analyticsPrivacyControllerMessenger, + analyticsId: 'test-analytics-id', }); const response = await rootMessenger.call( @@ -559,90 +439,16 @@ describe('AnalyticsPrivacyController', () => { expect(controller.state.deleteRegulationId).toBeNull(); }); - it('returns error response when AnalyticsController:getState throws 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', - () => { - throw new Error('Analytics ID not found'); - }, - ); - - rootMessenger.registerActionHandler( - 'AnalyticsPrivacyService:createDataDeletionTask', - jest.fn().mockResolvedValue({ - status: DATA_DELETE_RESPONSE_STATUSES.Success, - regulateId: 'test-regulate-id', - }), - ); - - 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(DATA_DELETE_RESPONSE_STATUSES.Failure); - expect(response.error).toBe('Analytics ID not found'); - }); - 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 }; - }, + AnalyticsPrivacyControllerActions | AnalyticsPrivacyServiceActions, AnalyticsPrivacyControllerEvents >({ namespace: MOCK_ANY_NAMESPACE }); const analyticsPrivacyControllerMessenger = new Messenger< 'AnalyticsPrivacyController', - | AnalyticsPrivacyControllerActions - | AnalyticsPrivacyServiceActions - | { - type: 'AnalyticsController:getState'; - handler: () => { analyticsId: string; optedIn: boolean }; - }, + AnalyticsPrivacyControllerActions | AnalyticsPrivacyServiceActions, AnalyticsPrivacyControllerEvents, typeof rootMessenger >({ @@ -650,14 +456,6 @@ describe('AnalyticsPrivacyController', () => { parent: rootMessenger, }); - rootMessenger.registerActionHandler( - 'AnalyticsController:getState', - () => ({ - analyticsId: 'test-analytics-id', - optedIn: true, - }), - ); - rootMessenger.registerActionHandler( 'AnalyticsPrivacyService:createDataDeletionTask', jest.fn().mockRejectedValue('String error'), @@ -665,16 +463,14 @@ describe('AnalyticsPrivacyController', () => { rootMessenger.delegate({ messenger: analyticsPrivacyControllerMessenger, - actions: [ - 'AnalyticsPrivacyService:createDataDeletionTask', - 'AnalyticsController:getState', - ], + actions: ['AnalyticsPrivacyService:createDataDeletionTask'], }); // Controller is instantiated to register action handlers // eslint-disable-next-line @typescript-eslint/no-unused-vars const _controller = new AnalyticsPrivacyController({ messenger: analyticsPrivacyControllerMessenger, + analyticsId: 'test-analytics-id', }); const response = await rootMessenger.call( @@ -688,23 +484,13 @@ describe('AnalyticsPrivacyController', () => { it('returns error response when service throws error', async () => { const rootMessenger = new Messenger< MockAnyNamespace, - | AnalyticsPrivacyControllerActions - | AnalyticsPrivacyServiceActions - | { - type: 'AnalyticsController:getState'; - handler: () => { analyticsId: string; optedIn: boolean }; - }, + AnalyticsPrivacyControllerActions | AnalyticsPrivacyServiceActions, AnalyticsPrivacyControllerEvents >({ namespace: MOCK_ANY_NAMESPACE }); const analyticsPrivacyControllerMessenger = new Messenger< 'AnalyticsPrivacyController', - | AnalyticsPrivacyControllerActions - | AnalyticsPrivacyServiceActions - | { - type: 'AnalyticsController:getState'; - handler: () => { analyticsId: string; optedIn: boolean }; - }, + AnalyticsPrivacyControllerActions | AnalyticsPrivacyServiceActions, AnalyticsPrivacyControllerEvents, typeof rootMessenger >({ @@ -712,14 +498,6 @@ describe('AnalyticsPrivacyController', () => { parent: rootMessenger, }); - rootMessenger.registerActionHandler( - 'AnalyticsController:getState', - () => ({ - analyticsId: 'test-analytics-id', - optedIn: true, - }), - ); - rootMessenger.registerActionHandler( 'AnalyticsPrivacyService:createDataDeletionTask', jest.fn().mockRejectedValue(new Error('Service error')), @@ -727,16 +505,14 @@ describe('AnalyticsPrivacyController', () => { rootMessenger.delegate({ messenger: analyticsPrivacyControllerMessenger, - actions: [ - 'AnalyticsPrivacyService:createDataDeletionTask', - 'AnalyticsController:getState', - ], + actions: ['AnalyticsPrivacyService:createDataDeletionTask'], }); // Controller is instantiated to register action handlers // eslint-disable-next-line @typescript-eslint/no-unused-vars const _controller = new AnalyticsPrivacyController({ messenger: analyticsPrivacyControllerMessenger, + analyticsId: 'test-analytics-id', }); const response = await rootMessenger.call( @@ -750,23 +526,13 @@ describe('AnalyticsPrivacyController', () => { it('preserves initial state when service throws error', async () => { const rootMessenger = new Messenger< MockAnyNamespace, - | AnalyticsPrivacyControllerActions - | AnalyticsPrivacyServiceActions - | { - type: 'AnalyticsController:getState'; - handler: () => { analyticsId: string; optedIn: boolean }; - }, + AnalyticsPrivacyControllerActions | AnalyticsPrivacyServiceActions, AnalyticsPrivacyControllerEvents >({ namespace: MOCK_ANY_NAMESPACE }); const analyticsPrivacyControllerMessenger = new Messenger< 'AnalyticsPrivacyController', - | AnalyticsPrivacyControllerActions - | AnalyticsPrivacyServiceActions - | { - type: 'AnalyticsController:getState'; - handler: () => { analyticsId: string; optedIn: boolean }; - }, + AnalyticsPrivacyControllerActions | AnalyticsPrivacyServiceActions, AnalyticsPrivacyControllerEvents, typeof rootMessenger >({ @@ -774,14 +540,6 @@ describe('AnalyticsPrivacyController', () => { parent: rootMessenger, }); - rootMessenger.registerActionHandler( - 'AnalyticsController:getState', - () => ({ - analyticsId: 'test-analytics-id', - optedIn: true, - }), - ); - rootMessenger.registerActionHandler( 'AnalyticsPrivacyService:createDataDeletionTask', jest.fn().mockRejectedValue(new Error('Service error')), @@ -789,6 +547,7 @@ describe('AnalyticsPrivacyController', () => { const controller = new AnalyticsPrivacyController({ messenger: analyticsPrivacyControllerMessenger, + analyticsId: 'test-analytics-id', }); const initialState = controller.state; @@ -839,23 +598,13 @@ describe('AnalyticsPrivacyController', () => { it('returns undefined timestamp when deleteRegulationTimestamp is null', async () => { const rootMessenger = new Messenger< MockAnyNamespace, - | AnalyticsPrivacyControllerActions - | AnalyticsPrivacyServiceActions - | { - type: 'AnalyticsController:getState'; - handler: () => AnalyticsControllerState; - }, + AnalyticsPrivacyControllerActions | AnalyticsPrivacyServiceActions, AnalyticsPrivacyControllerEvents >({ namespace: MOCK_ANY_NAMESPACE }); const analyticsPrivacyControllerMessenger = new Messenger< 'AnalyticsPrivacyController', - | AnalyticsPrivacyControllerActions - | AnalyticsPrivacyServiceActions - | { - type: 'AnalyticsController:getState'; - handler: () => AnalyticsControllerState; - }, + AnalyticsPrivacyControllerActions | AnalyticsPrivacyServiceActions, AnalyticsPrivacyControllerEvents, typeof rootMessenger >({ @@ -863,14 +612,6 @@ describe('AnalyticsPrivacyController', () => { parent: rootMessenger, }); - rootMessenger.registerActionHandler( - 'AnalyticsController:getState', - () => ({ - analyticsId: 'test-analytics-id', - optedIn: true, - }), - ); - rootMessenger.registerActionHandler( 'AnalyticsPrivacyService:checkDataDeleteStatus', jest.fn().mockResolvedValue({ @@ -881,15 +622,13 @@ describe('AnalyticsPrivacyController', () => { rootMessenger.delegate({ messenger: analyticsPrivacyControllerMessenger, - actions: [ - 'AnalyticsPrivacyService:checkDataDeleteStatus', - 'AnalyticsController:getState', - ], + actions: ['AnalyticsPrivacyService:checkDataDeleteStatus'], }); // eslint-disable-next-line @typescript-eslint/no-unused-vars const _controller = new AnalyticsPrivacyController({ messenger: analyticsPrivacyControllerMessenger, + analyticsId: 'test-analytics-id', state: { deleteRegulationId: 'test-regulation-id', deleteRegulationTimestamp: null, // null timestamp @@ -910,23 +649,13 @@ describe('AnalyticsPrivacyController', () => { it('returns unknown deletion status when service throws Error', async () => { const rootMessenger = new Messenger< MockAnyNamespace, - | AnalyticsPrivacyControllerActions - | AnalyticsPrivacyServiceActions - | { - type: 'AnalyticsController:getState'; - handler: () => { analyticsId: string; optedIn: boolean }; - }, + AnalyticsPrivacyControllerActions | AnalyticsPrivacyServiceActions, AnalyticsPrivacyControllerEvents >({ namespace: MOCK_ANY_NAMESPACE }); const analyticsPrivacyControllerMessenger = new Messenger< 'AnalyticsPrivacyController', - | AnalyticsPrivacyControllerActions - | AnalyticsPrivacyServiceActions - | { - type: 'AnalyticsController:getState'; - handler: () => { analyticsId: string; optedIn: boolean }; - }, + AnalyticsPrivacyControllerActions | AnalyticsPrivacyServiceActions, AnalyticsPrivacyControllerEvents, typeof rootMessenger >({ @@ -934,14 +663,6 @@ describe('AnalyticsPrivacyController', () => { parent: rootMessenger, }); - rootMessenger.registerActionHandler( - 'AnalyticsController:getState', - () => ({ - analyticsId: 'test-analytics-id', - optedIn: true, - }), - ); - rootMessenger.registerActionHandler( 'AnalyticsPrivacyService:checkDataDeleteStatus', jest.fn().mockRejectedValue(new Error('Service error')), @@ -952,6 +673,7 @@ describe('AnalyticsPrivacyController', () => { // eslint-disable-next-line @typescript-eslint/no-unused-vars const _controller = new AnalyticsPrivacyController({ messenger: analyticsPrivacyControllerMessenger, + analyticsId: 'test-analytics-id', state: { deleteRegulationId: 'test-regulation-id', deleteRegulationTimestamp: testTimestamp, diff --git a/packages/analytics-privacy-controller/src/AnalyticsPrivacyController.ts b/packages/analytics-privacy-controller/src/AnalyticsPrivacyController.ts index 8be79ecb613..a0e51d449b7 100644 --- a/packages/analytics-privacy-controller/src/AnalyticsPrivacyController.ts +++ b/packages/analytics-privacy-controller/src/AnalyticsPrivacyController.ts @@ -1,4 +1,3 @@ -import type { AnalyticsControllerGetStateAction } from '@metamask/analytics-controller'; import type { ControllerGetStateAction, ControllerStateChangeEvent, @@ -115,9 +114,7 @@ export type AnalyticsPrivacyControllerActions = /** * Actions from other messengers that {@link AnalyticsPrivacyControllerMessenger} calls. */ -type AllowedActions = - | AnalyticsControllerGetStateAction - | AnalyticsPrivacyServiceActions; +type AllowedActions = AnalyticsPrivacyServiceActions; /** * Event emitted when a data deletion task is created. @@ -181,6 +178,10 @@ export type AnalyticsPrivacyControllerOptions = { * Messenger used to communicate with BaseController and other controllers. */ messenger: AnalyticsPrivacyControllerMessenger; + /** + * Analytics ID used for data deletion requests. + */ + analyticsId: string; }; /** @@ -195,14 +196,24 @@ export class AnalyticsPrivacyController extends BaseController< AnalyticsPrivacyControllerState, AnalyticsPrivacyControllerMessenger > { + /** + * Analytics ID used for data deletion requests. + */ + readonly #analyticsId: string; + /** * 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 + * @param options.analyticsId - Analytics ID used for data deletion requests */ - constructor({ state = {}, messenger }: AnalyticsPrivacyControllerOptions) { + constructor({ + state = {}, + messenger, + analyticsId, + }: AnalyticsPrivacyControllerOptions) { const initialState: AnalyticsPrivacyControllerState = { ...getDefaultAnalyticsPrivacyControllerState(), ...state, @@ -215,6 +226,8 @@ export class AnalyticsPrivacyController extends BaseController< messenger, }); + this.#analyticsId = analyticsId; + this.messenger.registerMethodActionHandlers( this, MESSENGER_EXPOSED_METHODS, @@ -236,14 +249,9 @@ export class AnalyticsPrivacyController extends BaseController< */ async createDataDeletionTask(): Promise { try { - const analyticsControllerState = this.messenger.call( - 'AnalyticsController:getState', - ); - const { analyticsId } = analyticsControllerState; - - if (!analyticsId || analyticsId.trim() === '') { + if (!this.#analyticsId || this.#analyticsId.trim() === '') { const error = new Error( - 'Analytics ID not found. You need to set up AnalyticsController with an analytics ID. You can do this by initializing the AnalyticsController with a valid analytics ID before creating a data deletion task.', + 'Analytics ID not found. You need to provide a valid analytics ID when initializing the AnalyticsPrivacyController.', ); log('Analytics Deletion Task Error', error); return { @@ -254,7 +262,7 @@ export class AnalyticsPrivacyController extends BaseController< const response = await this.messenger.call( 'AnalyticsPrivacyService:createDataDeletionTask', - analyticsId, + this.#analyticsId, ); const deletionTimestamp = Date.now(); diff --git a/packages/analytics-privacy-controller/src/AnalyticsPrivacyService.test.ts b/packages/analytics-privacy-controller/src/AnalyticsPrivacyService.test.ts index fb07333c884..c60a90ab96c 100644 --- a/packages/analytics-privacy-controller/src/AnalyticsPrivacyService.test.ts +++ b/packages/analytics-privacy-controller/src/AnalyticsPrivacyService.test.ts @@ -402,7 +402,9 @@ describe('AnalyticsPrivacyService', () => { 'AnalyticsPrivacyService:createDataDeletionTask', 'test-analytics-id', ), - ).rejects.toThrow('Execution prevented because the circuit breaker is open'); + ).rejects.toThrow( + 'Execution prevented because the circuit breaker is open', + ); expect(onBreakListener).toHaveBeenCalled(); }); diff --git a/yarn.lock b/yarn.lock index 80eda654051..25a3d875682 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2554,7 +2554,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/analytics-controller@npm:^1.0.0, @metamask/analytics-controller@workspace:packages/analytics-controller": +"@metamask/analytics-controller@workspace:packages/analytics-controller": version: 0.0.0-use.local resolution: "@metamask/analytics-controller@workspace:packages/analytics-controller" dependencies: @@ -2577,7 +2577,6 @@ __metadata: 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:^11.18.0" From d9d94eecbeb75b4bb36cbe3a5aa0a35332e2cef5 Mon Sep 17 00:00:00 2001 From: Nicolas MASSART Date: Mon, 26 Jan 2026 21:14:56 +0100 Subject: [PATCH 15/16] fix(analytics-privacy-controller): enhance error handling in createDataDeletionTask - Updated the `createDataDeletionTask` method to throw errors instead of returning error responses when the analytics ID is missing or when the service call fails. - Improved test cases to reflect the new error handling behavior, ensuring that errors are thrown and not just returned as part of the response. - Added documentation to indicate that the method may throw errors under specific conditions. --- ...csPrivacyController-method-action-types.ts | 1 + .../src/AnalyticsPrivacyController.test.ts | 77 ++++++++----------- .../src/AnalyticsPrivacyController.ts | 70 +++++++---------- 3 files changed, 61 insertions(+), 87 deletions(-) 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 496e08f7de9..696ee44353b 100644 --- a/packages/analytics-privacy-controller/src/AnalyticsPrivacyController-method-action-types.ts +++ b/packages/analytics-privacy-controller/src/AnalyticsPrivacyController-method-action-types.ts @@ -10,6 +10,7 @@ import type { AnalyticsPrivacyController } from './AnalyticsPrivacyController'; * This is necessary to respect the GDPR and CCPA regulations. * * @returns Promise containing the status of the request + * @throws Error if analytics ID is missing or if the service call fails */ export type AnalyticsPrivacyControllerCreateDataDeletionTaskAction = { type: `AnalyticsPrivacyController:createDataDeletionTask`; diff --git a/packages/analytics-privacy-controller/src/AnalyticsPrivacyController.test.ts b/packages/analytics-privacy-controller/src/AnalyticsPrivacyController.test.ts index 669c6a89ee8..316c174b9cb 100644 --- a/packages/analytics-privacy-controller/src/AnalyticsPrivacyController.test.ts +++ b/packages/analytics-privacy-controller/src/AnalyticsPrivacyController.test.ts @@ -243,7 +243,7 @@ describe('AnalyticsPrivacyController', () => { }); }); - it('returns error response when analyticsId is empty string', async () => { + it('throws error when analyticsId is empty string', async () => { const rootMessenger = new Messenger< MockAnyNamespace, AnalyticsPrivacyControllerActions | AnalyticsPrivacyServiceActions, @@ -280,17 +280,14 @@ describe('AnalyticsPrivacyController', () => { analyticsId: '', // Empty string to test the !analyticsId check }); - const response = await rootMessenger.call( - 'AnalyticsPrivacyController:createDataDeletionTask', - ); - - expect(response.status).toBe(DATA_DELETE_RESPONSE_STATUSES.Failure); - expect(response.error).toBe( + await expect( + rootMessenger.call('AnalyticsPrivacyController:createDataDeletionTask'), + ).rejects.toThrow( 'Analytics ID not found. You need to provide a valid analytics ID when initializing the AnalyticsPrivacyController.', ); }); - it('returns error response without updating state when service throws error for missing regulateId', async () => { + it('throws error without updating state when service throws error for missing regulateId', async () => { const rootMessenger = new Messenger< MockAnyNamespace, AnalyticsPrivacyControllerActions | AnalyticsPrivacyServiceActions, @@ -330,17 +327,14 @@ describe('AnalyticsPrivacyController', () => { analyticsId: 'test-analytics-id', }); - const response = await rootMessenger.call( - 'AnalyticsPrivacyController:createDataDeletionTask', - ); - - expect(response.status).toBe(DATA_DELETE_RESPONSE_STATUSES.Failure); - expect(response.error).toBe( + await expect( + rootMessenger.call('AnalyticsPrivacyController:createDataDeletionTask'), + ).rejects.toThrow( 'Malformed response from Segment API: missing or invalid regulateId', ); }); - it('returns error response without updating state when service throws error for empty regulateId', async () => { + it('throws error without updating state when service throws error for empty regulateId', async () => { const rootMessenger = new Messenger< MockAnyNamespace, AnalyticsPrivacyControllerActions | AnalyticsPrivacyServiceActions, @@ -378,19 +372,16 @@ describe('AnalyticsPrivacyController', () => { analyticsId: 'test-analytics-id', }); - const response = await rootMessenger.call( - 'AnalyticsPrivacyController:createDataDeletionTask', - ); - - expect(response.status).toBe(DATA_DELETE_RESPONSE_STATUSES.Failure); - expect(response.error).toBe( + await expect( + rootMessenger.call('AnalyticsPrivacyController:createDataDeletionTask'), + ).rejects.toThrow( 'Malformed response from Segment API: missing or invalid regulateId', ); // State should not be updated when service throws error expect(controller.state.deleteRegulationId).toBeNull(); }); - it('returns error response and does not update state when service throws error for undefined regulateId', async () => { + it('throws error and does not update state when service throws error for undefined regulateId', async () => { const rootMessenger = new Messenger< MockAnyNamespace, AnalyticsPrivacyControllerActions | AnalyticsPrivacyServiceActions, @@ -428,18 +419,15 @@ describe('AnalyticsPrivacyController', () => { analyticsId: 'test-analytics-id', }); - const response = await rootMessenger.call( - 'AnalyticsPrivacyController:createDataDeletionTask', - ); - - expect(response.status).toBe(DATA_DELETE_RESPONSE_STATUSES.Failure); - expect(response.error).toBe( + await expect( + rootMessenger.call('AnalyticsPrivacyController:createDataDeletionTask'), + ).rejects.toThrow( 'Malformed response from Segment API: missing or invalid regulateId', ); expect(controller.state.deleteRegulationId).toBeNull(); }); - it('returns error response with default message when service throws non-Error value', async () => { + it('throws error when service throws non-Error value', async () => { const rootMessenger = new Messenger< MockAnyNamespace, AnalyticsPrivacyControllerActions | AnalyticsPrivacyServiceActions, @@ -473,15 +461,12 @@ describe('AnalyticsPrivacyController', () => { analyticsId: 'test-analytics-id', }); - const response = await rootMessenger.call( - 'AnalyticsPrivacyController:createDataDeletionTask', - ); - - expect(response.status).toBe(DATA_DELETE_RESPONSE_STATUSES.Failure); - expect(response.error).toBe('Analytics Deletion Task Error'); + await expect( + rootMessenger.call('AnalyticsPrivacyController:createDataDeletionTask'), + ).rejects.toBe('String error'); }); - it('returns error response when service throws error', async () => { + it('throws error when service throws error', async () => { const rootMessenger = new Messenger< MockAnyNamespace, AnalyticsPrivacyControllerActions | AnalyticsPrivacyServiceActions, @@ -515,12 +500,9 @@ describe('AnalyticsPrivacyController', () => { analyticsId: 'test-analytics-id', }); - const response = await rootMessenger.call( - 'AnalyticsPrivacyController:createDataDeletionTask', - ); - - expect(response.status).toBe(DATA_DELETE_RESPONSE_STATUSES.Failure); - expect(response.error).toBe('Service error'); + await expect( + rootMessenger.call('AnalyticsPrivacyController:createDataDeletionTask'), + ).rejects.toThrow('Service error'); }); it('preserves initial state when service throws error', async () => { @@ -545,15 +527,20 @@ describe('AnalyticsPrivacyController', () => { jest.fn().mockRejectedValue(new Error('Service error')), ); + rootMessenger.delegate({ + messenger: analyticsPrivacyControllerMessenger, + actions: ['AnalyticsPrivacyService:createDataDeletionTask'], + }); + const controller = new AnalyticsPrivacyController({ messenger: analyticsPrivacyControllerMessenger, analyticsId: 'test-analytics-id', }); const initialState = controller.state; - await rootMessenger.call( - 'AnalyticsPrivacyController:createDataDeletionTask', - ); + await expect( + rootMessenger.call('AnalyticsPrivacyController:createDataDeletionTask'), + ).rejects.toThrow('Service error'); expect(controller.state).toStrictEqual(initialState); }); diff --git a/packages/analytics-privacy-controller/src/AnalyticsPrivacyController.ts b/packages/analytics-privacy-controller/src/AnalyticsPrivacyController.ts index a0e51d449b7..6ca6bce0776 100644 --- a/packages/analytics-privacy-controller/src/AnalyticsPrivacyController.ts +++ b/packages/analytics-privacy-controller/src/AnalyticsPrivacyController.ts @@ -9,7 +9,7 @@ import type { Messenger } from '@metamask/messenger'; import type { AnalyticsPrivacyControllerMethodActions } from './AnalyticsPrivacyController-method-action-types'; import type { AnalyticsPrivacyServiceActions } from './AnalyticsPrivacyService'; import { projectLogger as log } from './logger'; -import { DATA_DELETE_RESPONSE_STATUSES, DATA_DELETE_STATUSES } from './types'; +import { DATA_DELETE_STATUSES } from './types'; import type { IDeleteRegulationResponse, IDeleteRegulationStatus, @@ -246,54 +246,40 @@ export class AnalyticsPrivacyController extends BaseController< * This is necessary to respect the GDPR and CCPA regulations. * * @returns Promise containing the status of the request + * @throws Error if analytics ID is missing or if the service call fails */ async createDataDeletionTask(): Promise { - try { - if (!this.#analyticsId || this.#analyticsId.trim() === '') { - const error = new Error( - 'Analytics ID not found. You need to provide a valid analytics ID when initializing the AnalyticsPrivacyController.', - ); - log('Analytics Deletion Task Error', error); - return { - status: DATA_DELETE_RESPONSE_STATUSES.Failure, - error: error.message, - }; - } - - const response = await this.messenger.call( - 'AnalyticsPrivacyService:createDataDeletionTask', - this.#analyticsId, + if (!this.#analyticsId || this.#analyticsId.trim() === '') { + const error = new Error( + 'Analytics ID not found. You need to provide a valid analytics ID when initializing the AnalyticsPrivacyController.', ); + log('Analytics Deletion Task Error', error); + throw error; + } - const deletionTimestamp = Date.now(); - // Service validates and throws if regulateId is missing, so it's always defined here - const { regulateId } = response; - // Type assertion is safe because service throws if regulateId is missing - const validRegulateId: string = regulateId as string; + const response = await this.messenger.call( + 'AnalyticsPrivacyService:createDataDeletionTask', + this.#analyticsId, + ); - this.update((state) => { - state.deleteRegulationId = validRegulateId; - state.deleteRegulationTimestamp = deletionTimestamp; - state.hasCollectedDataSinceDeletionRequest = false; - }); + const deletionTimestamp = Date.now(); + // Service validates and throws if regulateId is missing, so it's always defined here + const { regulateId } = response; + // Type assertion is safe because service throws if regulateId is missing + const validRegulateId: string = regulateId as string; - this.messenger.publish( - `${controllerName}:dataDeletionTaskCreated`, - response, - ); + this.update((state) => { + state.deleteRegulationId = validRegulateId; + state.deleteRegulationTimestamp = deletionTimestamp; + state.hasCollectedDataSinceDeletionRequest = false; + }); - return response; - } catch (error) { - log('Analytics Deletion Task Error', error); - const errorMessage = - error instanceof Error - ? error.message - : 'Analytics Deletion Task Error'; - return { - status: DATA_DELETE_RESPONSE_STATUSES.Failure, - error: errorMessage, - }; - } + this.messenger.publish( + `${controllerName}:dataDeletionTaskCreated`, + response, + ); + + return response; } /** From 541f90c4a20b8e4beee7b633c951adbc300aea63 Mon Sep 17 00:00:00 2001 From: Nicolas MASSART Date: Tue, 27 Jan 2026 12:53:51 +0100 Subject: [PATCH 16/16] refactor(analytics-privacy-controller): improve error handling and remove redundant code - Change error handling to throw errors instead of returning error objects - Remove redundant getter methods (use selectors instead) - Change state properties from null to optional (undefined) - Update service return types to only return success responses - Remove redundant validation in controller (trust service responses) - Update tests to match new error handling pattern" error: 1Password: Could not connect to socket. Is the agent running? --- ...csPrivacyController-method-action-types.ts | 36 +----- .../src/AnalyticsPrivacyController.test.ts | 118 +++--------------- .../src/AnalyticsPrivacyController.ts | 78 ++++-------- ...yticsPrivacyService-method-action-types.ts | 6 +- .../src/AnalyticsPrivacyService.ts | 24 ++-- .../src/selectors.test.ts | 6 +- .../src/selectors.ts | 4 +- .../analytics-privacy-controller/src/types.ts | 16 ++- 8 files changed, 71 insertions(+), 217 deletions(-) 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 696ee44353b..a6496fbb006 100644 --- a/packages/analytics-privacy-controller/src/AnalyticsPrivacyController-method-action-types.ts +++ b/packages/analytics-privacy-controller/src/AnalyticsPrivacyController-method-action-types.ts @@ -9,7 +9,7 @@ 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 + * @returns Promise containing the status of the request with regulateId * @throws Error if analytics ID is missing or if the service call fails */ export type AnalyticsPrivacyControllerCreateDataDeletionTaskAction = { @@ -27,37 +27,6 @@ export type AnalyticsPrivacyControllerCheckDataDeleteStatusAction = { handler: AnalyticsPrivacyController['checkDataDeleteStatus']; }; -/** - * Get the latest delete regulation request timestamp. - * - * @returns The timestamp (in milliseconds since epoch), or undefined - */ -export type AnalyticsPrivacyControllerGetDeleteRegulationCreationTimestampAction = - { - type: `AnalyticsPrivacyController:getDeleteRegulationCreationTimestamp`; - handler: AnalyticsPrivacyController['getDeleteRegulationCreationTimestamp']; - }; - -/** - * 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 @@ -85,7 +54,4 @@ export type AnalyticsPrivacyControllerUpdateDataRecordingFlagAction = { export type AnalyticsPrivacyControllerMethodActions = | AnalyticsPrivacyControllerCreateDataDeletionTaskAction | AnalyticsPrivacyControllerCheckDataDeleteStatusAction - | 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 316c174b9cb..3e75cc1e529 100644 --- a/packages/analytics-privacy-controller/src/AnalyticsPrivacyController.test.ts +++ b/packages/analytics-privacy-controller/src/AnalyticsPrivacyController.test.ts @@ -99,14 +99,14 @@ function setupController( describe('AnalyticsPrivacyController', () => { describe('getDefaultAnalyticsPrivacyControllerState', () => { - it('returns default state with hasCollectedDataSinceDeletionRequest false and null regulation fields', () => { + it('returns default state with hasCollectedDataSinceDeletionRequest false and undefined regulation fields', () => { const defaults = getDefaultAnalyticsPrivacyControllerState(); expect(defaults).toStrictEqual({ hasCollectedDataSinceDeletionRequest: false, - deleteRegulationId: null, - deleteRegulationTimestamp: null, }); + expect(defaults.deleteRegulationId).toBeUndefined(); + expect(defaults.deleteRegulationTimestamp).toBeUndefined(); }); it('returns identical values on each call', () => { @@ -146,8 +146,8 @@ describe('AnalyticsPrivacyController', () => { const { controller } = setupController({ state: partialState }); expect(controller.state.hasCollectedDataSinceDeletionRequest).toBe(true); - expect(controller.state.deleteRegulationId).toBeNull(); - expect(controller.state.deleteRegulationTimestamp).toBeNull(); + expect(controller.state.deleteRegulationId).toBeUndefined(); + expect(controller.state.deleteRegulationTimestamp).toBeUndefined(); }); }); @@ -378,7 +378,7 @@ describe('AnalyticsPrivacyController', () => { 'Malformed response from Segment API: missing or invalid regulateId', ); // State should not be updated when service throws error - expect(controller.state.deleteRegulationId).toBeNull(); + expect(controller.state.deleteRegulationId).toBeUndefined(); }); it('throws error and does not update state when service throws error for undefined regulateId', async () => { @@ -424,7 +424,7 @@ describe('AnalyticsPrivacyController', () => { ).rejects.toThrow( 'Malformed response from Segment API: missing or invalid regulateId', ); - expect(controller.state.deleteRegulationId).toBeNull(); + expect(controller.state.deleteRegulationId).toBeUndefined(); }); it('throws error when service throws non-Error value', async () => { @@ -582,7 +582,7 @@ describe('AnalyticsPrivacyController', () => { }); }); - it('returns undefined timestamp when deleteRegulationTimestamp is null', async () => { + it('returns undefined timestamp when deleteRegulationTimestamp is not set', async () => { const rootMessenger = new Messenger< MockAnyNamespace, AnalyticsPrivacyControllerActions | AnalyticsPrivacyServiceActions, @@ -618,7 +618,6 @@ describe('AnalyticsPrivacyController', () => { analyticsId: 'test-analytics-id', state: { deleteRegulationId: 'test-regulation-id', - deleteRegulationTimestamp: null, // null timestamp hasCollectedDataSinceDeletionRequest: false, }, }); @@ -633,7 +632,7 @@ describe('AnalyticsPrivacyController', () => { ); }); - it('returns unknown deletion status when service throws Error', async () => { + it('throws error when service throws Error', async () => { const rootMessenger = new Messenger< MockAnyNamespace, AnalyticsPrivacyControllerActions | AnalyticsPrivacyServiceActions, @@ -655,7 +654,11 @@ describe('AnalyticsPrivacyController', () => { jest.fn().mockRejectedValue(new Error('Service error')), ); - const testTimestamp = new Date('2026-01-15T12:00:00Z').getTime(); + rootMessenger.delegate({ + messenger: analyticsPrivacyControllerMessenger, + actions: ['AnalyticsPrivacyService:checkDataDeleteStatus'], + }); + // Controller is instantiated to register action handlers // eslint-disable-next-line @typescript-eslint/no-unused-vars const _controller = new AnalyticsPrivacyController({ @@ -663,99 +666,14 @@ describe('AnalyticsPrivacyController', () => { analyticsId: 'test-analytics-id', state: { deleteRegulationId: 'test-regulation-id', - deleteRegulationTimestamp: testTimestamp, + deleteRegulationTimestamp: new Date('2026-01-15T12:00:00Z').getTime(), hasCollectedDataSinceDeletionRequest: false, }, }); - const status = await rootMessenger.call( - 'AnalyticsPrivacyController:checkDataDeleteStatus', - ); - - expect(status.dataDeletionRequestStatus).toBe( - DATA_DELETE_STATUSES.Unknown, - ); - expect(status.deletionRequestTimestamp).toBe(testTimestamp); - expect(status.hasCollectedDataSinceDeletionRequest).toBe(false); - }); - }); - - describe('AnalyticsPrivacyController:getDeleteRegulationCreationTimestamp', () => { - it('returns the deletion timestamp when set', () => { - const testTimestamp = new Date('2026-01-15T12:00:00Z').getTime(); - const { rootMessenger } = setupController({ - state: { - deleteRegulationTimestamp: testTimestamp, - }, - }); - - const timestamp = rootMessenger.call( - 'AnalyticsPrivacyController:getDeleteRegulationCreationTimestamp', - ); - - expect(timestamp).toBe(testTimestamp); - }); - - it('returns undefined when deletion timestamp is not set', () => { - const { rootMessenger } = setupController(); - - const timestamp = rootMessenger.call( - 'AnalyticsPrivacyController:getDeleteRegulationCreationTimestamp', - ); - - expect(timestamp).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: { - hasCollectedDataSinceDeletionRequest: 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); + await expect( + rootMessenger.call('AnalyticsPrivacyController:checkDataDeleteStatus'), + ).rejects.toThrow('Service error'); }); }); diff --git a/packages/analytics-privacy-controller/src/AnalyticsPrivacyController.ts b/packages/analytics-privacy-controller/src/AnalyticsPrivacyController.ts index 6ca6bce0776..86ebdb52726 100644 --- a/packages/analytics-privacy-controller/src/AnalyticsPrivacyController.ts +++ b/packages/analytics-privacy-controller/src/AnalyticsPrivacyController.ts @@ -9,7 +9,7 @@ import type { Messenger } from '@metamask/messenger'; import type { AnalyticsPrivacyControllerMethodActions } from './AnalyticsPrivacyController-method-action-types'; import type { AnalyticsPrivacyServiceActions } from './AnalyticsPrivacyService'; import { projectLogger as log } from './logger'; -import { DATA_DELETE_STATUSES } from './types'; +import { DATA_DELETE_RESPONSE_STATUSES, DATA_DELETE_STATUSES } from './types'; import type { IDeleteRegulationResponse, IDeleteRegulationStatus, @@ -39,13 +39,13 @@ export type AnalyticsPrivacyControllerState = { * 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; + deleteRegulationId?: string; /** * Segment's data deletion regulation creation timestamp. * The timestamp (in milliseconds since epoch) when the deletion request was created. */ - deleteRegulationTimestamp: number | null; + deleteRegulationTimestamp?: number; }; /** @@ -56,8 +56,6 @@ export type AnalyticsPrivacyControllerState = { export function getDefaultAnalyticsPrivacyControllerState(): AnalyticsPrivacyControllerState { return { hasCollectedDataSinceDeletionRequest: false, - deleteRegulationId: null, - deleteRegulationTimestamp: null, }; } @@ -90,9 +88,6 @@ const analyticsPrivacyControllerMetadata = { const MESSENGER_EXPOSED_METHODS = [ 'createDataDeletionTask', 'checkDataDeleteStatus', - 'getDeleteRegulationCreationTimestamp', - 'getDeleteRegulationId', - 'isDataRecorded', 'updateDataRecordingFlag', ] as const; @@ -245,10 +240,13 @@ export class AnalyticsPrivacyController extends BaseController< * 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 + * @returns Promise containing the status of the request with regulateId * @throws Error if analytics ID is missing or if the service call fails */ - async createDataDeletionTask(): Promise { + async createDataDeletionTask(): Promise<{ + status: typeof DATA_DELETE_RESPONSE_STATUSES.Success; + regulateId: string; + }> { if (!this.#analyticsId || this.#analyticsId.trim() === '') { const error = new Error( 'Analytics ID not found. You need to provide a valid analytics ID when initializing the AnalyticsPrivacyController.', @@ -263,13 +261,10 @@ export class AnalyticsPrivacyController extends BaseController< ); const deletionTimestamp = Date.now(); - // Service validates and throws if regulateId is missing, so it's always defined here - const { regulateId } = response; - // Type assertion is safe because service throws if regulateId is missing - const validRegulateId: string = regulateId as string; - + // Service validates and throws on all errors, so if we reach here, the response + // is guaranteed to be a success response with regulateId present this.update((state) => { - state.deleteRegulationId = validRegulateId; + state.deleteRegulationId = response.regulateId; state.deleteRegulationTimestamp = deletionTimestamp; state.hasCollectedDataSinceDeletionRequest = false; }); @@ -295,57 +290,26 @@ export class AnalyticsPrivacyController extends BaseController< }; if (!this.state.deleteRegulationId) { + status.hasCollectedDataSinceDeletionRequest = + this.state.hasCollectedDataSinceDeletionRequest; 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 = DATA_DELETE_STATUSES.Unknown; - } + // Service validates and throws on all errors, so if we reach here, the response + // is guaranteed to be a success response with dataDeleteStatus present + const dataDeletionTaskStatus = await this.messenger.call( + 'AnalyticsPrivacyService:checkDataDeleteStatus', + this.state.deleteRegulationId, + ); - status.deletionRequestTimestamp = - this.state.deleteRegulationTimestamp ?? undefined; + status.dataDeletionRequestStatus = dataDeletionTaskStatus.dataDeleteStatus; + status.deletionRequestTimestamp = this.state.deleteRegulationTimestamp; status.hasCollectedDataSinceDeletionRequest = this.state.hasCollectedDataSinceDeletionRequest; return status; } - /** - * Get the latest delete regulation request timestamp. - * - * @returns The timestamp (in milliseconds since epoch), or undefined - */ - getDeleteRegulationCreationTimestamp(): number | undefined { - return this.state.deleteRegulationTimestamp ?? 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.hasCollectedDataSinceDeletionRequest; - } - /** * Update the data recording flag if needed. * This method should be called after tracking events to ensure diff --git a/packages/analytics-privacy-controller/src/AnalyticsPrivacyService-method-action-types.ts b/packages/analytics-privacy-controller/src/AnalyticsPrivacyService-method-action-types.ts index edade11fb0a..904de344ea1 100644 --- a/packages/analytics-privacy-controller/src/AnalyticsPrivacyService-method-action-types.ts +++ b/packages/analytics-privacy-controller/src/AnalyticsPrivacyService-method-action-types.ts @@ -9,7 +9,8 @@ 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. + * @returns Promise resolving to a successful deletion regulation response. + * @throws Error if the request fails or the response is invalid. */ export type AnalyticsPrivacyServiceCreateDataDeletionTaskAction = { type: `AnalyticsPrivacyService:createDataDeletionTask`; @@ -20,7 +21,8 @@ export type AnalyticsPrivacyServiceCreateDataDeletionTaskAction = { * Checks the status of a regulation by ID. * * @param regulationId - The regulation ID to check. - * @returns Promise resolving to the regulation status response. + * @returns Promise resolving to a successful regulation status response. + * @throws Error if the request fails or the response is invalid. */ export type AnalyticsPrivacyServiceCheckDataDeleteStatusAction = { type: `AnalyticsPrivacyService:checkDataDeleteStatus`; diff --git a/packages/analytics-privacy-controller/src/AnalyticsPrivacyService.ts b/packages/analytics-privacy-controller/src/AnalyticsPrivacyService.ts index 91b3aa5a606..89aa53c346d 100644 --- a/packages/analytics-privacy-controller/src/AnalyticsPrivacyService.ts +++ b/packages/analytics-privacy-controller/src/AnalyticsPrivacyService.ts @@ -9,10 +9,6 @@ import type { IDisposable } from 'cockatiel'; import type { AnalyticsPrivacyServiceMethodActions } from './AnalyticsPrivacyService-method-action-types'; import { DATA_DELETE_RESPONSE_STATUSES, DATA_DELETE_STATUSES } from './types'; import type { DataDeleteStatus } from './types'; -import type { - IDeleteRegulationResponse, - IDeleteRegulationStatusResponse, -} from './types'; /** * Segment API regulation type for DELETE_ONLY operations. @@ -288,11 +284,13 @@ export class 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. + * @returns Promise resolving to a successful deletion regulation response. + * @throws Error if the request fails or the response is invalid. */ - async createDataDeletionTask( - analyticsId: string, - ): Promise { + async createDataDeletionTask(analyticsId: string): Promise<{ + status: typeof DATA_DELETE_RESPONSE_STATUSES.Success; + regulateId: string; + }> { if (!this.#segmentSourceId || !this.#segmentRegulationsEndpoint) { throw new Error('Segment API source ID or endpoint not found'); } @@ -345,11 +343,13 @@ export class AnalyticsPrivacyService { * Checks the status of a regulation by ID. * * @param regulationId - The regulation ID to check. - * @returns Promise resolving to the regulation status response. + * @returns Promise resolving to a successful regulation status response. + * @throws Error if the request fails or the response is invalid. */ - async checkDataDeleteStatus( - regulationId: string, - ): Promise { + async checkDataDeleteStatus(regulationId: string): Promise<{ + status: typeof DATA_DELETE_RESPONSE_STATUSES.Success; + dataDeleteStatus: DataDeleteStatus; + }> { if (!regulationId || !this.#segmentRegulationsEndpoint) { throw new Error('Regulation ID or endpoint not configured'); } diff --git a/packages/analytics-privacy-controller/src/selectors.test.ts b/packages/analytics-privacy-controller/src/selectors.test.ts index 26a41fdae67..8191c60f738 100644 --- a/packages/analytics-privacy-controller/src/selectors.test.ts +++ b/packages/analytics-privacy-controller/src/selectors.test.ts @@ -45,10 +45,9 @@ describe('analyticsPrivacyControllerSelectors', () => { ).toBe('test-regulation-id'); }); - it('returns undefined when deleteRegulationId is null in state', () => { + it('returns undefined when deleteRegulationId is not set in state', () => { const state: AnalyticsPrivacyControllerState = { ...getDefaultAnalyticsPrivacyControllerState(), - deleteRegulationId: null, }; expect( @@ -72,10 +71,9 @@ describe('analyticsPrivacyControllerSelectors', () => { ).toBe(testTimestamp); }); - it('returns undefined when deleteRegulationTimestamp is null in state', () => { + it('returns undefined when deleteRegulationTimestamp is not set in state', () => { const state: AnalyticsPrivacyControllerState = { ...getDefaultAnalyticsPrivacyControllerState(), - deleteRegulationTimestamp: null, }; expect( diff --git a/packages/analytics-privacy-controller/src/selectors.ts b/packages/analytics-privacy-controller/src/selectors.ts index 8e33259752c..99fb7f9e65c 100644 --- a/packages/analytics-privacy-controller/src/selectors.ts +++ b/packages/analytics-privacy-controller/src/selectors.ts @@ -18,7 +18,7 @@ const selectHasCollectedDataSinceDeletionRequest = ( */ const selectDeleteRegulationId = ( state: AnalyticsPrivacyControllerState, -): string | undefined => state.deleteRegulationId ?? undefined; +): string | undefined => state.deleteRegulationId; /** * Selects the delete regulation creation timestamp from the controller state. @@ -28,7 +28,7 @@ const selectDeleteRegulationId = ( */ const selectDeleteRegulationTimestamp = ( state: AnalyticsPrivacyControllerState, -): number | undefined => state.deleteRegulationTimestamp ?? undefined; +): number | undefined => state.deleteRegulationTimestamp; /** * Selectors for the AnalyticsPrivacyController state. diff --git a/packages/analytics-privacy-controller/src/types.ts b/packages/analytics-privacy-controller/src/types.ts index f91346817ce..f3ea83dfe3c 100644 --- a/packages/analytics-privacy-controller/src/types.ts +++ b/packages/analytics-privacy-controller/src/types.ts @@ -35,12 +35,18 @@ export type DataDeleteResponseStatus = /** * Response from creating a data deletion task. + * When status is Success, regulateId is guaranteed to be present. + * When status is Failure, error is guaranteed to be present. */ -export type IDeleteRegulationResponse = { - status: DataDeleteResponseStatus; - regulateId?: string; // Using exact API field name from Segment API response - error?: string; -}; +export type IDeleteRegulationResponse = + | { + status: typeof DATA_DELETE_RESPONSE_STATUSES.Success; + regulateId: string; // Using exact API field name from Segment API response + } + | { + status: typeof DATA_DELETE_RESPONSE_STATUSES.Failure; + error: string; + }; /** * Status information for a data deletion request.