import { describe, afterEach, it, expect, vi } from "vitest"; import getEmeApiImplementation from "../../../../compat/eme"; import type { IKeySystemOption, IPlayerError } from "../../../../public_types"; import assert from "../../../../utils/assert"; import { concat } from "../../../../utils/byte_parsing"; import ContentDecryptor from "../../content_decryptor"; import { ContentDecryptorState } from "../../types"; import { formatFakeChallengeFromInitData, MediaKeySessionImpl, MediaKeysImpl, mockCompat, } from "./utils"; const mocks = vi.hoisted(() => { return { shouldRenewMediaKeySystemAccess: vi.fn(() => false), canReuseMediaKeys: vi.fn(() => true), onEncrypted: vi.fn(), requestMediaKeySystemAccess: vi.fn(), setMediaKeys: vi.fn(), getInitData: vi.fn(), generateKeyRequest: vi.fn(), }; }); vi.mock("../../../../compat/should_renew_media_key_system_access", () => ({ default: mocks.shouldRenewMediaKeySystemAccess, })); vi.mock("../../../../compat/can_reuse_media_keys", () => ({ default: mocks.canReuseMediaKeys, })); vi.mock("../../../../compat/eme", () => ({ default: () => ({ onEncrypted: mocks.onEncrypted, requestMediaKeySystemAccess: mocks.requestMediaKeySystemAccess, setMediaKeys: mocks.setMediaKeys, }), getInitData: mocks.getInitData, generateKeyRequest: mocks.generateKeyRequest, closeSession: vi.fn(), loadSession: vi.fn(), })); describe("decrypt - global tests - getLicense", () => { afterEach(() => { vi.resetModules(); vi.restoreAllMocks(); mocks.shouldRenewMediaKeySystemAccess.mockReset(); mocks.canReuseMediaKeys.mockReset(); mocks.onEncrypted.mockReset(); mocks.requestMediaKeySystemAccess.mockReset(); mocks.setMediaKeys.mockReset(); mocks.getInitData.mockReset(); mocks.generateKeyRequest.mockReset(); }); it("should update the session after getLicense resolves with a license", async () => { await checkGetLicense({ isGetLicensePromiseBased: true, configuredRetries: undefined, configuredTimeout: undefined, getTimeout: () => undefined, nbRetries: 0, ignoreLicenseRequests: false, }); }); it("should update the session after getLicense returns a license directly", async () => { await checkGetLicense({ isGetLicensePromiseBased: false, configuredRetries: undefined, configuredTimeout: undefined, getTimeout: () => undefined, nbRetries: 0, ignoreLicenseRequests: false, }); }); it("should not update the session after getLicense resolves with null", async () => { await checkGetLicense({ isGetLicensePromiseBased: true, configuredRetries: undefined, configuredTimeout: undefined, getTimeout: () => undefined, nbRetries: 0, ignoreLicenseRequests: true, }); }); it("should not update the session after getLicense returns null directly", async () => { await checkGetLicense({ isGetLicensePromiseBased: false, configuredRetries: undefined, configuredTimeout: undefined, getTimeout: () => undefined, nbRetries: 0, ignoreLicenseRequests: true, }); }); it("should be able to retry maximum two times and fail after 3 tries with a rejected promise", async () => { await checkGetLicense({ isGetLicensePromiseBased: true, configuredRetries: undefined, configuredTimeout: undefined, getTimeout: () => undefined, nbRetries: 3, ignoreLicenseRequests: false, }); }); it("should be able to retry maximum two times and fail after 3 tries with a thrown error", async () => { await checkGetLicense({ isGetLicensePromiseBased: false, configuredRetries: undefined, configuredTimeout: undefined, getTimeout: () => undefined, nbRetries: 3, ignoreLicenseRequests: true, }); }); it("should be able to retry two times and succeed after a time with a resolved license", async () => { await checkGetLicense({ isGetLicensePromiseBased: true, configuredRetries: undefined, configuredTimeout: undefined, getTimeout: () => undefined, nbRetries: 2, ignoreLicenseRequests: false, }); }); it("should be able to retry two times and succeed after a time with a returned license", async () => { await checkGetLicense({ isGetLicensePromiseBased: false, configuredRetries: undefined, configuredTimeout: undefined, getTimeout: () => undefined, nbRetries: 2, ignoreLicenseRequests: false, }); }); it("should be able to retry two times and succeed after a time with a resolved null", async () => { await checkGetLicense({ isGetLicensePromiseBased: true, configuredRetries: undefined, configuredTimeout: undefined, getTimeout: () => undefined, nbRetries: 2, ignoreLicenseRequests: true, }); }); it("should be able to retry two times and succeed after a time with a returned null", async () => { await checkGetLicense({ isGetLicensePromiseBased: false, configuredRetries: undefined, configuredTimeout: undefined, getTimeout: () => undefined, nbRetries: 2, ignoreLicenseRequests: true, }); }); it("should be able to retry two times and succeed after a time with a resolved license", async () => { await checkGetLicense({ isGetLicensePromiseBased: true, configuredRetries: undefined, configuredTimeout: undefined, getTimeout: () => undefined, nbRetries: 1, ignoreLicenseRequests: false, }); }); it("should be able to retry two times and succeed after a time with a returned license", async () => { await checkGetLicense({ isGetLicensePromiseBased: false, configuredRetries: undefined, configuredTimeout: undefined, getTimeout: () => undefined, nbRetries: 1, ignoreLicenseRequests: false, }); }); it("should be able to retry two times and succeed after a time with a resolved null", async () => { await checkGetLicense({ isGetLicensePromiseBased: true, configuredRetries: undefined, configuredTimeout: undefined, getTimeout: () => undefined, nbRetries: 1, ignoreLicenseRequests: true, }); }); it("should be able to retry two times and succeed after a time with a returned null", async () => { await checkGetLicense({ isGetLicensePromiseBased: false, configuredRetries: undefined, configuredTimeout: undefined, getTimeout: () => undefined, nbRetries: 1, ignoreLicenseRequests: true, }); }); it("should be able to fetch a license directly even when getLicenseConfig.retry is set to `0`", async () => { await checkGetLicense({ isGetLicensePromiseBased: true, configuredRetries: 0, configuredTimeout: undefined, getTimeout: () => undefined, nbRetries: 0, ignoreLicenseRequests: false, }); }); it("should fail after first failure when getLicenseConfig.retry is set to `0`", async () => { await checkGetLicense({ isGetLicensePromiseBased: true, configuredRetries: 0, configuredTimeout: undefined, getTimeout: () => undefined, nbRetries: 1, ignoreLicenseRequests: false, }); }); it("should fail after two failures when getLicenseConfig.retry is set to `1`", async () => { await checkGetLicense({ isGetLicensePromiseBased: true, configuredRetries: 1, configuredTimeout: undefined, getTimeout: () => undefined, nbRetries: 2, ignoreLicenseRequests: false, }); }); it("should not fail after one failure when getLicenseConfig.retry is set to `1`", async () => { await checkGetLicense({ isGetLicensePromiseBased: true, configuredRetries: 1, configuredTimeout: undefined, getTimeout: () => undefined, nbRetries: 1, ignoreLicenseRequests: false, }); }); it("should not fail after 6 failures when getLicenseConfig.retry is set to `6`", async () => { await checkGetLicense({ isGetLicensePromiseBased: true, configuredRetries: 6, configuredTimeout: undefined, getTimeout: () => undefined, nbRetries: 6, ignoreLicenseRequests: false, }); }, 25000); it("should fail after 7 failures when getLicenseConfig.retry is set to `6`", async () => { await checkGetLicense({ isGetLicensePromiseBased: true, configuredRetries: 6, configuredTimeout: undefined, getTimeout: () => undefined, nbRetries: 7, ignoreLicenseRequests: false, }); }, 25000); it("should not fail after 5 failures when getLicenseConfig.retry is set to `Infinity`", async () => { await checkGetLicense({ isGetLicensePromiseBased: true, configuredRetries: Infinity, configuredTimeout: undefined, getTimeout: () => undefined, nbRetries: 5, ignoreLicenseRequests: false, }); }, 15000); it("should emit KEY_LOAD_TIMEOUT when getLicense times out", async () => { const initData = new Uint8Array([54, 55, 75]); const initDataEvent = { type: "cenc", values: [{ systemId: "15", data: initData }], }; const challenge = formatFakeChallengeFromInitData(initData, "cenc"); const mediaKeySession = new MediaKeySessionImpl(); vi.spyOn(MediaKeysImpl.prototype, "createSession").mockReturnValue(mediaKeySession); const mockGetLicense = vi.fn(() => { return new Promise((resolve) => { setTimeout(() => { resolve(challenge); }, 30); }); }); const videoElt = document.createElement("video"); mockCompat(mocks); await new Promise((res, rej) => { const ksConfig: IKeySystemOption[] = [ { type: "com.widevine.alpha", getLicense: mockGetLicense, getLicenseConfig: { retry: 0, timeout: 5, }, }, ]; const eme = getEmeApiImplementation("auto"); assert(eme !== null); const contentDecryptor = new ContentDecryptor(eme, videoElt, ksConfig); contentDecryptor.addEventListener("stateChange", (newState: number) => { if (newState !== ContentDecryptorState.WaitingForAttachment) { rej(new Error(`Unexpected state: ${newState}`)); return; } contentDecryptor.removeEventListener("stateChange"); contentDecryptor.attach(); }); contentDecryptor.addEventListener("warning", (warning: Error) => { rej(new Error(`Unexpected warning: ${warning.toString()}`)); }); contentDecryptor.addEventListener("error", (error: Error) => { try { expect(error).toBeInstanceOf(Error); expect((error as IPlayerError).name).toEqual("EncryptedMediaError"); expect((error as IPlayerError).type).toEqual("ENCRYPTED_MEDIA_ERROR"); expect((error as IPlayerError).code).toEqual("KEY_LOAD_TIMEOUT"); expect((error as IPlayerError).message).toEqual( "KEY_LOAD_TIMEOUT: The license server took too much time to respond.", ); expect(mockGetLicense).toHaveBeenCalledTimes(1); expect(mockGetLicense).toHaveBeenNthCalledWith(1, challenge, "license-request"); contentDecryptor.dispose(undefined); res(); } catch (e) { rej(e); } }); contentDecryptor.onInitializationData(initDataEvent); }); }); }); /** * HUGE function to check most getLicense scenarios. * @param {Object} opts */ async function checkGetLicense({ isGetLicensePromiseBased, configuredRetries, configuredTimeout, getTimeout, nbRetries, ignoreLicenseRequests, }: { /** * Set it to false if you want to return directly a value from * `getLicense` (i.e. not wrapped in a Promise). * This is valable both for success, and errors (which will be either * thrown directly or wrapped in a rejecting Promise). * * This value will be forced to `true` if the `getTimeout` call for the * corresponding `getLicense` returns something other than `undefined`. */ isGetLicensePromiseBased: boolean; /** Maximum amount of retries: value of getLicenseConfig.retry. */ configuredRetries: number | undefined; /** * Return getLicense timeout after which the response will be emitted, in * milliseconds. * Put to `undefined` for no timeout at all (synchronous). * Note that if a timeout is given, getLicense will return a Promise. * As such, `isGetLicensePromiseBased` will be ignored. */ getTimeout: (callIdx: number) => number | undefined; /** Maximum configured timeout: value of getLicenseConfig.timeout. */ configuredTimeout: number | undefined; /** * Nb of times getLicense should fail in a row. * If put at a higher value than `configuredRetries`, no license will be * obtained. */ nbRetries: number; /** * If `true`, getLicense will return `null` - to ignore a request - when * it succeed. */ ignoreLicenseRequests: boolean; }): Promise { const initData = new Uint8Array([54, 55, 75]); const initDataEvent = { type: "cenc", values: [{ systemId: "15", data: initData }], }; const challenge = formatFakeChallengeFromInitData(initData, "cenc"); // == mocks == const mediaKeySession = new MediaKeySessionImpl(); vi.spyOn(MediaKeysImpl.prototype, "createSession").mockReturnValue(mediaKeySession); const mockUpdate = vi.spyOn(mediaKeySession, "update"); let remainingRetries = nbRetries; const mockGetLicense = vi.fn((): BufferSource | Promise | null => { const callIdx = nbRetries - remainingRetries; const timeout = getTimeout(callIdx); if (remainingRetries === 0) { const challengeU8 = new Uint8Array(challenge); const result = ignoreLicenseRequests ? null : concat(challengeU8, challengeU8); if (timeout !== undefined) { return new Promise((resolve) => { setTimeout(() => resolve(result), timeout); }); } return isGetLicensePromiseBased ? Promise.resolve(result) : result; } remainingRetries--; if (timeout !== undefined) { return new Promise((_, reject) => { setTimeout(() => { reject("AAAA"); }, timeout); }); } if (!isGetLicensePromiseBased) { throw new Error("AAAA"); } return Promise.reject(new Error("AAAA")); }); const videoElt = document.createElement("video"); mockCompat(mocks); return new Promise((res, rej) => { // == vars == /** Default keySystems configuration used in our tests. */ const maxRetries = configuredRetries === undefined ? 2 : configuredRetries; const shouldFail = nbRetries > maxRetries; let warningsLeft = nbRetries; const ksConfig: IKeySystemOption[] = [ { type: "com.widevine.alpha", getLicense: mockGetLicense, getLicenseConfig: configuredRetries !== undefined || configuredTimeout !== undefined ? { retry: configuredRetries, timeout: configuredTimeout } : undefined, }, ]; function checkKeyLoadError(error: unknown) { try { expect(error).toBeInstanceOf(Error); expect((error as IPlayerError).name).toEqual("EncryptedMediaError"); expect((error as IPlayerError).type).toEqual("ENCRYPTED_MEDIA_ERROR"); expect((error as IPlayerError).code).toEqual("KEY_LOAD_ERROR"); expect((error as IPlayerError).message).toEqual("AAAA"); } catch (e) { rej(e); } } // == test == const eme = getEmeApiImplementation("auto"); assert(eme !== null); const contentDecryptor = new ContentDecryptor(eme, videoElt, ksConfig); contentDecryptor.addEventListener("stateChange", (newState: number) => { if (newState !== ContentDecryptorState.WaitingForAttachment) { rej(new Error(`Unexpected state: ${newState}`)); } contentDecryptor.removeEventListener("stateChange"); contentDecryptor.attach(); }); contentDecryptor.addEventListener("error", (error: Error) => { if (shouldFail) { try { checkKeyLoadError(error); expect(mockGetLicense).toHaveBeenCalledTimes(maxRetries + 1); for (let i = 1; i <= maxRetries + 1; i++) { // TODO there's seem to be an issue with how vitest check Uint8Array // equality expect(mockGetLicense).toHaveBeenNthCalledWith( i, challenge, "license-request", ); } expect(mockUpdate).toHaveBeenCalledTimes(0); res(); } catch (e) { rej(e); } } else { rej(new Error(`Unexpected error: ${error.toString()}`)); } }); contentDecryptor.addEventListener("warning", (warning: Error) => { if (warningsLeft-- > 0) { try { checkKeyLoadError(warning); const requestIdx = nbRetries - remainingRetries; expect(mockGetLicense).toHaveBeenCalledTimes(requestIdx); // TODO there's seem to be an issue with how vitest check Uint8Array // equality expect(mockGetLicense).toHaveBeenNthCalledWith( requestIdx, challenge, "license-request", ); } catch (e) { rej(e); } } else { rej(new Error(`Unexpected warning: ${warning.toString()}`)); } }); contentDecryptor.onInitializationData(initDataEvent); if (!shouldFail) { let timeout: number; switch (nbRetries) { case 0: timeout = 100; break; case 1: timeout = 300; break; case 2: timeout = 800; break; case 3: timeout = 2000; break; case 4: timeout = 4000; break; case 5: timeout = 8000; break; case 6: timeout = 12000; break; default: timeout = 16000; } setTimeout(() => { try { if (ignoreLicenseRequests) { expect(mockUpdate).toHaveBeenCalledTimes(0); } else { const license = concat(challenge, challenge); expect(mockUpdate).toHaveBeenCalledTimes(1); expect(mockUpdate).toHaveBeenCalledWith(license); } expect(mockGetLicense).toHaveBeenCalledTimes(nbRetries + 1); for (let i = 1; i <= nbRetries + 1; i++) { // TODO there's seem to be an issue with how vitest check Uint8Array // equality expect(mockGetLicense).toHaveBeenNthCalledWith( i, challenge, "license-request", ); } contentDecryptor.dispose(undefined); res(); } catch (e) { rej(e); } }, timeout); } }); }