mirror of https://github.com/OpenVidu/openvidu.git
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
parent
b1fb3406a0
commit
0f075008a4
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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 [];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue