// Copyright 2025 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import assert from 'node:assert'; import { Proto, StorageState } from '@signalapp/mock-server'; import { expect } from 'playwright/test'; import * as Bytes from '../../Bytes.std.js'; import * as durations from '../../util/durations/index.std.js'; import { dropNull } from '../../util/dropNull.std.js'; import { constantTimeEqual } from '../../Crypto.node.js'; import { generateNotificationProfileId } from '../../types/NotificationProfile-node.node.js'; import { Bootstrap, debug } from './fixtures.node.js'; import { typeIntoInput } from '../helpers.node.js'; import type { App } from './fixtures.node.js'; import { DayOfWeek } from '../../types/NotificationProfile.std.js'; const IdentifierType = Proto.ManifestRecord.Identifier.Type; describe('storage service/notification profiles', function (this: Mocha.Suite) { this.timeout(durations.MINUTE); let bootstrap: Bootstrap; let app: App; beforeEach(async () => { bootstrap = new Bootstrap({ contactCount: 0 }); await bootstrap.init(); const { phone } = bootstrap; let state = StorageState.getEmpty(); state = state.updateAccount({ profileKey: phone.profileKey.serialize(), givenName: phone.profileName, }); await phone.setStorageState(state); app = await bootstrap.link(); }); afterEach(async function (this: Mocha.Context) { if (!bootstrap) { return; } await bootstrap.maybeSaveLogs(this.currentTest, app); if (app) { await app.close(); } await bootstrap.teardown(); }); it('updates storage service on initial onboard on desktop', async () => { const { phone } = bootstrap; const window = await app.getWindow(); const state = await phone.expectStorageState('initial state'); // wait for initial sync storage service update const secondState = await phone.waitForStorageState({ after: state }); debug('Opening settings tab'); await window.locator('[data-testid="NavTabsItem--Settings"]').click(); debug('Opening Notifications page'); await window.getByRole('button', { name: 'Notifications' }).click(); const profileName = 'NewProfile'; debug('Starting Notification Profiles onboarding'); await window.getByRole('button', { name: 'Set up' }).click(); debug('Dismiss onboarding dialog'); await window.getByRole('button', { name: 'Continue' }).click(); debug('Start the create flow'); await window.getByRole('button', { name: 'Create profile' }).click(); debug('Name page'); const nameInput = window.locator('.Input__input'); await typeIntoInput(nameInput, profileName, ''); await window.getByRole('button', { name: 'Next' }).click(); debug('Allowed page'); await window.getByRole('button', { name: 'Next' }).click(); debug('Schedule page'); await window.locator('button[role="switch"]').click(); await window.getByRole('button', { name: 'Next' }).click(); debug('Done page'); await window.getByRole('button', { name: 'Done' }).click(); debug('List page'); await expect( window.getByTestId(`EditProfile--${profileName}`) ).toBeVisible(); // finally, this storage service update should include the new notification profile const thirdState = await phone.waitForStorageState({ after: secondState, }); let profileId: Uint8Array | undefined; const profilewasAdded = thirdState.hasRecord(record => { if (record.record.notificationProfile == null) { return false; } assert.ok(record.type === IdentifierType.NOTIFICATION_PROFILE); const isMatch = record.record.notificationProfile.name === profileName && record.record.notificationProfile.scheduleEnabled === true; if (isMatch) { profileId = dropNull(record.record.notificationProfile.id); } return isMatch; }); if (!profilewasAdded) { throw new Error('Did not find new profile in storage service'); } if (!profileId || !profileId.length) { throw new Error('No profileId found on new notification record'); } debug('Open edit page for profile'); await window.getByTestId(`EditProfile--${profileName}`).click(); debug('Open edit schedule page'); await window.getByTestId('EditSchedule').click(); await window.locator('button[role="switch"]').click(); debug('Done page'); await window.getByRole('button', { name: 'Done' }).click(); debug('Done page'); await window.getByRole('button', { name: 'Done' }).click(); debug('List page'); await expect( window.getByTestId(`EditProfile--${profileName}`) ).toBeVisible(); // finally, this storage service update should include the new notification profile const fourthState = await phone.waitForStorageState({ after: secondState, }); const profileScheduleIsOff = fourthState.hasRecord(record => { if (record.record.notificationProfile == null) { return false; } assert.ok(record.type === IdentifierType.NOTIFICATION_PROFILE); return ( record.record.notificationProfile.name === profileName && record.record.notificationProfile.scheduleEnabled === false ); }); if (!profileScheduleIsOff) { throw new Error('Profile schedule was not disabled in storage service'); } debug('Opening chats tab'); await window.locator('[data-testid="NavTabsItem--Chats"]').click(); debug('Click triple-dot button'); await window.getByRole('button', { name: 'More Actions' }).click(); await window .getByRole('menuitem', { name: 'Notification profile', exact: true }) .click(); debug('Click to add enabled=true override'); await window.getByRole('menuitem', { name: profileName }).click(); // finally, this storage service update should have the new override const fifthState = await phone.waitForStorageState({ after: secondState, }); const acountRecordHasOverride = fifthState.hasRecord(record => { if (record.record.account == null) { return false; } const { notificationProfileManualOverride } = record.record.account; if (notificationProfileManualOverride?.override?.enabled == null) { return false; } const { id } = notificationProfileManualOverride.override.enabled; return Boolean( record.type === IdentifierType.ACCOUNT && id && id.length && profileId && constantTimeEqual(id, profileId) ); }); if (!acountRecordHasOverride) { throw new Error('Did not find matching override in storage service'); } }); it('reconciles profiles from storage service when sync is reenabled', async () => { const { phone } = bootstrap; const window = await app.getWindow(); const starting = await phone.expectStorageState('initial state'); const firstState = await phone.waitForStorageState({ after: starting }); debug('Opening settings tab'); await window.locator('[data-testid="NavTabsItem--Settings"]').click(); debug('Opening Notifications page'); await window.getByRole('button', { name: 'Notifications' }).click(); debug('Open Notification Profiles list page'); await window.getByRole('button', { name: 'Set up' }).click(); debug('Dismiss onboarding dialog'); await window.getByRole('button', { name: 'Continue' }).click(); debug('Adding two profiles and an override to storage service'); const now = Date.now(); const notificationProfileName1 = 'One'; const notificationProfileId1 = Bytes.fromHex( generateNotificationProfileId() ); const notificationProfileName2 = 'Two'; const notificationProfileId2 = Bytes.fromHex( generateNotificationProfileId() ); const notificationProfileName3 = 'Three'; const notificationProfileId3 = Bytes.fromHex( generateNotificationProfileId() ); const notificationProfileName4 = 'Four'; const notificationProfileId4 = Bytes.fromHex( generateNotificationProfileId() ); const DEFAULT_PROFILE = { allowAllCalls: true, allowAllMentions: false, scheduleStartTime: 900, scheduleEndTime: 1700, scheduleEnabled: false, scheduleDaysEnabled: [ DayOfWeek.MONDAY, DayOfWeek.TUESDAY, DayOfWeek.WEDNESDAY, DayOfWeek.THURSDAY, DayOfWeek.FRIDAY, ], emoji: null, allowedMembers: null, deletedAtTimestampMs: null, }; { let newState = firstState.addRecord({ type: IdentifierType.NOTIFICATION_PROFILE, record: { notificationProfile: { id: notificationProfileId1, name: notificationProfileName1, color: 0xffff0000, createdAtMs: BigInt(now + 1), ...DEFAULT_PROFILE, }, }, }); newState = newState.addRecord({ type: IdentifierType.NOTIFICATION_PROFILE, record: { notificationProfile: { id: notificationProfileId2, name: notificationProfileName2, color: 0xff00ff00, createdAtMs: BigInt(now + 2), ...DEFAULT_PROFILE, allowAllCalls: false, }, }, }); newState = newState.updateAccount({ notificationProfileManualOverride: { override: { enabled: { id: notificationProfileId1, endAtTimestampMs: null, }, }, }, }); await phone.setStorageState(newState); } debug('Waiting for desktop to process storage service updates'); await phone.sendFetchStorage({ timestamp: bootstrap.getTimestamp(), }); await app.waitForManifestVersion(firstState.version + 1n); debug('Now we should be on the Notification Profiles list page'); await expect( window.getByTestId(`EditProfile--${notificationProfileName1}`) ).toBeVisible(); await expect( window.getByTestId(`EditProfile--${notificationProfileName2}`) ).toBeVisible(); debug('Turn off Notification Profiles sync'); await window.locator('button[role="switch"]').click(); const secondState = await phone.waitForStorageState({ after: firstState, }); debug( 'We should still see the same items on the Notification Profiles list page' ); await expect( window.getByTestId(`EditProfile--${notificationProfileName1}`) ).toBeVisible(); await expect( window.getByTestId(`EditProfile--${notificationProfileName2}`) ).toBeVisible(); { const accountRecord = secondState.getAccountRecord(); if (!accountRecord?.notificationProfileSyncDisabled) { throw new Error('Notification profile sync is disabled!'); } assert.deepEqual( accountRecord.notificationProfileManualOverride?.override?.enabled?.id, notificationProfileId1 ); let countOfProfiles = 0; secondState.hasRecord(record => { if (record.record.notificationProfile == null) { return false; } const deletedTimestamp = record.record.notificationProfile.deletedAtTimestampMs; if ( record.type === IdentifierType.NOTIFICATION_PROFILE && (!deletedTimestamp || deletedTimestamp === 0n) ) { countOfProfiles += 1; } return false; }); assert.strictEqual( countOfProfiles, 2, 'Expect the original two still in storage service' ); } debug('Open edit page for existing profile'); await window .getByTestId(`EditProfile--${notificationProfileName1}`) .click(); debug('Open edit schedule page, enable schedule'); await window.getByTestId('EditSchedule').click(); await window.locator('button[role="switch"]').click(); debug('Done page'); await window.getByRole('button', { name: 'Done' }).click(); debug('Done page'); await window.getByRole('button', { name: 'Done' }).click(); debug('List page'); await expect( window.getByTestId(`EditProfile--${notificationProfileName1}`) ).toBeVisible(); debug('Now create a new Notification Profile'); await window.getByRole('button', { name: 'Create profile' }).click(); debug('Name page'); const nameInput = window.locator('.Input__input'); await typeIntoInput(nameInput, notificationProfileName3, ''); await window.getByRole('button', { name: 'Next' }).click(); debug('Allowed page'); await window.getByRole('button', { name: 'Next' }).click(); debug('Schedule page'); await window.getByRole('button', { name: 'Next' }).click(); debug('Done page'); await window.getByRole('button', { name: 'Done' }).click(); debug('List page'); await expect( window.getByTestId(`EditProfile--${notificationProfileName3}`) ).toBeVisible(); debug('Turn on sync on storage service, and add two new profiles'); { let newState = secondState.addRecord({ type: IdentifierType.NOTIFICATION_PROFILE, record: { notificationProfile: { id: notificationProfileId1, name: notificationProfileName1, color: 0xffff0000, createdAtMs: BigInt(now + 1), ...DEFAULT_PROFILE, }, }, }); newState = newState.addRecord({ type: IdentifierType.NOTIFICATION_PROFILE, record: { notificationProfile: { id: notificationProfileId2, name: notificationProfileName2, color: 0xff00ff00, createdAtMs: BigInt(now + 2), ...DEFAULT_PROFILE, allowAllCalls: false, }, }, }); newState = newState.addRecord({ type: IdentifierType.NOTIFICATION_PROFILE, record: { notificationProfile: { id: notificationProfileId3, name: notificationProfileName3, color: 0xff0000ff, createdAtMs: BigInt(now + 3), ...DEFAULT_PROFILE, }, }, }); newState = newState.addRecord({ type: IdentifierType.NOTIFICATION_PROFILE, record: { notificationProfile: { id: notificationProfileId4, name: notificationProfileName4, color: 0xff0000ff, createdAtMs: BigInt(now + 4), ...DEFAULT_PROFILE, allowAllCalls: false, scheduleStartTime: 1000, scheduleEndTime: 1100, scheduleDaysEnabled: [DayOfWeek.MONDAY], }, }, }); newState = newState.updateAccount({ notificationProfileManualOverride: { override: { enabled: { id: notificationProfileId1, endAtTimestampMs: null, }, }, }, notificationProfileSyncDisabled: false, }); await phone.setStorageState(newState); } // now desktop will see the off->on flip for sync, and reconcile profiles: // #1: was modified on Desktop, so will be duplicated // #2: same on both, should not be duplicated // #3: created separately on both sides with sync off, structurally similar, no dupe // #4: new via storage service after sync debug('Waiting for desktop to process storage service updates'); await phone.sendFetchStorage({ timestamp: bootstrap.getTimestamp(), }); await app.waitForManifestVersion(secondState.version + 1n); debug('Check what is on the list page now'); await expect( window.getByTestId(`EditProfile--${notificationProfileName1}`) ).toBeVisible(); await expect( window.getByTestId(`EditProfile--Copy of ${notificationProfileName1}`) ).toBeVisible(); await expect( window.getByTestId(`EditProfile--${notificationProfileName2}`) ).toBeVisible(); await expect( window.getByTestId(`EditProfile--${notificationProfileName3}`) ).toBeVisible(); await expect( window.getByTestId(`EditProfile--${notificationProfileName4}`) ).toBeVisible(); const thirdState = await phone.waitForStorageState({ after: secondState, }); let countOfProfiles = 0; thirdState.hasRecord(record => { if (record.record?.notificationProfile == null) { return false; } const deletedTimestamp = record.record.notificationProfile.deletedAtTimestampMs; if ( record.type === IdentifierType.NOTIFICATION_PROFILE && (!deletedTimestamp || deletedTimestamp === 0n) ) { countOfProfiles += 1; } return false; }); assert.strictEqual( countOfProfiles, 5, 'Expect all profiles in storage service' ); }); });