UNPKG

shaka-player

Version:
1,366 lines (1,143 loc) 91.8 kB
/*! @license * Shaka Player * Copyright 2016 Google LLC * SPDX-License-Identifier: Apache-2.0 */ describe('DrmEngine', () => { const Util = shaka.test.Util; const originalRequestMediaKeySystemAccess = navigator.requestMediaKeySystemAccess; const originalLogError = shaka.log.error; const originalBatchTime = shaka.media.DrmEngine.KEY_STATUS_BATCH_TIME; const originalDecodingInfo = navigator.mediaCapabilities.decodingInfo; /** @type {!jasmine.Spy} */ let decodingInfoSpy; /** @type {!jasmine.Spy} */ let logErrorSpy; /** @type {!jasmine.Spy} */ let onErrorSpy; /** @type {!jasmine.Spy} */ let onKeyStatusSpy; /** @type {!jasmine.Spy} */ let onExpirationSpy; /** @type {!jasmine.Spy} */ let onEventSpy; /** @type {!shaka.test.FakeNetworkingEngine} */ let fakeNetEngine; /** @type {!shaka.media.DrmEngine} */ let drmEngine; /** @type {shaka.extern.Manifest} */ let manifest; /** @type {shaka.extern.DrmConfiguration} */ let config; let mockMediaKeySystemAccess; let mockMediaKeys; /** @type {!shaka.test.FakeVideo} */ let mockVideo; let session1; let session2; let session3; const containing = jasmine.objectContaining; beforeAll(() => { shaka.media.DrmEngine.KEY_STATUS_BATCH_TIME = 0; }); afterAll(() => { shaka.media.DrmEngine.KEY_STATUS_BATCH_TIME = originalBatchTime; }); beforeEach(() => { decodingInfoSpy = jasmine.createSpy('decodingInfo'); navigator.mediaCapabilities.decodingInfo = shaka.test.Util.spyFunc(decodingInfoSpy); logErrorSpy = jasmine.createSpy('shaka.log.error'); shaka.log.error = shaka.test.Util.spyFunc(logErrorSpy); onErrorSpy = jasmine.createSpy('onError'); onKeyStatusSpy = jasmine.createSpy('onKeyStatus'); onExpirationSpy = jasmine.createSpy('onExpirationUpdated'); onEventSpy = jasmine.createSpy('onEvent'); manifest = shaka.test.ManifestGenerator.generate((manifest) => { manifest.addVariant(0, (variant) => { variant.addVideo(1, (stream) => { stream.encrypted = true; stream.addDrmInfo('drm.abc'); stream.addDrmInfo('drm.def'); stream.mime('video/foo', 'vbar'); }); variant.addAudio(2, (stream) => { stream.encrypted = true; stream.addDrmInfo('drm.abc'); stream.addDrmInfo('drm.def'); stream.mime('audio/foo', 'abar'); }); }); }); // By default, error logs and callbacks result in failure. onErrorSpy.and.callFake(fail); logErrorSpy.and.callFake(fail); // By default, allow keysystem drm.abc setDecodingInfoSpy(['drm.abc']); mockVideo = new shaka.test.FakeVideo(); session1 = createMockSession(); session2 = createMockSession(); session3 = createMockSession(); mockMediaKeySystemAccess = createMockMediaKeySystemAccess(); mockMediaKeys = createMockMediaKeys(); mockMediaKeys.createSession.and.callFake(() => { const index = mockMediaKeys.createSession.calls.count() - 1; return [session1, session2, session3][index]; }); mockMediaKeys.setServerCertificate.and.returnValue(Promise.resolve()); fakeNetEngine = new shaka.test.FakeNetworkingEngine(); const license = new Uint8Array(0); fakeNetEngine.setResponseValue('http://abc.drm/license', license); const playerInterface = { netEngine: fakeNetEngine, onError: shaka.test.Util.spyFunc(onErrorSpy), onKeyStatus: shaka.test.Util.spyFunc(onKeyStatusSpy), onExpirationUpdated: shaka.test.Util.spyFunc(onExpirationSpy), onEvent: shaka.test.Util.spyFunc(onEventSpy), }; drmEngine = new shaka.media.DrmEngine(playerInterface); config = shaka.util.PlayerConfiguration.createDefault().drm; config.servers = { 'drm.abc': 'http://abc.drm/license', 'drm.def': 'http://def.drm/license', }; drmEngine.configure(config); }); afterEach(async () => { await drmEngine.destroy(); navigator.requestMediaKeySystemAccess = originalRequestMediaKeySystemAccess; navigator.mediaCapabilities.decodingInfo = originalDecodingInfo; shaka.log.error = originalLogError; }); describe('supportsVariants', () => { it('supports all clear variants', async () => { const manifest = shaka.test.ManifestGenerator.generate((manifest) => { manifest.addVariant(0, (variant) => { variant.addVideo(1, (stream) => { stream.encrypted = false; stream.addDrmInfo('drm.abc'); stream.addDrmInfo('drm.def'); stream.mime('video/foo', 'vbar'); }); }); }); const variants = manifest.variants; await drmEngine.initForPlayback(variants, manifest.offlineSessionIds); expect(drmEngine.supportsVariant(variants[0])).toBeTruthy(); }); }); describe('init', () => { it('stops on first available key system', async () => { // Accept both drm.abc and drm.def. Only one can be chosen. setDecodingInfoSpy(['drm.abc', 'drm.def']); const variants = manifest.variants; await drmEngine.initForPlayback(variants, manifest.offlineSessionIds); expect(drmEngine.initialized()).toBe(true); expect(shaka.media.DrmEngine.keySystem(drmEngine.getDrmInfo())) .toBe('drm.abc'); }); it('tries to get the key systems in the order they appear in', async () => { // Fail both key systems. setDecodingInfoSpy([]); const variants = manifest.variants; await expectAsync(drmEngine.initForPlayback(variants, manifest.offlineSessionIds)).toBeRejected(); expect(decodingInfoSpy).toHaveBeenCalledTimes(2); expect(decodingInfoSpy).toHaveBeenCalledWith(containing({ keySystemConfiguration: containing({keySystem: 'drm.abc'}), })); expect(decodingInfoSpy).toHaveBeenCalledWith(containing({ keySystemConfiguration: containing({keySystem: 'drm.def'}), })); }); it('tries the second key system if the first fails', async () => { // Accept drm.def, but not drm.abc. setDecodingInfoSpy(['drm.def']); const variants = manifest.variants; await drmEngine.initForPlayback(variants, manifest.offlineSessionIds); expect(drmEngine.initialized()).toBe(true); expect(shaka.media.DrmEngine.keySystem(drmEngine.getDrmInfo())) .toBe('drm.def'); }); it('chooses systems by configured preferredKeySystems', async () => { // Accept both drm.abc and drm.def. Only one can be chosen. setDecodingInfoSpy(['drm.abc', 'drm.def']); config.preferredKeySystems = ['drm.def']; drmEngine.configure(config); logErrorSpy.and.stub(); const variants = manifest.variants; await drmEngine.initForPlayback(variants, manifest.offlineSessionIds); expect(variants[0].decodingInfos.length).toBe(2); expect(shaka.media.DrmEngine.keySystem(drmEngine.getDrmInfo())) .toBe('drm.def'); }); it('chooses systems with configured license servers', async () => { // Accept both drm.abc and drm.def. Only one can be chosen. setDecodingInfoSpy(['drm.abc', 'drm.def']); // Remove the server URI for drm.abc, which appears first in the manifest. delete config.servers['drm.abc']; drmEngine.configure(config); // Ignore error logs, which we expect to occur due to the missing server. logErrorSpy.and.stub(); const variants = manifest.variants; await drmEngine.initForPlayback(variants, manifest.offlineSessionIds); expect(variants[0].decodingInfos.length).toBe(2); expect(shaka.media.DrmEngine.keySystem(drmEngine.getDrmInfo())) .toBe('drm.def'); }); it('overrides manifest with configured license servers', async () => { // Accept both drm.abc and drm.def. Only one can be chosen. setDecodingInfoSpy(['drm.abc', 'drm.def']); // Add manifest-supplied license servers for both. tweakDrmInfos((drmInfos) => { for (const drmInfo of drmInfos) { if (drmInfo.keySystem == 'drm.abc') { drmInfo.licenseServerUri = 'http://foo.bar/abc'; } else if (drmInfo.keySystem == 'drm.def') { drmInfo.licenseServerUri = 'http://foo.bar/def'; } // Make sure we didn't somehow choose manifest-supplied values that // match the config. This would invalidate parts of the test. const configServer = config.servers[drmInfo.keySystem]; expect(drmInfo.licenseServerUri).not.toBe(configServer); } }); // Remove the server URI for drm.abc from the config, so that only drm.def // could be used, in spite of the manifest-supplied license server URI. delete config.servers['drm.abc']; drmEngine.configure(config); // Ignore error logs, which we expect to occur due to the missing server. logErrorSpy.and.stub(); const variants = manifest.variants; await drmEngine.initForPlayback(variants, manifest.offlineSessionIds); expect(variants[0].decodingInfos.length).toBe(2); const selectedDrmInfo = drmEngine.getDrmInfo(); expect(selectedDrmInfo).not.toBe(null); expect(selectedDrmInfo.keySystem).toBe('drm.def'); expect(selectedDrmInfo.licenseServerUri).toBe(config.servers['drm.def']); }); it('detects content type capabilities of key system', async () => { setDecodingInfoSpy(['drm.abc']); const variants = manifest.variants; await drmEngine.initForPlayback(variants, manifest.offlineSessionIds); expect(drmEngine.initialized()).toBe(true); expect(drmEngine.willSupport('audio/webm')).toBeTruthy(); expect(drmEngine.willSupport('video/mp4; codecs="fake"')).toBeTruthy(); expect(drmEngine.willSupport('video/mp4; codecs="FAKE"')).toBeTruthy(); // Because DrmEngine will err on being too accepting, make sure it will // reject something. However, we can only check that it is actually // thing on non-Edge browsers because of https://bit.ly/2IcEgv0 if (!shaka.util.Platform.isLegacyEdge()) { expect(drmEngine.willSupport('this-should-fail')).toBeFalsy(); } }); it('fails to initialize if no key systems are available', async () => { // Accept no key systems. setDecodingInfoSpy([]); const expected = Util.jasmineError(new shaka.util.Error( shaka.util.Error.Severity.CRITICAL, shaka.util.Error.Category.DRM, shaka.util.Error.Code.REQUESTED_KEY_SYSTEM_CONFIG_UNAVAILABLE)); const variants = manifest.variants; await expectAsync( drmEngine.initForPlayback(variants, manifest.offlineSessionIds)) .toBeRejectedWith(expected); expect(drmEngine.initialized()).toBe(false); expect(variants[0].decodingInfos.length).toBe(2); }); it('does not error for unencrypted assets with no EME', async () => { manifest = shaka.test.ManifestGenerator.generate((manifest) => { manifest.addVariant(0, (variant) => { variant.addVideo(1, (stream) => { stream.mime('video/foo', 'vbar'); }); variant.addAudio(2, (stream) => { stream.mime('audio/foo', 'abar'); }); }); }); // Accept no key systems, simulating a lack of EME. setDecodingInfoSpy([]); const variants = manifest.variants; // All that matters here is that we don't throw. await expectAsync( drmEngine.initForPlayback(variants, manifest.offlineSessionIds)) .not.toBeRejected(); }); it('fails to initialize if no key systems are recognized', async () => { // Simulate the DASH parser inserting a blank placeholder when only // unrecognized custom schemes are found. tweakDrmInfos((drmInfos) => { drmInfos[0].keySystem = ''; drmInfos[1].keySystem = ''; }); const expected = Util.jasmineError(new shaka.util.Error( shaka.util.Error.Severity.CRITICAL, shaka.util.Error.Category.DRM, shaka.util.Error.Code.NO_RECOGNIZED_KEY_SYSTEMS)); const variants = manifest.variants; await expectAsync( drmEngine.initForPlayback(variants, manifest.offlineSessionIds)) .toBeRejectedWith(expected); expect(drmEngine.initialized()).toBe(false); expect(variants[0].decodingInfos.length).toBe(1); expect(variants[0].decodingInfos[0].keySystemAccess).toBeFalsy(); }); it('fails to initialize if the CDM cannot be created', async () => { // The query succeeds, but we fail to create the CDM. mockMediaKeySystemAccess.createMediaKeys.and.throwError('whoops!'); const expected = Util.jasmineError(new shaka.util.Error( shaka.util.Error.Severity.CRITICAL, shaka.util.Error.Category.DRM, shaka.util.Error.Code.FAILED_TO_CREATE_CDM, 'whoops!')); const variants = manifest.variants; await expectAsync( drmEngine.initForPlayback(variants, manifest.offlineSessionIds)) .toBeRejectedWith(expected); expect(drmEngine.initialized()).toBe(false); expect(variants[0].decodingInfos.length).toBe(2); }); it('queries audio/video capabilities', async () => { setDecodingInfoSpy([]); const variants = manifest.variants; await expectAsync( drmEngine.initForPlayback(variants, manifest.offlineSessionIds)) .toBeRejected(); expect(drmEngine.initialized()).toBe(false); const decodingConfig1 = containing({ video: containing({ contentType: 'video/foo; codecs="vbar"', }), audio: containing({ contentType: 'audio/foo; codecs="abar"', }), keySystemConfiguration: containing({ keySystem: 'drm.abc', persistentState: 'optional', distinctiveIdentifier: 'optional', sessionTypes: ['temporary'], initDataType: 'cenc', }), }); const decodingConfig2 = containing({ video: containing({ contentType: 'video/foo; codecs="vbar"', }), audio: containing({ contentType: 'audio/foo; codecs="abar"', }), keySystemConfiguration: containing({ keySystem: 'drm.def', persistentState: 'optional', distinctiveIdentifier: 'optional', sessionTypes: ['temporary'], initDataType: 'cenc', }), }); expect(decodingInfoSpy).toHaveBeenCalledTimes(2); expect(decodingInfoSpy).toHaveBeenCalledWith(decodingConfig1); expect(decodingInfoSpy).toHaveBeenCalledWith(decodingConfig2); }); it('asks for persistent state and license for offline', async () => { setDecodingInfoSpy([]); const variants = manifest.variants; await expectAsync( drmEngine.initForStorage(variants, /* usePersistentLicense= */ true)) .toBeRejected(); expect(drmEngine.initialized()).toBe(false); expect(decodingInfoSpy).toHaveBeenCalledTimes(2); expect(decodingInfoSpy).toHaveBeenCalledWith(containing({ keySystemConfiguration: containing({ keySystem: 'drm.abc', distinctiveIdentifier: 'optional', persistentState: 'required', sessionTypes: ['persistent-license'], initDataType: 'cenc', }), }), ); expect(decodingInfoSpy).toHaveBeenCalledWith(containing({ keySystemConfiguration: containing({ keySystem: 'drm.def', distinctiveIdentifier: 'optional', persistentState: 'required', sessionTypes: ['persistent-license'], initDataType: 'cenc', }), }), ); }); it('honors distinctive identifier and persistent state', async () => { setDecodingInfoSpy([]); tweakDrmInfos((drmInfos) => { drmInfos[0].distinctiveIdentifierRequired = true; drmInfos[1].persistentStateRequired = true; }); const variants = manifest.variants; await expectAsync( drmEngine.initForPlayback(variants, manifest.offlineSessionIds)) .toBeRejected(); expect(drmEngine.initialized()).toBe(false); expect(decodingInfoSpy).toHaveBeenCalledTimes(2); expect(decodingInfoSpy).toHaveBeenCalledWith(containing({ keySystemConfiguration: containing({ keySystem: 'drm.abc', distinctiveIdentifier: 'required', persistentState: 'optional', sessionTypes: ['temporary'], }), }), ); expect(decodingInfoSpy).toHaveBeenCalledWith(containing({ keySystemConfiguration: containing({ keySystem: 'drm.def', distinctiveIdentifier: 'optional', persistentState: 'required', sessionTypes: ['temporary'], }), }), ); }); it('makes no queries for key systems with clear content if no key config', async () => { setDecodingInfoSpy([]); manifest.variants[0].video.drmInfos = []; manifest.variants[0].audio.drmInfos = []; config.servers = {}; config.advanced = {}; drmEngine.configure(config); const variants = manifest.variants; await drmEngine.initForPlayback(variants, manifest.offlineSessionIds); // Gets decodingInfo for clear content with no keySystemAccess. expect(variants[0].decodingInfos.length).toBe(1); expect(variants[0].decodingInfos[0].keySystemAccess).toBeFalsy(); expect( shaka.media.DrmEngine.keySystem(drmEngine.getDrmInfo())).toBe(''); expect(drmEngine.initialized()).toBe(true); }); it('makes queries for clear content if key is configured', async () => { setDecodingInfoSpy(['drm.abc']); manifest.variants[0].video.drmInfos = []; manifest.variants[0].audio.drmInfos = []; config.servers = { 'drm.abc': 'http://abc.drm/license', }; drmEngine.configure(config); const variants = manifest.variants; await drmEngine.initForPlayback(variants, manifest.offlineSessionIds); expect(drmEngine.initialized()).toBe(true); expect(shaka.media.DrmEngine.keySystem(drmEngine.getDrmInfo())) .toBe('drm.abc'); expect(variants[0].decodingInfos.length).toBe(1); }); it('uses advanced config to fill in DrmInfo', async () => { // Leave only one drmInfo manifest = shaka.test.ManifestGenerator.generate((manifest) => { manifest.addVariant(0, (variant) => { variant.addVideo(1, (stream) => { stream.encrypted = true; stream.addDrmInfo('drm.abc'); }); variant.addAudio(2, (stream) => { stream.encrypted = true; stream.addDrmInfo('drm.abc'); }); }); }); setDecodingInfoSpy([]); config.advanced['drm.abc'] = { audioRobustness: 'good', videoRobustness: 'really_really_ridiculously_good', serverCertificate: null, serverCertificateUri: '', sessionType: 'persistent-license', individualizationServer: '', distinctiveIdentifierRequired: true, persistentStateRequired: true, }; drmEngine.configure(config); const variants = manifest.variants; await expectAsync( drmEngine.initForPlayback(variants, manifest.offlineSessionIds)) .toBeRejected(); expect(drmEngine.initialized()).toBe(false); expect(decodingInfoSpy).toHaveBeenCalledTimes(1); expect(decodingInfoSpy).toHaveBeenCalledWith(containing({ keySystemConfiguration: containing({ keySystem: 'drm.abc', distinctiveIdentifier: 'required', persistentState: 'required', sessionTypes: ['persistent-license'], initDataType: 'cenc', audio: containing({ robustness: 'good', }), video: containing({ robustness: 'really_really_ridiculously_good', }), }), })); }); it('prefers advanced config from manifest if present', async () => { // Leave only one drmInfo manifest = shaka.test.ManifestGenerator.generate((manifest) => { manifest.addVariant(0, (variant) => { variant.addVideo(1, (stream) => { stream.encrypted = true; stream.addDrmInfo('drm.abc'); }); variant.addAudio(2, (stream) => { stream.encrypted = true; stream.addDrmInfo('drm.abc'); }); }); }); setDecodingInfoSpy([]); // DrmInfo directly sets advanced settings. tweakDrmInfos((drmInfos) => { drmInfos[0].distinctiveIdentifierRequired = true; drmInfos[0].persistentStateRequired = true; drmInfos[0].audioRobustness = 'good'; drmInfos[0].videoRobustness = 'really_really_ridiculously_good'; }); config.advanced['drm.abc'] = { audioRobustness: 'bad', videoRobustness: 'so_bad_it_hurts', serverCertificate: null, serverCertificateUri: '', sessionType: '', individualizationServer: '', distinctiveIdentifierRequired: false, persistentStateRequired: false, }; drmEngine.configure(config); const variants = manifest.variants; await expectAsync( drmEngine.initForPlayback(variants, manifest.offlineSessionIds)) .toBeRejected(); expect(drmEngine.initialized()).toBe(false); expect(decodingInfoSpy).toHaveBeenCalledTimes(1); expect(decodingInfoSpy).toHaveBeenCalledWith(containing({ keySystemConfiguration: containing({ keySystem: 'drm.abc', audio: containing({ robustness: 'good', }), video: containing({ robustness: 'really_really_ridiculously_good', }), distinctiveIdentifier: 'required', persistentState: 'required', initDataType: 'cenc', }), })); }); it('sets unique initDataTypes if specified from the initData', async () => { tweakDrmInfos((drmInfos) => { drmInfos[0].initData = [ {initDataType: 'very_nice', initData: new Uint8Array(5), keyId: null}, {initDataType: 'very_nice', initData: new Uint8Array(5), keyId: null}, ]; }); drmEngine.configure(config); const variants = manifest.variants; await drmEngine.initForPlayback(variants, manifest.offlineSessionIds); expect(drmEngine.initialized()).toBe(true); expect(decodingInfoSpy).toHaveBeenCalledTimes(2); expect(decodingInfoSpy).toHaveBeenCalledWith(containing({ keySystemConfiguration: containing({ keySystem: 'drm.abc', initDataType: 'very_nice', }), }), ); }); it('fails if license server is not configured', async () => { setDecodingInfoSpy(['drm.abc']); config.servers = {}; drmEngine.configure(config); const expected = Util.jasmineError(new shaka.util.Error( shaka.util.Error.Severity.CRITICAL, shaka.util.Error.Category.DRM, shaka.util.Error.Code.NO_LICENSE_SERVER_GIVEN, 'drm.abc')); const variants = manifest.variants; await expectAsync( drmEngine.initForPlayback(variants, manifest.offlineSessionIds)) .toBeRejectedWith(expected); }); it('maps TS MIME types through the transmuxer', async () => { const originalIsSupported = shaka.media.Transmuxer.isSupported; try { // Mock out isSupported on Transmuxer so that we don't have to care // about what MediaSource supports under that. All we really care about // is the translation of MIME types. shaka.media.Transmuxer.isSupported = (mimeType, contentType) => { return mimeType.startsWith('video/mp2t'); }; // The default mock for this is so unrealistic, some of our test // conditions would always fail. Make it realistic enough for this // test case by returning the same types we are supposed to be querying // for. That way, supportsVariant() should work produce the correct // result after translating the types of the variant's streams. mockMediaKeySystemAccess.getConfiguration.and.callFake(() => { return { audioCapabilities: [{contentType: 'audio/mp4; codecs="abar"'}], videoCapabilities: [{contentType: 'video/mp4; codecs="vbar"'}], }; }); setDecodingInfoSpy(['drm.abc']); const variants = manifest.variants; variants[0].video.mimeType = 'video/mp2t'; variants[0].audio.mimeType = 'video/mp2t'; await drmEngine.initForPlayback(variants, manifest.offlineSessionIds); expect(drmEngine.initialized()).toBe(true); const decodingConfig = containing({ video: containing({ contentType: 'video/mp4; codecs="vbar"', }), audio: containing({ contentType: 'audio/mp4; codecs="abar"', }), }); expect(decodingInfoSpy).toHaveBeenCalledTimes(2); expect(decodingInfoSpy).toHaveBeenCalledWith(decodingConfig); expect(drmEngine.supportsVariant(variants[0])).toBeTruthy(); } finally { // Restore the mock. shaka.media.Transmuxer.isSupported = originalIsSupported; } }); }); // describe('init') describe('attach', () => { beforeEach(() => { // Both audio and video with the same key system: manifest = shaka.test.ManifestGenerator.generate((manifest) => { manifest.addVariant(0, (variant) => { variant.addVideo(1, (stream) => { stream.encrypted = true; stream.addDrmInfo('drm.abc'); }); variant.addAudio(2, (stream) => { stream.encrypted = true; stream.addDrmInfo('drm.abc'); }); }); }); }); it('does nothing for unencrypted content', async () => { setDecodingInfoSpy([]); manifest.variants[0].video.drmInfos = []; manifest.variants[0].audio.drmInfos = []; config.servers = {}; config.advanced = {}; await initAndAttach(); expect(mockVideo.setMediaKeys).not.toHaveBeenCalled(); }); it('sets MediaKeys for encrypted content', async () => { await initAndAttach(); expect(mockVideo.setMediaKeys).toHaveBeenCalledWith(mockMediaKeys); }); it('sets server certificate if present in config', async () => { const cert = new Uint8Array(1); config.advanced['drm.abc'] = createAdvancedConfig(cert); config.advanced['drm.abc'].serverCertificateUri = 'https://drm-service.com/certificate'; drmEngine.configure(config); const variants = manifest.variants; await drmEngine.initForPlayback(variants, manifest.offlineSessionIds); expect(fakeNetEngine.request).not.toHaveBeenCalled(); // Should be set merely after init, without waiting for attach. expect(mockMediaKeys.setServerCertificate).toHaveBeenCalledWith(cert); }); it('fetches and sets server certificate from uri', async () => { const cert = new Uint8Array(0); const serverCertificateUri = 'https://drm-service.com/certificate'; config.advanced['drm.abc'] = createAdvancedConfig(cert); config.advanced['drm.abc'].serverCertificateUri = serverCertificateUri; fakeNetEngine.setResponseValue( serverCertificateUri, shaka.util.BufferUtils.toArrayBuffer(new Uint8Array(1))); drmEngine.configure(config); const variants = manifest.variants; await drmEngine.initForPlayback(variants, manifest.offlineSessionIds); fakeNetEngine.expectRequest( serverCertificateUri, shaka.net.NetworkingEngine.RequestType.SERVER_CERTIFICATE); // Should be set merely after init, without waiting for attach. expect(mockMediaKeys.setServerCertificate).toHaveBeenCalledWith( new Uint8Array(1)); }); it('fetches server certificate from uri and triggers error', async () => { const cert = new Uint8Array(0); const serverCertificateUri = 'https://drm-service.com/certificate'; config.advanced['drm.abc'] = createAdvancedConfig(cert); config.advanced['drm.abc'].serverCertificateUri = serverCertificateUri; // Simulate a permission error from the web server. const netError = new shaka.util.Error( shaka.util.Error.Severity.CRITICAL, shaka.util.Error.Category.NETWORK, shaka.util.Error.Code.BAD_HTTP_STATUS, serverCertificateUri, 403); const operation = shaka.util.AbortableOperation.failed(netError); fakeNetEngine.request.and.returnValue(operation); drmEngine.configure(config); const expected = Util.jasmineError(new shaka.util.Error( shaka.util.Error.Severity.CRITICAL, shaka.util.Error.Category.DRM, shaka.util.Error.Code.SERVER_CERTIFICATE_REQUEST_FAILED, netError)); await expectAsync(initAndAttach()).toBeRejectedWith(expected); fakeNetEngine.expectRequest( serverCertificateUri, shaka.net.NetworkingEngine.RequestType.SERVER_CERTIFICATE); // Should be set merely after init, without waiting for attach. expect(mockMediaKeys.setServerCertificate).not.toHaveBeenCalled(); }); it('prefers server certificate from DrmInfo', async () => { const cert1 = new Uint8Array(5); const cert2 = new Uint8Array(1); tweakDrmInfos((drmInfos) => { drmInfos[0].serverCertificate = cert1; }); config.advanced['drm.abc'] = createAdvancedConfig(cert2); drmEngine.configure(config); await initAndAttach(); expect(mockMediaKeys.setServerCertificate).toHaveBeenCalledWith(cert1); }); it('does not set server certificate if absent', async () => { await initAndAttach(); expect(mockMediaKeys.setServerCertificate).not.toHaveBeenCalled(); }); it('creates sessions for init data overrides', async () => { // Set up init data overrides in the manifest: /** @type {!Uint8Array} */ const initData1 = new Uint8Array(5); /** @type {!Uint8Array} */ const initData2 = new Uint8Array(0); /** @type {!Uint8Array} */ const initData3 = new Uint8Array(10); tweakDrmInfos((drmInfos) => { drmInfos[0].initData = [ {initData: initData1, initDataType: 'cenc', keyId: null}, {initData: initData2, initDataType: 'webm', keyId: null}, {initData: initData3, initDataType: 'cenc', keyId: null}, ]; }); await initAndAttach(); expect(mockMediaKeys.createSession).toHaveBeenCalledTimes(3); expect(session1.generateRequest) .toHaveBeenCalledWith('cenc', initData1); expect(session2.generateRequest) .toHaveBeenCalledWith('webm', initData2); expect(session3.generateRequest) .toHaveBeenCalledWith('cenc', initData3); }); it('ignores duplicate init data overrides', async () => { // Set up init data overrides in the manifest; // The second initData has a different keyId from the first, // but the same initData. // The third initData has a different initData from the first, // but the same keyId. // Both should be discarded as duplicates. /** @type {!Uint8Array} */ const initData1 = new Uint8Array(1); const initData2 = new Uint8Array(1); const initData3 = new Uint8Array(10); tweakDrmInfos((drmInfos) => { drmInfos[0].initData = [ {initData: initData1, initDataType: 'cenc', keyId: 'abc'}, {initData: initData2, initDataType: 'cenc', keyId: 'def'}, {initData: initData3, initDataType: 'cenc', keyId: 'abc'}, ]; }); await initAndAttach(); expect(mockMediaKeys.createSession).toHaveBeenCalledTimes(1); expect(session1.generateRequest) .toHaveBeenCalledWith('cenc', initData1); }); // https://github.com/shaka-project/shaka-player/issues/2754 it('ignores duplicate init data from newInitData', async () => { /** @type {!Uint8Array} */ const initData = new Uint8Array(1); tweakDrmInfos((drmInfos) => { drmInfos[0].initData = [{initData: initData, initDataType: 'cenc', keyId: 'abc'}]; }); await drmEngine.initForPlayback( manifest.variants, manifest.offlineSessionIds); drmEngine.newInitData('cenc', initData); await drmEngine.attach(mockVideo); expect(mockMediaKeys.createSession).toHaveBeenCalledTimes(1); expect(session1.generateRequest).toHaveBeenCalledWith('cenc', initData); }); it('uses clearKeys config to override DrmInfo', async () => { tweakDrmInfos((drmInfos) => { drmInfos[0].keySystem = 'com.fake.NOT.clearkey'; }); setDecodingInfoSpy(['org.w3.clearkey']); // Configure clear keys (map of hex key IDs to keys) config.clearKeys = { 'deadbeefdeadbeefdeadbeefdeadbeef': '18675309186753091867530918675309', '02030507011013017019023029031037': '03050701302303204201080425098033', }; drmEngine.configure(config); const session = createMockSession(); mockMediaKeys.createSession.and.callFake(() => { expect(mockMediaKeys.createSession).toHaveBeenCalledTimes(1); return session; }); await initAndAttach(); const Uint8ArrayUtils = shaka.util.Uint8ArrayUtils; tweakDrmInfos((drmInfos) => { expect(drmInfos.length).toBe(1); expect(drmInfos[0].keySystem).toBe('org.w3.clearkey'); }); expect(session.generateRequest) .toHaveBeenCalledWith('keyids', jasmine.any(Uint8Array)); const initData = /** @type {{kids: !Array.<string>}} */(JSON.parse( shaka.util.StringUtils.fromUTF8( session.generateRequest.calls.argsFor(0)[1]))); const keyId1 = Uint8ArrayUtils.toHex( Uint8ArrayUtils.fromBase64(initData.kids[0])); const keyId2 = Uint8ArrayUtils.toHex( Uint8ArrayUtils.fromBase64(initData.kids[1])); expect(keyId1).toBe('deadbeefdeadbeefdeadbeefdeadbeef'); expect(keyId2).toBe('02030507011013017019023029031037'); }); // Regression test for #2139, in which we suppressed errors if drmInfos was // empty and clearKeys config was given it('fails if clearKeys config fails', async () => { manifest.variants[0].video.drmInfos = []; manifest.variants[0].audio.drmInfos = []; // Make it so that clear key setup fails by pretending we don't have it. // In reality, it was failing because of missing codec info, but any // failure should do for testing purposes. setDecodingInfoSpy([]); // Configure clear keys (map of hex key IDs to keys) config.clearKeys = { 'deadbeefdeadbeefdeadbeefdeadbeef': '18675309186753091867530918675309', '02030507011013017019023029031037': '03050701302303204201080425098033', }; drmEngine.configure(config); const variants = manifest.variants; const expected = Util.jasmineError(new shaka.util.Error( shaka.util.Error.Severity.CRITICAL, shaka.util.Error.Category.DRM, shaka.util.Error.Code.REQUESTED_KEY_SYSTEM_CONFIG_UNAVAILABLE)); await expectAsync(drmEngine.initForPlayback(variants, [])) .toBeRejectedWith(expected); }); it('fails with an error if setMediaKeys fails', async () => { // Fail setMediaKeys. mockVideo.setMediaKeys.and.returnValue(Promise.reject( new Error('whoops!'))); const expected = Util.jasmineError(new shaka.util.Error( shaka.util.Error.Severity.CRITICAL, shaka.util.Error.Category.DRM, shaka.util.Error.Code.FAILED_TO_ATTACH_TO_VIDEO, 'whoops!')); await expectAsync(initAndAttach()).toBeRejectedWith(expected); }); it('fails with an error if setServerCertificate fails', async () => { const cert = new Uint8Array(1); config.advanced['drm.abc'] = createAdvancedConfig(cert); drmEngine.configure(config); // Fail setServerCertificate. mockMediaKeys.setServerCertificate.and.returnValue(Promise.reject( new Error('whoops!'))); const expected = Util.jasmineError(new shaka.util.Error( shaka.util.Error.Severity.CRITICAL, shaka.util.Error.Category.DRM, shaka.util.Error.Code.INVALID_SERVER_CERTIFICATE, 'whoops!')); await expectAsync(initAndAttach()).toBeRejectedWith(expected); }); it('dispatches an error if generateRequest fails', async () => { // Set up an init data override in the manifest to get an immediate call // to generateRequest: const initData1 = new Uint8Array(5); tweakDrmInfos((drmInfos) => { drmInfos[0].initData = [ {initData: initData1, initDataType: 'cenc', keyId: null}, ]; }); // Fail generateRequest. const session1 = createMockSession(); const message = 'whoops!'; const nativeError = new Error(message); session1.generateRequest.and.returnValue(Promise.reject(nativeError)); mockMediaKeys.createSession.and.returnValue(session1); onErrorSpy.and.stub(); await initAndAttach(); expect(onErrorSpy).toHaveBeenCalled(); const error = onErrorSpy.calls.argsFor(0)[0]; shaka.test.Util.expectToEqualError(error, new shaka.util.Error( shaka.util.Error.Severity.CRITICAL, shaka.util.Error.Category.DRM, shaka.util.Error.Code.FAILED_TO_GENERATE_LICENSE_REQUEST, message, nativeError, undefined)); }); }); // describe('attach') describe('events', () => { describe('encrypted', () => { it('is listened for', async () => { await initAndAttach(); expect(mockVideo.addEventListener).toHaveBeenCalledWith( 'encrypted', jasmine.any(Function), jasmine.anything()); }); it('triggers the creation of a session', async () => { await initAndAttach(); const initData1 = new Uint8Array(1); const initData2 = new Uint8Array(2); mockVideo.on['encrypted']( {initDataType: 'webm', initData: initData1, keyId: null}); mockVideo.on['encrypted']( {initDataType: 'cenc', initData: initData2, keyId: null}); expect(mockMediaKeys.createSession).toHaveBeenCalledTimes(2); expect(session1.generateRequest) .toHaveBeenCalledWith('webm', initData1); expect(session2.generateRequest) .toHaveBeenCalledWith('cenc', initData2); }); it('suppresses duplicate initDatas', async () => { await initAndAttach(); const initData1 = new Uint8Array(1); const initData2 = new Uint8Array(1); // identical to initData1 mockVideo.on['encrypted']( {initDataType: 'webm', initData: initData1, keyId: null}); mockVideo.on['encrypted']( {initDataType: 'cenc', initData: initData2, keyId: null}); expect(mockMediaKeys.createSession).toHaveBeenCalledTimes(1); expect(session1.generateRequest) .toHaveBeenCalledWith('webm', initData1); }); it('is ignored when init data is in DrmInfo', async () => { // Set up an init data override in the manifest: tweakDrmInfos((drmInfos) => { drmInfos[0].initData = [ {initData: new Uint8Array(0), initDataType: 'cenc', keyId: null}, ]; }); await initAndAttach(); // We already created a session for the init data override. expect(mockMediaKeys.createSession).toHaveBeenCalledTimes(1); // We aren't even listening for 'encrypted' events. expect(mockVideo.on['encrypted']).toBe(undefined); }); it('dispatches an error if createSession fails', async () => { mockMediaKeys.createSession.and.throwError('whoops!'); onErrorSpy.and.stub(); await initAndAttach(); const initData1 = new Uint8Array(1); mockVideo.on['encrypted']( {initDataType: 'webm', initData: initData1, keyId: null}); expect(onErrorSpy).toHaveBeenCalled(); const error = onErrorSpy.calls.argsFor(0)[0]; shaka.test.Util.expectToEqualError(error, new shaka.util.Error( shaka.util.Error.Severity.CRITICAL, shaka.util.Error.Category.DRM, shaka.util.Error.Code.FAILED_TO_CREATE_SESSION, 'whoops!')); }); it('dispatches an error if manifest says unencrypted', async () => { manifest.variants[0].video.drmInfos = []; manifest.variants[0].audio.drmInfos = []; config.servers = {}; config.advanced = {}; onErrorSpy.and.stub(); await initAndAttach(); const initData1 = new Uint8Array(1); mockVideo.on['encrypted']( {initDataType: 'webm', initData: initData1, keyId: null}); expect(onErrorSpy).toHaveBeenCalled(); const error = onErrorSpy.calls.argsFor(0)[0]; shaka.test.Util.expectToEqualError(error, new shaka.util.Error( shaka.util.Error.Severity.CRITICAL, shaka.util.Error.Category.DRM, shaka.util.Error.Code.ENCRYPTED_CONTENT_WITHOUT_DRM_INFO)); }); }); // describe('encrypted') describe('message', () => { it('is listened for', async () => { await initAndAttach(); const initData = new Uint8Array(0); mockVideo.on['encrypted']( {initDataType: 'webm', initData: initData, keyId: null}); expect(session1.addEventListener).toHaveBeenCalledWith( 'message', jasmine.any(Function), jasmine.anything()); }); it('triggers a license request', async () => { await sendMessageTest('http://abc.drm/license'); }); it('prefers a license server URI from configuration', async () => { tweakDrmInfos((drmInfos) => { drmInfos[0].licenseServerUri = 'http://foo.bar/drm'; }); await sendMessageTest('http://abc.drm/license'); }); it('handles "individualization-request" messages special', async () => { config.advanced['drm.abc'] = createAdvancedConfig(null); config.advanced['drm.abc'].individualizationServer = 'http://foo.bar/drm'; expect(config.servers['drm.abc']).not.toBe('http://foo.bar/drm'); await sendMessageTest( 'http://foo.bar/drm', 'individualization-request'); }); it('uses license server for "individualization-request" by default', async () => { config.advanced['drm.abc'] = createAdvancedConfig(null); config.advanced['drm.abc'].individualizationServer = ''; await sendMessageTest( 'http://abc.drm/license', 'individualization-request'); }); it('dispatches an error if license request fails', async () => { onErrorSpy.and.stub(); await initAndAttach(); const initData = new Uint8Array(0); mockVideo.on['encrypted']( {initDataType: 'webm', initData: initData, keyId: null}); // Simulate a permission error from the web server. const netError = new shaka.util.Error( shaka.util.Error.Severity.CRITICAL, shaka.util.Error.Category.NETWORK, shaka.util.Error.Code.BAD_HTTP_STATUS, 'http://abc.drm/license', 403); const operation = shaka.util.AbortableOperation.failed(netError); fakeNetEngine.request.and.returnValue(operation); const message = new Uint8Array(0); session1.on['message']({target: session1, message: message}); await shaka.test.Util.shortDelay(); expect(onErrorSpy).toHaveBeenCalled(); const error = onErrorSpy.calls.argsFor(0)[0]; shaka.test.Util.expectToEqualError(error, new shaka.util.Error( shaka.util.Error.Severity.CRITICAL, shaka.util.Error.Category.DRM, shaka.util.Error.Code.LICENSE_REQUEST_FAILED, jasmine.objectContaining({ category: shaka.util.Error.Category.NETWORK, code: shaka.util.Error.Code.BAD_HTTP_STATUS, data: ['http://abc.drm/license', 403], }))); }); /** * @param {string=} expectedUrl * @param {string=} messageType * @return {!Promise} */ async function sendMessageTest( expectedUrl, messageType = 'license-request') { await initAndAttach(); const initData = new Uint8Array(0); mockVideo.on['encrypted']( {initDataType: 'webm', initData: initData, keyId: null}); const operation = shaka.util.AbortableOperation.completed({}); fakeNetEngine.request.and.returnValue(operation); const message = new Uint8Array(0); session1.on['message']( {target: session1, message: message, messageType: messageType}); expect(fakeNetEngine.request).toHaveBeenCalledWith( shaka.net.NetworkingEngine.RequestType.LICENSE, jasmine.objectContaining({ uris: [expectedUrl], method: 'POST', body: message, licenseRequestType: messageType, })); } }); // describe('message') describe('keystatuseschange', () => { it('is listened for', async () => { await initAndAttach(); const initData = new Uint8Array(0); mockVideo.on['encrypted']( {initDataType: 'webm', initData: initData, keyId: null}); expect(session1.addEventListener).toHaveBeenCalledWith( 'keystatuseschange', jasmine.any(Function), jasmine.anything()); }); it('triggers callback', async () => { await initAndAttach(); const initData = new Uint8Array(0); mockVideo.on['encrypted']( {initDataType: 'webm', initData: initData, keyId: null}); const keyId1 = makeKeyId(1); const keyId2 = makeKeyId(2); const status1 = 'usable'; const status2 = 'expired'; session1.keyStatuses.forEach.and.callFake((callback) => { callback(keyId1, status1); callback(keyId2, status2); }); onKeyStatusSpy.and.callFake((statusMap) => { expect(statusMap).toEqual({ '01': status1, '02': status2, }); }); session1.on['keystatuseschange']({target: session1}); await Util.shortDelay(); expect(onKeyStatusSpy).toHaveBeenCalled(); }); // See https://github.com/shaka-project/shaka-player/issues/1541 it('does not update public key statuses before callback', async () => { await initAndAttach(); const initData = new Uint8Array(0); mockVideo.on['encrypted']( {initDataType: 'webm', initData: initData, keyId: null}); const keyId1 = makeKeyId(1); const keyId2 = makeKeyId(2); const status1 = 'usable'; const status2 = 'expired'; session1.keyStatuses.forEach.and.callFake((callback) => { callback(keyId1, status1); callback(keyId2, status2); }); session1.on['keystatuseschange']({target: session1}); // The callback waits for some time to pass, to batch up status changes. expect(onKeyStatusSpy).not.toHaveBeenCalled(); // The publicly-accessible key statuses should not show these new // changes yet. This shows that we have solved the race between the // callback and any polling done by any other component. let keyIds = Object.keys(drmEngine.getKeyStatuses()); expect(keyIds.length).toBe(0); // Wait for the callback to occur, then end the test. await new Promise((resolve) => { onKeyStatusSpy.and.callFake(resolve); }); // Now key statuses are available. keyIds = Object.keys(drmEngine.getKeyStatuses()); expect(keyIds.length).toBe(2); }); // See https://github.com/shaka-project/shaka-player/issues/1541 it('does not invoke callback until all sessions are loaded', async () => { // Set up init data overrides in the manifest so that we get multiple // sessions. const initData1 = new Uint8Array(10); const initData2 = new Uint8Array(11); tweakDrmInfos((drmInfos) => { drmInfos[0].initData = [ {initData: initData1, initDataType: 'cenc', keyId: null}, {initData: initData2, initDataType: 'cenc', keyId: null}, ]; }); const keyId1 = makeKeyId(1); const keyId2 = makeKeyId(2); session1.keyStatuses.forEach.and.callFake((callback) => { callback(keyId1, 'usable'); }); session2.keyStatuses.forEach.and.callFake((callback) => { callback(keyId2, 'usable'); }); await initAndAttach(); // The callback waits for some time to pass, to batch up status changes. // But even after some time has passed, we should not have invoked the // callback, because we don't have a status for session2 yet. session1.on['keystatuseschange']({target: session1}); await shaka.test.Util.shortDelay(); expect(onKeyStatusSpy).not.toHaveBeenCalled(); // After both sessions have been loaded, we will finally invoke the // callback. session2.on['keystatuses