ov-components: Refactor DeviceService to improve permission handling and add utility methods

- Extracted permission strategies into a separate method for better readability.
- Created a method to handle permission strategy attempts and return valid devices.
- Added a utility method to filter out invalid devices.
- Improved error handling in getMediaDevicesFirefox method.

test: Add unit tests for DocumentService

- Implemented comprehensive tests for DocumentService methods including toggleFullscreen, isSmallElement, and fullscreen handling.
- Mocked document and element interactions to ensure proper functionality.

feat: Implement E2EE service with encryption and decryption capabilities

- Developed E2eeService to handle end-to-end encryption with methods for encrypting and decrypting data.
- Added caching for decrypted strings to optimize performance.
- Included tests for various scenarios including encryption failures and binary data handling.

test: Add unit tests for PanelService

- Created tests for PanelService to validate panel opening, closing, and state management.
- Ensured proper emissions from panelStatusObs during panel state changes.

fix: Initialize externalType in PanelService to avoid undefined state

- Set default value for externalType to an empty string to prevent potential issues.
pull/856/head
Carlos Santos 2025-11-13 20:16:01 +01:00
parent b1fb3406a0
commit 0f075008a4
9 changed files with 1534 additions and 655 deletions

View File

@ -0,0 +1,147 @@
import { TestBed } from '@angular/core/testing';
import { ChatService } from './chat.service';
import { LoggerService } from '../logger/logger.service';
import { ParticipantService } from '../participant/participant.service';
import { PanelService } from '../panel/panel.service';
import { ActionService } from '../action/action.service';
import { TranslateService } from '../translate/translate.service';
import { E2eeService } from '../e2ee/e2ee.service';
import { DataTopic } from '../../models/data-topic.model';
import { ChatMessage } from '../../models/chat.model';
class AudioDouble {
play = jasmine.createSpy('play').and.returnValue(Promise.resolve());
volume = 0;
}
describe('ChatService', () => {
let service: ChatService;
let loggerInstance: { d: jasmine.Spy; i: jasmine.Spy; e: jasmine.Spy };
let loggerServiceMock: { get: jasmine.Spy };
let participantServiceMock: { publishData: jasmine.Spy; getMyName: jasmine.Spy };
let panelServiceMock: { isChatPanelOpened: jasmine.Spy; togglePanel: jasmine.Spy };
let actionServiceMock: { launchNotification: jasmine.Spy };
let translateServiceMock: { translate: jasmine.Spy };
let e2eeServiceMock: { encrypt: jasmine.Spy };
let audioFactorySpy: jasmine.Spy;
let audioInstance: AudioDouble;
let originalAudio: typeof Audio;
beforeAll(() => {
originalAudio = (window as any).Audio;
audioFactorySpy = jasmine.createSpy('Audio').and.callFake(() => {
audioInstance = new AudioDouble();
return audioInstance;
});
(window as any).Audio = audioFactorySpy;
});
afterAll(() => {
(window as any).Audio = originalAudio;
});
beforeEach(() => {
audioFactorySpy.calls.reset();
loggerInstance = {
d: jasmine.createSpy('d'),
i: jasmine.createSpy('i'),
e: jasmine.createSpy('e')
};
loggerServiceMock = {
get: jasmine.createSpy('get').and.returnValue(loggerInstance)
};
participantServiceMock = {
publishData: jasmine.createSpy('publishData').and.resolveTo(undefined),
getMyName: jasmine.createSpy('getMyName').and.returnValue('alice')
};
panelServiceMock = {
isChatPanelOpened: jasmine.createSpy('isChatPanelOpened').and.returnValue(true),
togglePanel: jasmine.createSpy('togglePanel')
};
actionServiceMock = {
launchNotification: jasmine.createSpy('launchNotification')
};
translateServiceMock = {
translate: jasmine.createSpy('translate').and.callFake((key: string) => `${key}_translated`)
};
e2eeServiceMock = {
encrypt: jasmine.createSpy('encrypt').and.callFake(async (plain: Uint8Array) => plain)
};
TestBed.configureTestingModule({
providers: [
ChatService,
{ provide: LoggerService, useValue: loggerServiceMock },
{ provide: ParticipantService, useValue: participantServiceMock },
{ provide: PanelService, useValue: panelServiceMock },
{ provide: ActionService, useValue: actionServiceMock },
{ provide: TranslateService, useValue: translateServiceMock },
{ provide: E2eeService, useValue: e2eeServiceMock }
]
});
service = TestBed.inject(ChatService);
});
it('adds remote message without notification when chat panel is open', async () => {
const emissions: ChatMessage[][] = [];
const sub = service.chatMessages$.subscribe((messages) => emissions.push(messages));
await service.addRemoteMessage('Hello world', 'Bob');
expect(emissions.at(-1)).toEqual([{ isLocal: false, participantName: 'Bob', message: 'Hello world' }]);
expect(actionServiceMock.launchNotification).not.toHaveBeenCalled();
expect(audioInstance.play).not.toHaveBeenCalled();
sub.unsubscribe();
});
it('adds remote message and triggers notification with sound when chat panel is closed', async () => {
panelServiceMock.isChatPanelOpened.and.returnValue(false);
const emissions: ChatMessage[][] = [];
const sub = service.chatMessages$.subscribe((messages) => emissions.push(messages));
await service.addRemoteMessage('Hi there', 'Bob');
expect(actionServiceMock.launchNotification).toHaveBeenCalled();
const notificationArgs = actionServiceMock.launchNotification.calls.mostRecent().args[0];
expect(notificationArgs.message).toContain('BOB');
expect(notificationArgs.buttonActionText).toBe('PANEL.CHAT.OPEN_CHAT_translated');
expect(audioInstance.play).toHaveBeenCalled();
expect(emissions.at(-1)?.length).toBe(1);
sub.unsubscribe();
});
it('does not send empty messages', async () => {
await service.sendMessage(' ');
expect(e2eeServiceMock.encrypt).not.toHaveBeenCalled();
expect(participantServiceMock.publishData).not.toHaveBeenCalled();
});
it('encrypts, publishes and stores local messages', async () => {
const emissions: ChatMessage[][] = [];
const sub = service.chatMessages$.subscribe((messages) => emissions.push(messages));
await service.sendMessage('Hello world');
expect(e2eeServiceMock.encrypt).toHaveBeenCalled();
expect(participantServiceMock.publishData).toHaveBeenCalled();
const [, publishOptions] = participantServiceMock.publishData.calls.mostRecent().args;
expect(publishOptions).toEqual({ topic: DataTopic.CHAT, reliable: true });
expect(emissions.at(-1)).toEqual([{ isLocal: true, participantName: 'alice', message: 'Hello world' }]);
sub.unsubscribe();
});
it('logs and rethrows errors when encryption fails', async () => {
const error = new Error('encryption failed');
e2eeServiceMock.encrypt.and.callFake(() => {
throw error;
});
await expectAsync(service.sendMessage('fail')).toBeRejectedWith(error);
expect(loggerInstance.e).toHaveBeenCalledWith('Error sending chat message:', error);
});
});

View File

@ -179,23 +179,15 @@ export class DeviceService {
* @returns A promise that resolves to an array of `MediaDeviceInfo` objects representing the available local devices.
*/
private async getLocalDevices(): Promise<MediaDeviceInfo[]> {
// Forcing media permissions request.
const strategies = [
{ audio: true, video: true },
{ audio: true, video: false },
{ audio: false, video: true }
];
const strategies = this.getPermissionStrategies();
for (const strategy of strategies) {
try {
this.log.d(`Trying strategy: audio=${strategy.audio}, video=${strategy.video}`);
const localTracks = await createLocalTracks(strategy);
localTracks.forEach((track) => track.stop());
// Permission granted
const devices = this.platformSrv.isFirefox() ? await this.getMediaDevicesFirefox() : await Room.getLocalDevices();
return devices.filter((d: MediaDeviceInfo) => d.label && d.deviceId && d.deviceId !== 'default');
const devices = await this.tryPermissionStrategy(strategy);
if (devices) {
return this.filterValidDevices(devices);
}
} catch (error: any) {
this.log.w(`Strategy failed: audio=${strategy.audio}, video=${strategy.video}`, error);
@ -209,6 +201,38 @@ export class DeviceService {
return [];
}
/**
* @internal
* Get the list of permission strategies to try
*/
protected getPermissionStrategies(): Array<{ audio: boolean; video: boolean }> {
return [
{ audio: true, video: true },
{ audio: true, video: false },
{ audio: false, video: true }
];
}
/**
* @internal
* Try a specific permission strategy and return devices if successful
*/
protected async tryPermissionStrategy(strategy: { audio: boolean; video: boolean }): Promise<MediaDeviceInfo[] | null> {
const localTracks = await createLocalTracks(strategy);
localTracks.forEach((track) => track.stop());
// Permission granted
return this.platformSrv.isFirefox() ? await this.getMediaDevicesFirefox() : await Room.getLocalDevices();
}
/**
* @internal
* Filter devices to remove default and invalid entries
*/
protected filterValidDevices(devices: MediaDeviceInfo[]): MediaDeviceInfo[] {
return devices.filter((d: MediaDeviceInfo) => d.label && d.deviceId && d.deviceId !== 'default');
}
private async getMediaDevicesFirefox(): Promise<MediaDeviceInfo[]> {
// Firefox requires to get user media to get the devices
await navigator.mediaDevices.getUserMedia({ audio: true, video: true });
@ -219,20 +243,28 @@ export class DeviceService {
this.log.w('All permission strategies failed, trying device enumeration without permissions');
try {
if (error?.name === 'NotReadableError' || error?.name === 'AbortError') {
this.log.w('Device busy, using enumerateDevices() instead');
const devices = await navigator.mediaDevices.enumerateDevices();
return devices.filter((d) => d.deviceId && d.deviceId !== 'default');
}
if (error?.name === 'NotAllowedError' || error?.name === 'SecurityError') {
this.log.w('Permission denied to access devices');
this.deviceAccessDeniedError = true;
}
return [];
return await this.handleFallbackByErrorType(error);
} catch (error) {
this.log.e('Complete failure getting devices', error);
this.deviceAccessDeniedError = true;
return [];
}
}
/**
* @internal
* Handle fallback based on error type
*/
protected async handleFallbackByErrorType(error: any): Promise<MediaDeviceInfo[]> {
if (error?.name === 'NotReadableError' || error?.name === 'AbortError') {
this.log.w('Device busy, using enumerateDevices() instead');
const devices = await navigator.mediaDevices.enumerateDevices();
return devices.filter((d) => d.deviceId && d.deviceId !== 'default');
}
if (error?.name === 'NotAllowedError' || error?.name === 'SecurityError') {
this.log.w('Permission denied to access devices');
this.deviceAccessDeniedError = true;
}
return [];
}
}

View File

@ -0,0 +1,295 @@
import { DocumentService } from './document.service';
import { LayoutClass } from '../../models/layout.model';
describe('DocumentService', () => {
let service: DocumentService;
beforeEach(() => {
service = new DocumentService();
});
it('should be created', () => {
expect(service).toBeTruthy();
});
describe('isSmallElement', () => {
it('should return true if element has SMALL_ELEMENT class', () => {
const element = document.createElement('div');
element.className = LayoutClass.SMALL_ELEMENT;
expect(service.isSmallElement(element)).toBeTruthy();
});
it('should return false if element does not have SMALL_ELEMENT class', () => {
const element = document.createElement('div');
element.className = 'other-class';
expect(service.isSmallElement(element)).toBeFalsy();
});
it('should return false if element is null', () => {
expect(service.isSmallElement(null as any)).toBeFalsy();
});
it('should return true if element has SMALL_ELEMENT class combined with other classes', () => {
const element = document.createElement('div');
element.className = `some-class ${LayoutClass.SMALL_ELEMENT} another-class`;
expect(service.isSmallElement(element)).toBeTruthy();
});
it('should return true if SMALL_ELEMENT is at the beginning of className', () => {
const element = document.createElement('div');
element.className = `${LayoutClass.SMALL_ELEMENT} another-class`;
expect(service.isSmallElement(element)).toBeTruthy();
});
it('should return true if SMALL_ELEMENT is at the end of className', () => {
const element = document.createElement('div');
element.className = `some-class ${LayoutClass.SMALL_ELEMENT}`;
expect(service.isSmallElement(element)).toBeTruthy();
});
});
describe('toggleFullscreen', () => {
let mockDocument: any;
let mockElement: any;
beforeEach(() => {
mockElement = {
requestFullscreen: jasmine.createSpy('requestFullscreen'),
msRequestFullscreen: jasmine.createSpy('msRequestFullscreen'),
mozRequestFullScreen: jasmine.createSpy('mozRequestFullScreen'),
webkitRequestFullscreen: jasmine.createSpy('webkitRequestFullscreen')
};
mockDocument = {
fullscreenElement: null,
mozFullScreenElement: null,
webkitFullscreenElement: null,
msFullscreenElement: null,
exitFullscreen: jasmine.createSpy('exitFullscreen'),
msExitFullscreen: jasmine.createSpy('msExitFullscreen'),
mozCancelFullScreen: jasmine.createSpy('mozCancelFullScreen'),
webkitExitFullscreen: jasmine.createSpy('webkitExitFullscreen')
};
spyOn<any>(service, 'getDocument').and.returnValue(mockDocument);
spyOn<any>(service, 'getElementById').and.returnValue(mockElement);
});
it('should request fullscreen when not in fullscreen mode', () => {
spyOn<any>(service, 'isInFullscreen').and.returnValue(false);
const requestSpy = spyOn<any>(service, 'requestFullscreen');
service.toggleFullscreen('test-element');
expect(service['getElementById']).toHaveBeenCalledWith('test-element');
expect(requestSpy).toHaveBeenCalledWith(mockElement);
});
it('should exit fullscreen when in fullscreen mode', () => {
spyOn<any>(service, 'isInFullscreen').and.returnValue(true);
const exitSpy = spyOn<any>(service, 'exitFullscreen');
service.toggleFullscreen('test-element');
expect(exitSpy).toHaveBeenCalledWith(mockDocument);
});
});
describe('isInFullscreen', () => {
it('should return false when no fullscreen element', () => {
const mockDocument = {
fullscreenElement: null,
mozFullScreenElement: null,
webkitFullscreenElement: null,
msFullscreenElement: null
};
spyOn<any>(service, 'getDocument').and.returnValue(mockDocument);
expect(service['isInFullscreen']()).toBeFalse();
});
it('should return true when fullscreenElement is set', () => {
const mockElement = document.createElement('div');
const mockDocument = {
fullscreenElement: mockElement,
mozFullScreenElement: null,
webkitFullscreenElement: null,
msFullscreenElement: null
};
spyOn<any>(service, 'getDocument').and.returnValue(mockDocument);
expect(service['isInFullscreen']()).toBeTrue();
});
it('should return true when mozFullScreenElement is set', () => {
const mockElement = document.createElement('div');
const mockDocument = {
fullscreenElement: null,
mozFullScreenElement: mockElement,
webkitFullscreenElement: null,
msFullscreenElement: null
};
spyOn<any>(service, 'getDocument').and.returnValue(mockDocument);
expect(service['isInFullscreen']()).toBeTrue();
});
it('should return true when webkitFullscreenElement is set', () => {
const mockElement = document.createElement('div');
const mockDocument = {
fullscreenElement: null,
mozFullScreenElement: null,
webkitFullscreenElement: mockElement,
msFullscreenElement: null
};
spyOn<any>(service, 'getDocument').and.returnValue(mockDocument);
expect(service['isInFullscreen']()).toBeTrue();
});
it('should return true when msFullscreenElement is set', () => {
const mockElement = document.createElement('div');
const mockDocument = {
fullscreenElement: null,
mozFullScreenElement: null,
webkitFullscreenElement: null,
msFullscreenElement: mockElement
};
spyOn<any>(service, 'getDocument').and.returnValue(mockDocument);
expect(service['isInFullscreen']()).toBeTrue();
});
});
describe('requestFullscreen', () => {
it('should call requestFullscreen when available', () => {
const mockElement = {
requestFullscreen: jasmine.createSpy('requestFullscreen')
};
service['requestFullscreen'](mockElement);
expect(mockElement.requestFullscreen).toHaveBeenCalled();
});
it('should call msRequestFullscreen when requestFullscreen not available', () => {
const mockElement = {
requestFullscreen: undefined,
msRequestFullscreen: jasmine.createSpy('msRequestFullscreen')
};
service['requestFullscreen'](mockElement);
expect(mockElement.msRequestFullscreen).toHaveBeenCalled();
});
it('should call mozRequestFullScreen when standard methods not available', () => {
const mockElement = {
requestFullscreen: undefined,
msRequestFullscreen: undefined,
mozRequestFullScreen: jasmine.createSpy('mozRequestFullScreen')
};
service['requestFullscreen'](mockElement);
expect(mockElement.mozRequestFullScreen).toHaveBeenCalled();
});
it('should call webkitRequestFullscreen when other methods not available', () => {
const mockElement = {
requestFullscreen: undefined,
msRequestFullscreen: undefined,
mozRequestFullScreen: undefined,
webkitRequestFullscreen: jasmine.createSpy('webkitRequestFullscreen')
};
service['requestFullscreen'](mockElement);
expect(mockElement.webkitRequestFullscreen).toHaveBeenCalled();
});
it('should handle null element gracefully', () => {
expect(() => service['requestFullscreen'](null)).not.toThrow();
});
it('should handle undefined element gracefully', () => {
expect(() => service['requestFullscreen'](undefined)).not.toThrow();
});
});
describe('exitFullscreen', () => {
it('should call exitFullscreen when available', () => {
const mockDocument = {
exitFullscreen: jasmine.createSpy('exitFullscreen')
};
service['exitFullscreen'](mockDocument);
expect(mockDocument.exitFullscreen).toHaveBeenCalled();
});
it('should call msExitFullscreen when exitFullscreen not available', () => {
const mockDocument = {
exitFullscreen: undefined,
msExitFullscreen: jasmine.createSpy('msExitFullscreen')
};
service['exitFullscreen'](mockDocument);
expect(mockDocument.msExitFullscreen).toHaveBeenCalled();
});
it('should call mozCancelFullScreen when standard methods not available', () => {
const mockDocument = {
exitFullscreen: undefined,
msExitFullscreen: undefined,
mozCancelFullScreen: jasmine.createSpy('mozCancelFullScreen')
};
service['exitFullscreen'](mockDocument);
expect(mockDocument.mozCancelFullScreen).toHaveBeenCalled();
});
it('should call webkitExitFullscreen when other methods not available', () => {
const mockDocument = {
exitFullscreen: undefined,
msExitFullscreen: undefined,
mozCancelFullScreen: undefined,
webkitExitFullscreen: jasmine.createSpy('webkitExitFullscreen')
};
service['exitFullscreen'](mockDocument);
expect(mockDocument.webkitExitFullscreen).toHaveBeenCalled();
});
it('should handle null document gracefully', () => {
expect(() => service['exitFullscreen'](null)).not.toThrow();
});
it('should handle undefined document gracefully', () => {
expect(() => service['exitFullscreen'](undefined)).not.toThrow();
});
});
describe('getDocument and getElementById', () => {
it('should return window.document by default', () => {
const doc = service['getDocument']();
expect(doc).toBe(window.document);
});
it('should return element from document', () => {
const testElement = document.createElement('div');
testElement.id = 'test-element-id';
document.body.appendChild(testElement);
const element = service['getElementById']('test-element-id');
expect(element).toBe(testElement);
document.body.removeChild(testElement);
});
});
});

View File

@ -11,37 +11,83 @@ export class DocumentService {
constructor() {}
toggleFullscreen(elementId: string) {
const document: any = window.document;
const fs = document.getElementById(elementId);
if (
!document.fullscreenElement &&
!document.mozFullScreenElement &&
!document.webkitFullscreenElement &&
!document.msFullscreenElement
) {
if (fs.requestFullscreen) {
fs.requestFullscreen();
} else if (fs.msRequestFullscreen) {
fs.msRequestFullscreen();
} else if (fs.mozRequestFullScreen) {
fs.mozRequestFullScreen();
} else if (fs.webkitRequestFullscreen) {
fs.webkitRequestFullscreen();
}
const document: any = this.getDocument();
const fs = this.getElementById(elementId);
if (this.isInFullscreen()) {
this.exitFullscreen(document);
} else {
if (document.exitFullscreen) {
document.exitFullscreen();
} else if (document.msExitFullscreen) {
document.msExitFullscreen();
} else if (document.mozCancelFullScreen) {
document.mozCancelFullScreen();
} else if (document.webkitExitFullscreen) {
document.webkitExitFullscreen();
}
this.requestFullscreen(fs);
}
}
isSmallElement(element: HTMLElement | Element): boolean {
return element?.className.includes(LayoutClass.SMALL_ELEMENT);
}
/**
* @internal
* Get the document object (can be overridden for testing)
*/
protected getDocument(): any {
return window.document;
}
/**
* @internal
* Get element by ID (can be overridden for testing)
*/
protected getElementById(elementId: string): any {
return this.getDocument().getElementById(elementId);
}
/**
* @internal
* Check if currently in fullscreen mode
*/
protected isInFullscreen(): boolean {
const document: any = this.getDocument();
return !!(
document.fullscreenElement ||
document.mozFullScreenElement ||
document.webkitFullscreenElement ||
document.msFullscreenElement
);
}
/**
* @internal
* Request fullscreen on element using vendor-specific methods
*/
protected requestFullscreen(element: any): void {
if (!element) return;
if (element.requestFullscreen) {
element.requestFullscreen();
} else if (element.msRequestFullscreen) {
element.msRequestFullscreen();
} else if (element.mozRequestFullScreen) {
element.mozRequestFullScreen();
} else if (element.webkitRequestFullscreen) {
element.webkitRequestFullscreen();
}
}
/**
* @internal
* Exit fullscreen using vendor-specific methods
*/
protected exitFullscreen(document: any): void {
if (!document) return;
if (document.exitFullscreen) {
document.exitFullscreen();
} else if (document.msExitFullscreen) {
document.msExitFullscreen();
} else if (document.mozCancelFullScreen) {
document.mozCancelFullScreen();
} else if (document.webkitExitFullscreen) {
document.webkitExitFullscreen();
}
}
}

View File

@ -0,0 +1,211 @@
import { TestBed } from '@angular/core/testing';
import { E2eeService } from './e2ee.service';
import { OpenViduComponentsConfigService } from '../config/directive-config.service';
import { OpenViduComponentsConfigServiceMock } from '../../../test-helpers/mocks';
import * as livekit from 'livekit-client';
describe('E2eeService', () => {
let service: E2eeService;
let configMock: OpenViduComponentsConfigServiceMock;
beforeEach(() => {
configMock = new OpenViduComponentsConfigServiceMock();
TestBed.configureTestingModule({
providers: [
E2eeService,
{ provide: OpenViduComponentsConfigService, useValue: configMock }
]
});
service = TestBed.inject(E2eeService);
});
it('should be created with E2EE disabled by default', () => {
expect(service).toBeTruthy();
expect(service.isEnabled).toBeFalse();
});
it('encrypt returns original string when E2EE disabled', async () => {
const input = 'hello world';
const out = await service.encrypt(input);
expect(out).toBe(input);
});
it('setE2EEKey enables service when deriveEncryptionKey succeeds', async () => {
// Spy the private deriveEncryptionKey to simulate successful key derivation
spyOn<any>(service as any, 'deriveEncryptionKey').and.callFake(async (passphrase: string) => {
// Simulate setting encryptionKey
(service as any).encryptionKey = {} as CryptoKey;
});
// Call setE2EEKey with a value
await service.setE2EEKey('my-secret');
expect((service as any).isE2EEEnabled).toBeTrue();
expect((service as any).encryptionKey).toBeDefined();
expect(service.isEnabled).toBeTrue();
});
it('clearCache empties decryption cache and ngOnDestroy clears and completes', () => {
// Populate cache
(service as any).decryptionCache.set('a', 'b');
expect((service as any).decryptionCache.size).toBeGreaterThan(0);
service.clearCache();
expect((service as any).decryptionCache.size).toBe(0);
// Re-add and call ngOnDestroy
(service as any).decryptionCache.set('x', 'y');
service.ngOnDestroy();
expect((service as any).decryptionCache.size).toBe(0);
});
it('setE2EEKey calls deriveEncryptionKey and applies result on success (via spy)', async () => {
spyOn<any>(service as any, 'deriveEncryptionKey').and.callFake(async (passphrase: string) => {
(service as any).encryptionKey = {} as CryptoKey;
});
await service.setE2EEKey('passphrase');
expect((service as any).encryptionKey).toBeDefined();
expect((service as any).isE2EEEnabled).toBeTrue();
expect(service.isEnabled).toBeTrue();
});
it('setE2EEKey handles deriveEncryptionKey failure (via spy) and leaves encryptionKey undefined', async () => {
// Simulate deriveEncryptionKey handling the error internally (doesn't throw) and leaving encryptionKey undefined
spyOn<any>(service as any, 'deriveEncryptionKey').and.callFake(async () => {
(service as any).encryptionKey = undefined;
});
await service.setE2EEKey('bad');
expect((service as any).encryptionKey).toBeUndefined();
expect((service as any).isE2EEEnabled).toBeTrue();
expect(service.isEnabled).toBeFalse();
});
it('encrypt and decrypt support binary Uint8Array paths', async () => {
(service as any).isE2EEEnabled = true;
(service as any).encryptionKey = {} as CryptoKey;
// Fake encrypt returns payload buffer
const payload = new Uint8Array([10, 11, 12]).buffer;
spyOn((window.crypto as any).subtle, 'encrypt').and.callFake(() => Promise.resolve(payload));
spyOn((window.crypto as any), 'getRandomValues').and.callFake((arr: Uint8Array) => {
for (let i = 0; i < arr.length; i++) arr[i] = i + 2;
return arr;
});
const input = new TextEncoder().encode('binary-data');
const encrypted = await service.encrypt(input) as Uint8Array;
expect(encrypted instanceof Uint8Array).toBeTrue();
// encrypted should contain iv (12) + payload
expect(encrypted.length).toBeGreaterThan(12);
// Now fake decrypt to return original input buffer
spyOn((window.crypto as any).subtle, 'decrypt').and.callFake(() => Promise.resolve(input.buffer));
// Create combined iv + payload similar to encrypt output
const iv = new Uint8Array(12);
for (let i = 0; i < iv.length; i++) iv[i] = i + 2;
const combined = new Uint8Array(iv.length + input.length);
combined.set(iv, 0);
combined.set(input, iv.length);
const decrypted = await service.decrypt(combined) as Uint8Array;
expect(decrypted instanceof Uint8Array).toBeTrue();
expect(new TextDecoder().decode(decrypted)).toBe('binary-data');
});
it('decryptOrMask returns masked outputs when decryption fails for string and binary', async () => {
// Force enabled and provide a key so decrypt will be attempted
spyOnProperty(service, 'isEnabled', 'get').and.returnValue(true);
(service as any).encryptionKey = {} as CryptoKey;
// For string: provide base64 that will lead decrypt to throw
const fakeBase64 = btoa('garbage');
spyOn((window.crypto as any).subtle, 'decrypt').and.callFake(() => Promise.reject(new Error('fail')));
const maskedStr = await service.decryptOrMask(fakeBase64, undefined, 'MASKED');
expect(maskedStr).toBe('MASKED');
// For binary: provide Uint8Array that will make decrypt fail
const fakeBinary = new Uint8Array([1, 2, 3, 4]);
const maskedBin = await service.decryptOrMask(fakeBinary, undefined, 'BLANK') as Uint8Array;
expect(new TextDecoder().decode(maskedBin)).toBe('BLANK');
});
it('encrypt and decrypt flow when enabled uses Web Crypto and caches decrypted strings', async () => {
// Enable E2EE and set a dummy encryptionKey
(service as any).isE2EEEnabled = true;
(service as any).encryptionKey = {} as CryptoKey;
// Stub crypto.subtle.encrypt to return a small payload buffer
const fakeEncryptedPayload = new Uint8Array([9, 8, 7]).buffer;
spyOn((window.crypto as any).subtle, 'encrypt').and.callFake(() => Promise.resolve(fakeEncryptedPayload));
// Stub getRandomValues to return predictable IV (12 bytes)
spyOn((window.crypto as any), 'getRandomValues').and.callFake((arr: Uint8Array) => {
for (let i = 0; i < arr.length; i++) arr[i] = i + 1; // 1..12
return arr;
});
// Encrypt a string -> should return base64 string
const plain = 'hello-e2ee';
const encrypted = await service.encrypt(plain) as string;
expect(typeof encrypted).toBe('string');
expect(encrypted.length).toBeGreaterThan(0);
// Now stub crypto.subtle.decrypt to return decrypted buffer matching original plain
spyOn((window.crypto as any).subtle, 'decrypt').and.callFake(() => Promise.resolve(new TextEncoder().encode(plain).buffer));
// Call decrypt with the base64 returned by encrypt
const decrypted = await service.decrypt(encrypted, 'participant1') as string;
expect(decrypted).toBe(plain);
// Call decrypt again with same input -> should hit cache and not call crypto.subtle.decrypt again
const decryptSpy = (window.crypto as any).subtle.decrypt as jasmine.Spy;
decryptSpy.calls.reset();
const decrypted2 = await service.decrypt(encrypted, 'participant1') as string;
expect(decrypted2).toBe(plain);
expect(decryptSpy).not.toHaveBeenCalled();
});
it('decrypt throws when encryptionKey is not initialized but isEnabled forced true', async () => {
(service as any).isE2EEEnabled = true;
(service as any).encryptionKey = undefined;
// Force the isEnabled getter to return true so we hit the encryptionKey missing branch
spyOnProperty(service, 'isEnabled', 'get').and.returnValue(true);
await expectAsync(service.decrypt(new Uint8Array([1, 2, 3]))).toBeRejectedWithError(/E2EE decryption not available/);
});
it('decryptOrMask returns masked value when key missing and returns input when not base64', async () => {
// Case: E2EE disabled -> returns input
(service as any).isE2EEEnabled = false;
const txt = 'not-encrypted';
expect(await service.decryptOrMask(txt)).toBe(txt);
// Case: E2EE enabled but encryptionKey missing -> since isEnabled is false, decryptOrMask returns original input
(service as any).isE2EEEnabled = true;
(service as any).encryptionKey = undefined;
const maskedWhenNotEnabled = await service.decryptOrMask(txt, undefined, 'MASK');
expect(maskedWhenNotEnabled).toBe(txt);
// If we force isEnabled to true but encryptionKey missing, decryptOrMask should return the mask
spyOnProperty(service, 'isEnabled', 'get').and.returnValue(true);
(service as any).encryptionKey = undefined;
const masked = await service.decryptOrMask(txt, undefined, 'MASK');
expect(masked).toBe('MASK');
// Case: input not base64 -> when enabled and key present, should return input unchanged
(service as any).encryptionKey = {} as CryptoKey;
// restore isEnabled behavior to rely on actual getter
(service as any).isE2EEEnabled = true;
const notBase64 = 'this is not base64!';
expect(await service.decryptOrMask(notBase64)).toBe(notBase64);
});
});

View File

@ -0,0 +1,124 @@
import { TestBed } from '@angular/core/testing';
import { PanelService } from './panel.service';
import { LoggerService } from '../logger/logger.service';
import { PanelType, PanelSettingsOptions } from '../../models/panel.model';
import { PanelStatusInfo } from '../../models/panel.model';
import { LoggerServiceMock } from '../../../test-helpers/mocks';
describe('PanelService', () => {
let service: PanelService;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
PanelService,
{ provide: LoggerService, useClass: LoggerServiceMock }
]
});
service = TestBed.inject(PanelService);
});
it('should be created and initially closed', () => {
expect(service).toBeTruthy();
expect(service.isPanelOpened()).toBeFalse();
});
it('panelStatusObs emits initial value and after toggle opens the CHAT panel', () => {
const emissions: PanelStatusInfo[] = [];
const sub = service.panelStatusObs.subscribe(v => emissions.push(v));
// initial emission
expect(emissions.length).toBe(1);
expect(emissions[0].isOpened).toBeFalse();
// open chat
service.togglePanel(PanelType.CHAT);
expect(service.isPanelOpened()).toBeTrue();
expect(service.isChatPanelOpened()).toBeTrue();
// verify an emission was pushed and panelType is CHAT
const last = emissions[emissions.length - 1];
expect(last.isOpened).toBeTrue();
expect(last.panelType).toBe(PanelType.CHAT);
sub.unsubscribe();
});
it('toggling same panel closes it and toggling different panel sets previousPanelType', () => {
const emissions: PanelStatusInfo[] = [];
const sub = service.panelStatusObs.subscribe(v => emissions.push(v));
service.togglePanel(PanelType.PARTICIPANTS);
expect(service.isParticipantsPanelOpened()).toBeTrue();
// toggling same panel should close it
service.togglePanel(PanelType.PARTICIPANTS);
expect(service.isPanelOpened()).toBeFalse();
// open panel A then open panel B -> previousPanelType should be A
service.togglePanel(PanelType.ACTIVITIES);
expect(service.isActivitiesPanelOpened()).toBeTrue();
service.togglePanel(PanelType.SETTINGS);
expect(service.isSettingsPanelOpened()).toBeTrue();
const last = emissions[emissions.length - 1];
expect(last.previousPanelType).toBe(PanelType.ACTIVITIES);
sub.unsubscribe();
});
it('supports external panels and subOptionType', () => {
const externalName = 'MY_EXTERNAL_PANEL';
const subOpt: PanelSettingsOptions | string = 'SOME_OPTION';
// open external
service.togglePanel(externalName, subOpt);
expect(service.isExternalPanelOpened()).toBeTrue();
// panelStatusObs should contain the external panel type and subOptionType
const emitted = [] as PanelStatusInfo[];
const s = service.panelStatusObs.subscribe(v => emitted.push(v));
// last pushed value
const last = emitted[emitted.length - 1];
expect(last.panelType).toBe(externalName);
expect(last.subOptionType).toBe(subOpt);
// toggling the same external panel closes it
service.togglePanel(externalName);
expect(service.isExternalPanelOpened()).toBeFalse();
s.unsubscribe();
});
it('opens and closes the background effects panel correctly', () => {
// Open background effects
service.togglePanel(PanelType.BACKGROUND_EFFECTS);
expect(service.isBackgroundEffectsPanelOpened()).toBeTrue();
// Verify panelStatusObs last emission has correct panelType
const emitted = [] as any[];
const sub = service.panelStatusObs.subscribe(v => emitted.push(v));
const last = emitted[emitted.length - 1];
expect(last.panelType).toBe(PanelType.BACKGROUND_EFFECTS);
// Close it
service.togglePanel(PanelType.BACKGROUND_EFFECTS);
expect(service.isBackgroundEffectsPanelOpened()).toBeFalse();
sub.unsubscribe();
});
it('closePanel and clear close the panel and reset state', () => {
service.togglePanel(PanelType.CHAT);
expect(service.isPanelOpened()).toBeTrue();
service.closePanel();
expect(service.isPanelOpened()).toBeFalse();
// open again and then clear
service.togglePanel(PanelType.CHAT);
expect(service.isPanelOpened()).toBeTrue();
service.clear();
expect(service.isPanelOpened()).toBeFalse();
});
});

View File

@ -14,7 +14,7 @@ export class PanelService {
panelStatusObs: Observable<PanelStatusInfo>;
private log: ILogger;
private isExternalOpened: boolean = false;
private externalType: string;
private externalType: string = '';
private _panelOpened = <BehaviorSubject<PanelStatusInfo>>new BehaviorSubject({ isOpened: false });
private panelTypes: string[] = Object.values(PanelType);

View File

@ -0,0 +1,24 @@
import { BehaviorSubject } from 'rxjs';
export class LoggerServiceMock {
get() {
return {
d: () => {},
i: () => {},
e: () => {}
};
}
}
export class OpenViduComponentsConfigServiceMock {
// Expose e2eeKey$ as a BehaviorSubject so tests can emit values
e2eeKey$ = new BehaviorSubject<string | null>(null);
getE2EEKey() {
return this.e2eeKey$.getValue();
}
updateE2EEKey(value: string | null) {
this.e2eeKey$.next(value);
}
}