mirror of
https://github.com/video-dev/hls.js.git
synced 2026-06-16 13:34:44 +00:00
1036 lines
32 KiB
TypeScript
1036 lines
32 KiB
TypeScript
import { expect, use } from 'chai';
|
|
import { EventEmitter } from 'eventemitter3';
|
|
import { fakeXhr } from 'nise';
|
|
import sinon from 'sinon';
|
|
import sinonChai from 'sinon-chai';
|
|
import EMEController from '../../../src/controller/eme-controller';
|
|
import { ErrorDetails } from '../../../src/errors';
|
|
import { Events } from '../../../src/events';
|
|
import { Fragment } from '../../../src/loader/fragment';
|
|
import { LevelKey } from '../../../src/loader/level-key';
|
|
import { PlaylistLevelType } from '../../../src/types/loader';
|
|
import {
|
|
KeySystemFormats,
|
|
KeySystems,
|
|
} from '../../../src/utils/mediakeys-helper';
|
|
import HlsMock from '../../mocks/hls.mock';
|
|
import type { MediaKeySessionContext } from '../../../src/controller/eme-controller';
|
|
import type { EncryptedFragment } from '../../../src/loader/fragment';
|
|
import type { MediaAttachedData } from '../../../src/types/events';
|
|
|
|
use(sinonChai);
|
|
|
|
type EMEControllerTestable = Omit<EMEController, 'hls' | 'mediaKeySessions'> & {
|
|
hls: HlsMock;
|
|
mediaKeySessions: MediaKeySessionContext[];
|
|
activeKeys: {
|
|
main?: EncryptedFragment;
|
|
audio?: EncryptedFragment;
|
|
previous?: EncryptedFragment[];
|
|
};
|
|
onMediaAttached: (
|
|
event: Events.MEDIA_ATTACHED,
|
|
data: MediaAttachedData,
|
|
) => void;
|
|
onMediaDetached: () => void;
|
|
onManifestLoaded: (
|
|
event: Events.MANIFEST_LOADED,
|
|
data: { sessionKeys: LevelKey[] },
|
|
) => void;
|
|
media: HTMLMediaElement | null;
|
|
keyFormatPromise: Promise<KeySystemFormats> | null;
|
|
getKeyStatuses: (mediaKeySessionContext: MediaKeySessionContext) => {
|
|
[keyId: string]: MediaKeyStatus;
|
|
};
|
|
};
|
|
|
|
class MediaMock extends EventEmitter {
|
|
setMediaKeys: sinon.SinonSpy<[mediaKeys: MediaKeys | null], Promise<void>>;
|
|
addEventListener: any;
|
|
removeEventListener: any;
|
|
constructor() {
|
|
super();
|
|
this.setMediaKeys = sinon.spy((mediaKeys: MediaKeys | null) =>
|
|
Promise.resolve(),
|
|
);
|
|
this.addEventListener = this.addListener.bind(this);
|
|
this.removeEventListener = this.removeListener.bind(this);
|
|
}
|
|
}
|
|
|
|
class MediaKeysMock implements MediaKeys {
|
|
createSession(/* sessionType?: MediaKeySessionType */) {
|
|
return new MediaKeySessionMock();
|
|
}
|
|
getStatusForPolicy(/* policy?: MediaKeysPolicy */) {
|
|
// "output-restricted" | "released" | "status-pending" | "usable" | "usable-in-future"
|
|
return Promise.resolve('usable' as MediaKeyStatus);
|
|
}
|
|
setServerCertificate(/* serverCertificate: BufferSource */) {
|
|
return Promise.resolve(true);
|
|
}
|
|
}
|
|
|
|
class MediaKeySessionMock extends EventEmitter implements MediaKeySession {
|
|
addEventListener: any;
|
|
removeEventListener: any;
|
|
private _resolveClose: (reason: MediaKeySessionClosedReason) => void =
|
|
() => {};
|
|
protected _keyStatuses: Map<Uint8Array, string>;
|
|
readonly closed: Promise<MediaKeySessionClosedReason>;
|
|
readonly keyStatuses: MediaKeyStatusMap;
|
|
readonly expiration: number;
|
|
readonly onkeystatuseschange = null; // use add/removeEventListener
|
|
readonly onmessage = null; // use add/removeEventListener
|
|
readonly sessionId: string;
|
|
|
|
constructor() {
|
|
super();
|
|
this.addEventListener = this.addListener.bind(this);
|
|
this.removeEventListener = this.removeListener.bind(this);
|
|
this.closed = new Promise((resolve) => {
|
|
this._resolveClose = resolve;
|
|
});
|
|
this.expiration = NaN;
|
|
this.sessionId = '';
|
|
|
|
const keyStatuses = (this._keyStatuses = new Map());
|
|
this.keyStatuses = {
|
|
get size() {
|
|
return keyStatuses.size;
|
|
},
|
|
get(keyId) {
|
|
return keyStatuses.get(keyId);
|
|
},
|
|
has(keyId) {
|
|
return keyStatuses.has(keyId);
|
|
},
|
|
forEach(callbackfn, thisArg?) {
|
|
return keyStatuses.forEach(callbackfn, thisArg);
|
|
},
|
|
} as any as MediaKeyStatusMap; // missing entries, keys, and values of Iterable
|
|
}
|
|
dispatchEvent() {
|
|
return true;
|
|
}
|
|
close() {
|
|
this._resolveClose('release-acknoledged' as MediaKeySessionClosedReason);
|
|
return this.closed.then(() => {});
|
|
}
|
|
generateRequest() {
|
|
return Promise.resolve().then(() => {
|
|
this.emit('message', {
|
|
messageType: 'license-request',
|
|
message: new Uint8Array(0),
|
|
});
|
|
this._keyStatuses.set(new Uint8Array(16), 'usable');
|
|
this.emit('keystatuseschange', {});
|
|
});
|
|
}
|
|
load(sessionId: string) {
|
|
return Promise.reject(new Error('not supported'));
|
|
}
|
|
remove() {
|
|
return Promise.resolve();
|
|
}
|
|
update() {
|
|
return Promise.resolve();
|
|
}
|
|
}
|
|
|
|
let emeController: EMEControllerTestable;
|
|
let media: MediaMock;
|
|
let sinonFakeXMLHttpRequestStatic: sinon.SinonFakeXMLHttpRequestStatic;
|
|
|
|
const setupEach = function (config) {
|
|
const hls = new HlsMock(config);
|
|
hls.levelController = {
|
|
levels: [
|
|
{
|
|
audioCodec: 'audio/foo',
|
|
},
|
|
{
|
|
videoCodec: 'video/foo',
|
|
},
|
|
],
|
|
};
|
|
media = new MediaMock();
|
|
emeController = new EMEController(hls as any) as any as EMEControllerTestable;
|
|
sinonFakeXMLHttpRequestStatic = fakeXhr.useFakeXMLHttpRequest();
|
|
};
|
|
|
|
const getParsedLevelKey = (
|
|
uri: string = 'data://key-uri',
|
|
format: string = 'com.apple.streamingkeydelivery',
|
|
) => {
|
|
const levelKey = new LevelKey('SAMPLE-AES', uri, format);
|
|
levelKey.keyId = new Uint8Array(16);
|
|
levelKey.pssh = new Uint8Array(16);
|
|
return levelKey;
|
|
};
|
|
|
|
const getEncryptedFrag = (levelKey: LevelKey) => {
|
|
const encryptedFrag = new Fragment(PlaylistLevelType.MAIN, '');
|
|
encryptedFrag.levelkeys = {
|
|
[levelKey.keyFormat]: levelKey,
|
|
};
|
|
encryptedFrag.setKeyFormat(levelKey.keyFormat as KeySystemFormats);
|
|
return encryptedFrag as EncryptedFragment;
|
|
};
|
|
|
|
describe('EMEController', function () {
|
|
beforeEach(function () {
|
|
setupEach({});
|
|
});
|
|
|
|
afterEach(function () {
|
|
sinonFakeXMLHttpRequestStatic.restore();
|
|
});
|
|
|
|
it('should request keysystem access based on key format when `emeEnabled` is true', function () {
|
|
const mediaKeys = new MediaKeysMock();
|
|
const reqMediaKsAccessSpy = sinon.spy(function () {
|
|
return Promise.resolve({
|
|
// Media-keys mock
|
|
keySystem: 'com.apple.fps',
|
|
createMediaKeys: sinon.spy(() => Promise.resolve(mediaKeys)),
|
|
});
|
|
});
|
|
|
|
setupEach({
|
|
emeEnabled: true,
|
|
drmSystems: {
|
|
'com.apple.fps': {
|
|
licenseUrl: 'http://noop',
|
|
},
|
|
},
|
|
requestMediaKeySystemAccessFunc: reqMediaKsAccessSpy,
|
|
});
|
|
|
|
sinonFakeXMLHttpRequestStatic.onCreate = (
|
|
xhr: sinon.SinonFakeXMLHttpRequest,
|
|
) => {
|
|
self.setTimeout(() => {
|
|
(xhr as any).response = new Uint8Array();
|
|
xhr.respond(200, {}, '');
|
|
}, 0);
|
|
};
|
|
|
|
emeController.onMediaAttached(Events.MEDIA_ATTACHED, {
|
|
media: media as any as HTMLMediaElement,
|
|
});
|
|
|
|
expect(media.setMediaKeys).callCount(0);
|
|
expect(reqMediaKsAccessSpy).callCount(0);
|
|
|
|
const levelKey = getParsedLevelKey();
|
|
const encryptedFrag = getEncryptedFrag(levelKey);
|
|
const emePromise = emeController.loadKey(encryptedFrag);
|
|
|
|
expect(emePromise).to.be.a('Promise');
|
|
return emePromise.finally(() => {
|
|
expect(media.setMediaKeys).callCount(1);
|
|
expect(reqMediaKsAccessSpy).callCount(1);
|
|
});
|
|
});
|
|
|
|
it('should request keys with specified robustness options when `emeEnabled` is true', function () {
|
|
const reqMediaKsAccessSpy = sinon.spy(function () {
|
|
return Promise.resolve({
|
|
// Media-keys mock
|
|
keySystem: 'com.apple.fps',
|
|
createMediaKeys: sinon.spy(() =>
|
|
Promise.resolve({
|
|
setServerCertificate: () => Promise.resolve(),
|
|
createSession: () => new MediaKeySessionMock(),
|
|
}),
|
|
),
|
|
});
|
|
});
|
|
|
|
setupEach({
|
|
emeEnabled: true,
|
|
drmSystems: {
|
|
'com.apple.fps': {
|
|
licenseUrl: 'http://noop',
|
|
},
|
|
},
|
|
drmSystemOptions: {
|
|
audioRobustness: 'HW_SECURE_ALL',
|
|
videoRobustness: 'HW_SECURE_ALL',
|
|
},
|
|
requestMediaKeySystemAccessFunc: reqMediaKsAccessSpy,
|
|
});
|
|
|
|
sinonFakeXMLHttpRequestStatic.onCreate = (
|
|
xhr: sinon.SinonFakeXMLHttpRequest,
|
|
) => {
|
|
self.setTimeout(() => {
|
|
(xhr as any).response = new Uint8Array();
|
|
xhr.respond(200, {}, '');
|
|
}, 0);
|
|
};
|
|
|
|
emeController.onMediaAttached(Events.MEDIA_ATTACHED, {
|
|
media: media as any as HTMLMediaElement,
|
|
});
|
|
|
|
expect(media.setMediaKeys).callCount(0);
|
|
expect(reqMediaKsAccessSpy).callCount(0);
|
|
|
|
const levelKey = getParsedLevelKey();
|
|
const encryptedFrag = getEncryptedFrag(levelKey);
|
|
const emePromise = emeController.loadKey(encryptedFrag);
|
|
|
|
expect(emePromise).to.be.a('Promise');
|
|
return emePromise.finally(() => {
|
|
expect(reqMediaKsAccessSpy).callCount(1);
|
|
const args = reqMediaKsAccessSpy.getCall(0)
|
|
.args as MediaKeySystemConfiguration[][];
|
|
const baseConfig: MediaKeySystemConfiguration = args[1][0];
|
|
expect(baseConfig.audioCapabilities)
|
|
.to.be.an('array')
|
|
.with.property('0')
|
|
.with.property('robustness', 'HW_SECURE_ALL');
|
|
expect(baseConfig.videoCapabilities)
|
|
.to.be.an('array')
|
|
.with.property('0')
|
|
.with.property('robustness', 'HW_SECURE_ALL');
|
|
});
|
|
});
|
|
|
|
it('should ignore "encrypted" events with bad data', function () {
|
|
const reqMediaKsAccessSpy = sinon.spy(function () {
|
|
return Promise.resolve({
|
|
// Media-keys mock
|
|
keySystem: 'com.apple.fps',
|
|
createMediaKeys: sinon.spy(() =>
|
|
Promise.resolve({
|
|
setServerCertificate: () => Promise.resolve(),
|
|
createSession: () => ({
|
|
addEventListener: () => {},
|
|
removeEventListener: () => {},
|
|
generateRequest: () => Promise.reject(new Error('bad data')),
|
|
remove: () => Promise.resolve(),
|
|
update: () => Promise.resolve(),
|
|
keyStatuses: new Map(),
|
|
}),
|
|
}),
|
|
),
|
|
});
|
|
});
|
|
|
|
setupEach({
|
|
emeEnabled: true,
|
|
requestMediaKeySystemAccessFunc: reqMediaKsAccessSpy,
|
|
drmSystems: {
|
|
'com.apple.fps': {
|
|
licenseUrl: '.',
|
|
},
|
|
},
|
|
});
|
|
|
|
const badData = {
|
|
initDataType: 'cenc',
|
|
initData: new Uint8Array([
|
|
// box size
|
|
0, 0, 0, 44,
|
|
// "PSSH"
|
|
112, 115, 115, 104,
|
|
// version
|
|
0, 0, 0, 0,
|
|
// Widevine system id
|
|
237, 239, 139, 169, 121, 214, 74, 206, 163, 200, 39, 220, 213, 29, 33,
|
|
237,
|
|
// data size
|
|
0, 0, 0, 12,
|
|
// data (incomplete key)
|
|
0, 0, 0, 0, 0, 0, 0, 0, 240, 0, 186, 0,
|
|
]).buffer,
|
|
};
|
|
|
|
emeController.onMediaAttached(Events.MEDIA_ATTACHED, {
|
|
media: media as any as HTMLMediaElement,
|
|
});
|
|
|
|
media.emit('encrypted', badData);
|
|
|
|
return emeController
|
|
.selectKeySystemFormat({
|
|
levelkeys: {
|
|
[KeySystemFormats.FAIRPLAY]: {},
|
|
[KeySystemFormats.WIDEVINE]: {},
|
|
[KeySystemFormats.PLAYREADY]: {},
|
|
},
|
|
sn: 0,
|
|
type: 'main',
|
|
} as any)
|
|
.then(() => {
|
|
expect(emeController.activeKeys).to.deep.equal(
|
|
{},
|
|
'`activeKeys` should be an empty dictionary when no playlisty-keys are found',
|
|
);
|
|
});
|
|
});
|
|
|
|
it('should exchange keyID and status if keyStatuses forEach callback error', function () {
|
|
class MediaKeySessionMock2 extends MediaKeySessionMock {
|
|
constructor() {
|
|
super();
|
|
this._keyStatuses.set(new Uint8Array(16), 'usable');
|
|
}
|
|
}
|
|
|
|
setupEach({
|
|
emeEnabled: true,
|
|
requestMediaKeySystemAccessFunc: sinon.spy(),
|
|
drmSystems: {
|
|
'com.apple.fps': {
|
|
serverCertificateUrl: 'https://example.com/certificate.cer',
|
|
},
|
|
},
|
|
});
|
|
|
|
const keySession = new MediaKeySessionMock2();
|
|
const levelKey = getParsedLevelKey();
|
|
const mockMediaKeySessionContext: MediaKeySessionContext = {
|
|
keySystem: KeySystems.FAIRPLAY,
|
|
mediaKeys: new MediaKeysMock(),
|
|
mediaKeysSession: keySession,
|
|
keyRequests: {},
|
|
keyStatuses: {},
|
|
createdFor: { levelKey, reason: 'playlist-key' },
|
|
initialized: false,
|
|
};
|
|
|
|
const keyStatuses = emeController.getKeyStatuses(
|
|
mockMediaKeySessionContext,
|
|
);
|
|
expect(keyStatuses)
|
|
.to.have.property('00000000000000000000000000000000')
|
|
.which.equals('usable');
|
|
});
|
|
|
|
it('should fetch the server certificate and set it into the session', function () {
|
|
const mediaKeysSetServerCertificateSpy = sinon.spy(() => Promise.resolve());
|
|
|
|
const reqMediaKsAccessSpy = sinon.spy(function () {
|
|
return Promise.resolve({
|
|
// Media-keys mock
|
|
keySystem: 'com.apple.fps',
|
|
createMediaKeys: sinon.spy(() =>
|
|
Promise.resolve({
|
|
setServerCertificate: mediaKeysSetServerCertificateSpy,
|
|
createSession: () => new MediaKeySessionMock(),
|
|
}),
|
|
),
|
|
});
|
|
});
|
|
|
|
setupEach({
|
|
emeEnabled: true,
|
|
requestMediaKeySystemAccessFunc: reqMediaKsAccessSpy,
|
|
drmSystems: {
|
|
'com.apple.fps': {
|
|
serverCertificateUrl: 'https://example.com/certificate.cer',
|
|
licenseUrl: 'https://example.com/license',
|
|
},
|
|
},
|
|
});
|
|
|
|
sinonFakeXMLHttpRequestStatic.onCreate = (
|
|
xhr: sinon.SinonFakeXMLHttpRequest,
|
|
) => {
|
|
self.setTimeout(() => {
|
|
xhr.respond(200, {}, 'abcdef');
|
|
}, 0);
|
|
};
|
|
|
|
emeController.onMediaAttached(Events.MEDIA_ATTACHED, {
|
|
media: media as any as HTMLMediaElement,
|
|
});
|
|
|
|
const levelKey = getParsedLevelKey();
|
|
const encryptedFrag = getEncryptedFrag(levelKey);
|
|
return emeController.loadKey(encryptedFrag).then(() => {
|
|
expect(mediaKeysSetServerCertificateSpy).to.have.been.calledOnce;
|
|
expect(mediaKeysSetServerCertificateSpy).to.have.been.calledWith(
|
|
sinon.match({ byteLength: 6 }),
|
|
);
|
|
});
|
|
});
|
|
|
|
it('should fetch the server certificate and trigger update failed error', function () {
|
|
const mediaKeysSetServerCertificateSpy = sinon.spy(() =>
|
|
Promise.reject(new Error('Failed')),
|
|
);
|
|
|
|
const reqMediaKsAccessSpy = sinon.spy(function () {
|
|
return Promise.resolve({
|
|
// Media-keys mock
|
|
keySystem: 'com.apple.fps',
|
|
createMediaKeys: sinon.spy(() =>
|
|
Promise.resolve({
|
|
setServerCertificate: mediaKeysSetServerCertificateSpy,
|
|
createSession: () => ({
|
|
addEventListener: () => {},
|
|
removeEventListener: () => {},
|
|
generateRequest: () => Promise.resolve(),
|
|
remove: () => Promise.resolve(),
|
|
update: () => Promise.resolve(),
|
|
keyStatuses: new Map(),
|
|
}),
|
|
}),
|
|
),
|
|
});
|
|
});
|
|
|
|
setupEach({
|
|
emeEnabled: true,
|
|
requestMediaKeySystemAccessFunc: reqMediaKsAccessSpy,
|
|
drmSystems: {
|
|
'com.apple.fps': {
|
|
serverCertificateUrl: 'https://example.com/certificate.cer',
|
|
},
|
|
},
|
|
});
|
|
|
|
let xhrInstance;
|
|
sinonFakeXMLHttpRequestStatic.onCreate = (
|
|
xhr: sinon.SinonFakeXMLHttpRequest,
|
|
) => {
|
|
xhrInstance = xhr;
|
|
self.setTimeout(() => {
|
|
(xhr as any).response = new Uint8Array();
|
|
xhr.respond(200, {}, '');
|
|
}, 0);
|
|
};
|
|
|
|
const levelKey = getParsedLevelKey();
|
|
levelKey.pssh = null;
|
|
const encryptedFrag = getEncryptedFrag(levelKey);
|
|
|
|
emeController.onMediaAttached(Events.MEDIA_ATTACHED, {
|
|
media: media as any as HTMLMediaElement,
|
|
});
|
|
|
|
emeController
|
|
.loadKey(encryptedFrag)
|
|
.catch(() => {})
|
|
.finally(() => {
|
|
expect(mediaKeysSetServerCertificateSpy).to.have.been.calledOnce;
|
|
expect((mediaKeysSetServerCertificateSpy.args[0] as any)[0]).to.equal(
|
|
xhrInstance.response,
|
|
);
|
|
|
|
expect(emeController.hls.trigger).to.have.been.calledOnce;
|
|
expect(emeController.hls.trigger.args[0][1].details).to.equal(
|
|
ErrorDetails.KEY_SYSTEM_SERVER_CERTIFICATE_UPDATE_FAILED,
|
|
);
|
|
});
|
|
});
|
|
|
|
it('should fetch the server certificate and trigger request failed error', function () {
|
|
const reqMediaKsAccessSpy = sinon.spy(function () {
|
|
return Promise.resolve({
|
|
// Media-keys mock
|
|
keySystem: 'com.apple.fps',
|
|
createMediaKeys: sinon.spy(() =>
|
|
Promise.resolve({
|
|
createSession: () => ({
|
|
addEventListener: () => {},
|
|
removeEventListener: () => {},
|
|
generateRequest: () => Promise.resolve(),
|
|
remove: () => Promise.resolve(),
|
|
update: () => Promise.resolve(),
|
|
keyStatuses: new Map(),
|
|
}),
|
|
}),
|
|
),
|
|
});
|
|
});
|
|
|
|
setupEach({
|
|
emeEnabled: true,
|
|
requestMediaKeySystemAccessFunc: reqMediaKsAccessSpy,
|
|
drmSystems: {
|
|
'com.apple.fps': {
|
|
serverCertificateUrl: 'https://example.com/certificate.cer',
|
|
},
|
|
},
|
|
});
|
|
|
|
sinonFakeXMLHttpRequestStatic.onCreate = (
|
|
xhr: sinon.SinonFakeXMLHttpRequest,
|
|
) => {
|
|
self.setTimeout(() => {
|
|
xhr.status = 400;
|
|
xhr.error();
|
|
}, 0);
|
|
};
|
|
|
|
const levelKey = getParsedLevelKey();
|
|
levelKey.pssh = null;
|
|
const encryptedFrag = getEncryptedFrag(levelKey);
|
|
|
|
emeController.onMediaAttached(Events.MEDIA_ATTACHED, {
|
|
media: media as any as HTMLMediaElement,
|
|
});
|
|
emeController
|
|
.loadKey(encryptedFrag)
|
|
.catch(() => {})
|
|
.finally(() => {
|
|
expect(emeController.hls.trigger).to.have.been.calledOnce;
|
|
expect(emeController.hls.trigger.args[0][1].details).to.equal(
|
|
ErrorDetails.KEY_SYSTEM_SERVER_CERTIFICATE_REQUEST_FAILED,
|
|
);
|
|
});
|
|
});
|
|
|
|
it('should reject pending session-key key-system selection with invalid state when destroyed', function () {
|
|
let resolveKeySystemAccess!: (value: MediaKeySystemAccess) => void;
|
|
const keySystemAccessPromise = new Promise<MediaKeySystemAccess>(
|
|
(resolve) => {
|
|
resolveKeySystemAccess = resolve;
|
|
},
|
|
);
|
|
const createMediaKeysSpy = sinon.spy(() =>
|
|
Promise.resolve(new MediaKeysMock()),
|
|
);
|
|
const reqMediaKsAccessSpy = sinon.spy(() => keySystemAccessPromise);
|
|
|
|
setupEach({
|
|
emeEnabled: true,
|
|
requestMediaKeySystemAccessFunc: reqMediaKsAccessSpy,
|
|
drmSystems: {
|
|
'com.apple.fps': {},
|
|
},
|
|
});
|
|
|
|
const levelKey = getParsedLevelKey();
|
|
|
|
emeController.onManifestLoaded(Events.MANIFEST_LOADED, {
|
|
sessionKeys: [levelKey],
|
|
});
|
|
|
|
const keyFormatPromise = emeController.keyFormatPromise;
|
|
if (!keyFormatPromise) {
|
|
throw new Error('Expected pending key-system selection');
|
|
}
|
|
|
|
emeController.destroy();
|
|
const mediaKeySystemAccess: MediaKeySystemAccess = {
|
|
keySystem: KeySystems.FAIRPLAY,
|
|
createMediaKeys: createMediaKeysSpy,
|
|
getConfiguration: () => ({}),
|
|
};
|
|
resolveKeySystemAccess(mediaKeySystemAccess);
|
|
|
|
return keyFormatPromise.then(
|
|
() => {
|
|
throw new Error('Expected key-system selection to reject');
|
|
},
|
|
(error) => {
|
|
expect(error).to.be.instanceOf(Error);
|
|
expect(error.message).to.equal('invalid state');
|
|
expect(createMediaKeysSpy).not.to.have.been.called;
|
|
},
|
|
);
|
|
});
|
|
|
|
it('should handle unused session-key key-system selection rejections', function () {
|
|
let rejectKeySystemAccess!: (error: Error) => void;
|
|
const keySystemAccessPromise = new Promise<MediaKeySystemAccess>(
|
|
(resolve, reject) => {
|
|
rejectKeySystemAccess = reject;
|
|
},
|
|
);
|
|
let unhandledRejection: PromiseRejectionEvent | null = null;
|
|
const onUnhandledRejection = (event: PromiseRejectionEvent) => {
|
|
unhandledRejection = event;
|
|
event.preventDefault();
|
|
};
|
|
|
|
self.addEventListener('unhandledrejection', onUnhandledRejection);
|
|
|
|
setupEach({
|
|
emeEnabled: true,
|
|
requestMediaKeySystemAccessFunc: () => keySystemAccessPromise,
|
|
drmSystems: {
|
|
'com.apple.fps': {},
|
|
},
|
|
});
|
|
|
|
emeController.onManifestLoaded(Events.MANIFEST_LOADED, {
|
|
sessionKeys: [getParsedLevelKey()],
|
|
});
|
|
|
|
const keyFormatPromise = emeController.keyFormatPromise;
|
|
if (!keyFormatPromise) {
|
|
throw new Error('Expected key-system selection promise');
|
|
}
|
|
|
|
rejectKeySystemAccess(new Error('key-system access failed'));
|
|
|
|
return new Promise<void>((resolve) => {
|
|
self.setTimeout(resolve, 0);
|
|
})
|
|
.then(() => {
|
|
expect(unhandledRejection).to.equal(null);
|
|
expect(emeController.hls.trigger).not.to.have.been.called;
|
|
return keyFormatPromise.then(
|
|
() => {
|
|
throw new Error('Expected key-system selection to reject');
|
|
},
|
|
(error) => {
|
|
expect(error).to.be.instanceOf(Error);
|
|
expect(error.message).to.equal('key-system access failed');
|
|
},
|
|
);
|
|
})
|
|
.finally(() => {
|
|
self.removeEventListener('unhandledrejection', onUnhandledRejection);
|
|
});
|
|
});
|
|
|
|
it('should remove media property when media is detached', function () {
|
|
const reqMediaKsAccessSpy = sinon.spy(function () {
|
|
return Promise.resolve({
|
|
// Media-keys mock
|
|
keySystem: 'com.apple.fps',
|
|
createMediaKeys: sinon.spy(() =>
|
|
Promise.resolve({
|
|
setServerCertificate: () => Promise.resolve(),
|
|
createSession: () => ({
|
|
addEventListener: () => {},
|
|
removeEventListener: () => {},
|
|
generateRequest: () => Promise.resolve(),
|
|
remove: () => Promise.resolve(),
|
|
update: () => Promise.resolve(),
|
|
keyStatuses: new Map(),
|
|
}),
|
|
}),
|
|
),
|
|
});
|
|
});
|
|
|
|
setupEach({
|
|
emeEnabled: true,
|
|
requestMediaKeySystemAccessFunc: reqMediaKsAccessSpy,
|
|
});
|
|
|
|
emeController.onMediaAttached(Events.MEDIA_ATTACHED, {
|
|
media: media as any as HTMLMediaElement,
|
|
});
|
|
|
|
const keySession = new MediaKeySessionMock();
|
|
const levelKey = getParsedLevelKey();
|
|
const mockMediaKeySessionContext: MediaKeySessionContext = {
|
|
keySystem: KeySystems.FAIRPLAY,
|
|
mediaKeys: new MediaKeysMock(),
|
|
mediaKeysSession: keySession,
|
|
keyRequests: {},
|
|
keyStatuses: {},
|
|
createdFor: { levelKey, reason: 'playlist-key' },
|
|
initialized: false,
|
|
};
|
|
|
|
emeController.mediaKeySessions = [mockMediaKeySessionContext];
|
|
emeController.destroy();
|
|
|
|
expect(emeController.media).to.equal(null);
|
|
});
|
|
|
|
it('should close all media key sessions and remove media keys when call destroy', function () {
|
|
const reqMediaKsAccessSpy = sinon.spy(function () {
|
|
return Promise.resolve({
|
|
// Media-keys mock
|
|
keySystem: 'com.apple.fps',
|
|
createMediaKeys: sinon.spy(() =>
|
|
Promise.resolve({
|
|
setServerCertificate: () => Promise.resolve(),
|
|
createSession: () => ({
|
|
addEventListener: () => {},
|
|
removeEventListener: () => {},
|
|
generateRequest: () => Promise.resolve(),
|
|
remove: () => Promise.resolve(),
|
|
update: () => Promise.resolve(),
|
|
keyStatuses: new Map(),
|
|
}),
|
|
}),
|
|
),
|
|
});
|
|
});
|
|
|
|
setupEach({
|
|
emeEnabled: true,
|
|
requestMediaKeySystemAccessFunc: reqMediaKsAccessSpy,
|
|
});
|
|
|
|
emeController.onMediaAttached(Events.MEDIA_ATTACHED, {
|
|
media: media as any as HTMLMediaElement,
|
|
});
|
|
|
|
const keySession = new MediaKeySessionMock();
|
|
const levelKey = getParsedLevelKey();
|
|
const mockMediaKeySessionContext: MediaKeySessionContext = {
|
|
keySystem: KeySystems.FAIRPLAY,
|
|
mediaKeys: new MediaKeysMock(),
|
|
mediaKeysSession: keySession,
|
|
keyRequests: {},
|
|
keyStatuses: {},
|
|
createdFor: { levelKey, reason: 'playlist-key' },
|
|
initialized: false,
|
|
};
|
|
const keySessionCloseSpy = sinon.spy(keySession, 'close');
|
|
|
|
emeController.mediaKeySessions = [mockMediaKeySessionContext];
|
|
emeController.destroy();
|
|
|
|
expect(EMEController.CDMCleanupPromise).to.be.a('Promise');
|
|
if (!EMEController.CDMCleanupPromise) {
|
|
return;
|
|
}
|
|
return EMEController.CDMCleanupPromise.then(() => {
|
|
expect(keySessionCloseSpy).callCount(1);
|
|
expect(emeController.mediaKeySessions.length).to.equal(0);
|
|
expect(media.setMediaKeys).calledWith(null);
|
|
});
|
|
});
|
|
|
|
it('should remove all media key sessions and remove all media key sessions when call destroy with persistent-license session type', function () {
|
|
const reqMediaKsAccessSpy = sinon.spy(function () {
|
|
return Promise.resolve({
|
|
// Media-keys mock
|
|
keySystem: 'com.apple.fps',
|
|
createMediaKeys: sinon.spy(() =>
|
|
Promise.resolve({
|
|
setServerCertificate: () => Promise.resolve(),
|
|
createSession: () => ({
|
|
addEventListener: () => {},
|
|
removeEventListener: () => {},
|
|
generateRequest: () => Promise.resolve(),
|
|
remove: () => Promise.resolve(),
|
|
update: () => Promise.resolve(),
|
|
keyStatuses: new Map(),
|
|
}),
|
|
}),
|
|
),
|
|
});
|
|
});
|
|
|
|
setupEach({
|
|
emeEnabled: true,
|
|
requestMediaKeySystemAccessFunc: reqMediaKsAccessSpy,
|
|
drmSystemOptions: {
|
|
sessionType: 'persistent-license',
|
|
},
|
|
});
|
|
|
|
emeController.onMediaAttached(Events.MEDIA_ATTACHED, {
|
|
media: media as any as HTMLMediaElement,
|
|
});
|
|
|
|
const keySession = new MediaKeySessionMock();
|
|
const levelKey = getParsedLevelKey();
|
|
const mockMediaKeySessionContext: MediaKeySessionContext = {
|
|
keySystem: KeySystems.FAIRPLAY,
|
|
mediaKeys: new MediaKeysMock(),
|
|
mediaKeysSession: keySession,
|
|
keyRequests: {},
|
|
keyStatuses: {},
|
|
createdFor: { levelKey, reason: 'playlist-key' },
|
|
initialized: false,
|
|
};
|
|
const keySessionCloseSpy = sinon.spy(keySession, 'close');
|
|
|
|
emeController.mediaKeySessions = [mockMediaKeySessionContext];
|
|
emeController.destroy();
|
|
|
|
expect(EMEController.CDMCleanupPromise).to.be.a('Promise');
|
|
if (!EMEController.CDMCleanupPromise) {
|
|
return;
|
|
}
|
|
return EMEController.CDMCleanupPromise.then(() => {
|
|
expect(keySessionCloseSpy).callCount(1);
|
|
expect(emeController.mediaKeySessions.length).to.equal(0);
|
|
});
|
|
});
|
|
|
|
describe('serialized key-session setup (#7796)', function () {
|
|
class DeferredMediaKeySessionMock extends MediaKeySessionMock {
|
|
generateRequest() {
|
|
this.emit('message', {
|
|
messageType: 'license-request',
|
|
message: new Uint8Array(0),
|
|
});
|
|
// Do not auto-publish key statuses — tests drive timing manually.
|
|
return Promise.resolve();
|
|
}
|
|
|
|
publishKeyStatus(keyId: Uint8Array<ArrayBuffer> = new Uint8Array(16)) {
|
|
this._keyStatuses.set(keyId, 'usable');
|
|
this.emit('keystatuseschange', {});
|
|
}
|
|
}
|
|
|
|
const drain = () =>
|
|
new Promise<void>((resolve) => self.setTimeout(() => resolve(), 0));
|
|
|
|
const buildKsAccessSpy = (sessions: DeferredMediaKeySessionMock[]) => {
|
|
const createSessionSpy = sinon.spy(() => {
|
|
const session = new DeferredMediaKeySessionMock();
|
|
sessions.push(session);
|
|
return session;
|
|
});
|
|
const reqMediaKsAccessSpy = sinon.spy(function () {
|
|
return Promise.resolve({
|
|
keySystem: 'com.apple.fps',
|
|
createMediaKeys: sinon.spy(() =>
|
|
Promise.resolve({
|
|
setServerCertificate: () => Promise.resolve(),
|
|
createSession: createSessionSpy,
|
|
}),
|
|
),
|
|
});
|
|
});
|
|
return { reqMediaKsAccessSpy, createSessionSpy };
|
|
};
|
|
|
|
const respondToXhrEmpty = () => {
|
|
sinonFakeXMLHttpRequestStatic.onCreate = (
|
|
xhr: sinon.SinonFakeXMLHttpRequest,
|
|
) => {
|
|
self.setTimeout(() => {
|
|
(xhr as any).response = new Uint8Array();
|
|
xhr.respond(200, {}, '');
|
|
}, 0);
|
|
};
|
|
};
|
|
|
|
it('blocks concurrent loadKey for the same URI on a single session', function () {
|
|
const sessions: DeferredMediaKeySessionMock[] = [];
|
|
const { reqMediaKsAccessSpy, createSessionSpy } =
|
|
buildKsAccessSpy(sessions);
|
|
setupEach({
|
|
emeEnabled: true,
|
|
drmSystems: { 'com.apple.fps': { licenseUrl: 'http://noop' } },
|
|
requestMediaKeySystemAccessFunc: reqMediaKsAccessSpy,
|
|
});
|
|
respondToXhrEmpty();
|
|
emeController.onMediaAttached(Events.MEDIA_ATTACHED, {
|
|
media: media as any as HTMLMediaElement,
|
|
});
|
|
|
|
const levelKey = getParsedLevelKey();
|
|
const pA = emeController.loadKey(getEncryptedFrag(levelKey));
|
|
const pB = emeController.loadKey(getEncryptedFrag(levelKey));
|
|
|
|
return drain()
|
|
.then(() => {
|
|
expect(createSessionSpy).callCount(1);
|
|
sessions[0].publishKeyStatus();
|
|
return Promise.all([pA, pB]);
|
|
})
|
|
.then(() => {
|
|
expect(createSessionSpy).callCount(1);
|
|
expect(emeController.mediaKeySessions.length).to.equal(1);
|
|
});
|
|
});
|
|
|
|
it('creates distinct concurrent sessions for distinct URIs', function () {
|
|
const sessions: DeferredMediaKeySessionMock[] = [];
|
|
const { reqMediaKsAccessSpy, createSessionSpy } =
|
|
buildKsAccessSpy(sessions);
|
|
setupEach({
|
|
emeEnabled: true,
|
|
drmSystems: { 'com.apple.fps': { licenseUrl: 'http://noop' } },
|
|
requestMediaKeySystemAccessFunc: reqMediaKsAccessSpy,
|
|
});
|
|
respondToXhrEmpty();
|
|
emeController.onMediaAttached(Events.MEDIA_ATTACHED, {
|
|
media: media as any as HTMLMediaElement,
|
|
});
|
|
|
|
// Distinct keyIds mirror real HLS where different key URIs map to
|
|
// different keys. Distinct URIs do not block each other — FPS in
|
|
// particular relies on parallel session setup.
|
|
const keyA = getParsedLevelKey('data://key-uri-A');
|
|
keyA.keyId = new Uint8Array(16).fill(0xa1);
|
|
const keyB = getParsedLevelKey('data://key-uri-B');
|
|
keyB.keyId = new Uint8Array(16).fill(0xb2);
|
|
const fragA = getEncryptedFrag(keyA);
|
|
const fragB = getEncryptedFrag(keyB);
|
|
|
|
const pA = emeController.loadKey(fragA);
|
|
const pB = emeController.loadKey(fragB);
|
|
|
|
return drain()
|
|
.then(() => {
|
|
// Distinct URIs run in parallel — each gets its own session.
|
|
expect(createSessionSpy).callCount(2);
|
|
sessions[0].publishKeyStatus(keyA.keyId!);
|
|
sessions[1].publishKeyStatus(keyB.keyId!);
|
|
return Promise.all([pA, pB]);
|
|
})
|
|
.then(() => {
|
|
expect(emeController.mediaKeySessions.length).to.equal(2);
|
|
});
|
|
});
|
|
|
|
it('does not orphan blocked callers when the first session setup fails', function () {
|
|
const sessions: DeferredMediaKeySessionMock[] = [];
|
|
const createSessionSpy = sinon.spy(() => {
|
|
const session = new DeferredMediaKeySessionMock();
|
|
if (sessions.length === 0) {
|
|
(session as any).generateRequest = () =>
|
|
Promise.reject(new Error('first session failed'));
|
|
}
|
|
sessions.push(session);
|
|
return session;
|
|
});
|
|
const reqMediaKsAccessSpy = sinon.spy(function () {
|
|
return Promise.resolve({
|
|
keySystem: 'com.apple.fps',
|
|
createMediaKeys: sinon.spy(() =>
|
|
Promise.resolve({
|
|
setServerCertificate: () => Promise.resolve(),
|
|
createSession: createSessionSpy,
|
|
}),
|
|
),
|
|
});
|
|
});
|
|
setupEach({
|
|
emeEnabled: true,
|
|
drmSystems: { 'com.apple.fps': { licenseUrl: 'http://noop' } },
|
|
requestMediaKeySystemAccessFunc: reqMediaKsAccessSpy,
|
|
});
|
|
respondToXhrEmpty();
|
|
emeController.onMediaAttached(Events.MEDIA_ATTACHED, {
|
|
media: media as any as HTMLMediaElement,
|
|
});
|
|
|
|
const levelKey = getParsedLevelKey();
|
|
const pA = emeController
|
|
.loadKey(getEncryptedFrag(levelKey))
|
|
.catch(() => undefined);
|
|
const pB = emeController.loadKey(getEncryptedFrag(levelKey));
|
|
|
|
return drain()
|
|
.then(() => drain())
|
|
.then(() => {
|
|
// After A's failure, B should drive a fresh session creation.
|
|
expect(createSessionSpy.callCount).to.be.at.least(1);
|
|
if (sessions.length >= 2) {
|
|
sessions[1].publishKeyStatus();
|
|
}
|
|
return Promise.all([pA, pB]);
|
|
})
|
|
.then(() => {
|
|
expect(createSessionSpy.callCount).to.be.at.least(2);
|
|
expect(emeController.mediaKeySessions.length).to.equal(1);
|
|
});
|
|
});
|
|
});
|
|
});
|