/* Any copyright is dedicated to the Public Domain. * http://creativecommons.org/publicdomain/zero/1.0/ */ const EXAMPLE_COM_URL = "https://example.com/document-builder.sjs?html=

Test midi permission with synthetic site permission addon

"; const PAGE_WITH_IFRAMES_URL = `https://example.org/document-builder.sjs?html=

Test midi permission with synthetic site permission addon in iframes

`; const l10n = new Localization( [ "browser/addonNotifications.ftl", "toolkit/global/extensions.ftl", "toolkit/global/extensionPermissions.ftl", "branding/brand.ftl", ], true ); const { HttpServer } = ChromeUtils.import("resource://testing-common/httpd.js"); ChromeUtils.defineESModuleGetters(this, { AddonTestUtils: "resource://testing-common/AddonTestUtils.sys.mjs", }); add_setup(async function () { await SpecialPowers.pushPrefEnv({ set: [["midi.prompt.testing", false]], }); AddonTestUtils.initMochitest(this); AddonTestUtils.hookAMTelemetryEvents(); // Once the addon is installed, a dialog is displayed as a confirmation. // This could interfere with tests running after this one, so we set up a listener // that will always accept post install dialogs so we don't have to deal with them in // the test. alwaysAcceptAddonPostInstallDialogs(); registerCleanupFunction(async () => { // Remove the permission. await SpecialPowers.removePermission("midi-sysex", { url: EXAMPLE_COM_URL, }); await SpecialPowers.removePermission("midi-sysex", { url: PAGE_WITH_IFRAMES_URL, }); await SpecialPowers.removePermission("midi", { url: EXAMPLE_COM_URL, }); await SpecialPowers.removePermission("midi", { url: PAGE_WITH_IFRAMES_URL, }); await SpecialPowers.removePermission("install", { url: EXAMPLE_COM_URL, }); while (gBrowser.tabs.length > 1) { BrowserTestUtils.removeTab(gBrowser.selectedTab); } }); }); add_task(async function testRequestMIDIAccess() { gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser, EXAMPLE_COM_URL); await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); const testPageHost = gBrowser.selectedTab.linkedBrowser.documentURI.host; info("Check that midi-sysex isn't set"); ok( await SpecialPowers.testPermission( "midi-sysex", SpecialPowers.Services.perms.UNKNOWN_ACTION, { url: EXAMPLE_COM_URL } ), "midi-sysex value should have UNKNOWN permission" ); info("Request midi-sysex access"); let onAddonInstallBlockedNotification = waitForNotification( "addon-install-blocked" ); await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { content.midiAccessRequestPromise = content.navigator.requestMIDIAccess({ sysex: true, }); }); info("Deny site permission addon install in first popup"); let addonInstallPanel = await onAddonInstallBlockedNotification; const [installPopupHeader, installPopupMessage] = addonInstallPanel.querySelectorAll( "description.popup-notification-description" ); is( installPopupHeader.textContent, l10n.formatValueSync("site-permission-install-first-prompt-midi-header"), "First popup has expected header text" ); is( installPopupMessage.textContent, l10n.formatValueSync("site-permission-install-first-prompt-midi-message"), "First popup has expected message" ); let notification = addonInstallPanel.childNodes[0]; // secondaryButton is the "Don't allow" button notification.secondaryButton.click(); let rejectionMessage = await SpecialPowers.spawn( gBrowser.selectedBrowser, [], async () => { let errorMessage; try { await content.midiAccessRequestPromise; } catch (e) { errorMessage = `${e.name}: ${e.message}`; } delete content.midiAccessRequestPromise; return errorMessage; } ); is( rejectionMessage, "SecurityError: WebMIDI requires a site permission add-on to activate" ); assertSitePermissionInstallTelemetryEvents(["site_warning", "cancelled"]); info("Deny site permission addon install in second popup"); onAddonInstallBlockedNotification = waitForNotification( "addon-install-blocked" ); await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { content.midiAccessRequestPromise = content.navigator.requestMIDIAccess({ sysex: true, }); }); addonInstallPanel = await onAddonInstallBlockedNotification; notification = addonInstallPanel.childNodes[0]; let dialogPromise = waitForInstallDialog(); notification.button.click(); let installDialog = await dialogPromise; is( installDialog.querySelector(".popup-notification-description").textContent, l10n.formatValueSync( "webext-site-perms-header-with-gated-perms-midi-sysex", { hostname: testPageHost } ), "Install dialog has expected header text" ); is( installDialog.querySelector("popupnotificationcontent description") .textContent, l10n.formatValueSync("webext-site-perms-description-gated-perms-midi"), "Install dialog has expected description" ); // secondaryButton is the "Cancel" button installDialog.secondaryButton.click(); rejectionMessage = await SpecialPowers.spawn( gBrowser.selectedBrowser, [], async () => { let errorMessage; try { await content.midiAccessRequestPromise; } catch (e) { errorMessage = `${e.name}: ${e.message}`; } delete content.midiAccessRequestPromise; return errorMessage; } ); is( rejectionMessage, "SecurityError: WebMIDI requires a site permission add-on to activate" ); assertSitePermissionInstallTelemetryEvents([ "site_warning", "permissions_prompt", "cancelled", ]); info("Request midi-sysex access again"); onAddonInstallBlockedNotification = waitForNotification( "addon-install-blocked" ); await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { content.midiAccessRequestPromise = content.navigator.requestMIDIAccess({ sysex: true, }); }); info("Accept site permission addon install"); addonInstallPanel = await onAddonInstallBlockedNotification; notification = addonInstallPanel.childNodes[0]; dialogPromise = waitForInstallDialog(); notification.button.click(); installDialog = await dialogPromise; installDialog.button.click(); info("Wait for the midi-sysex access request promise to resolve"); let accessGranted = await SpecialPowers.spawn( gBrowser.selectedBrowser, [], async () => { try { await content.midiAccessRequestPromise; return true; } catch (e) {} delete content.midiAccessRequestPromise; return false; } ); ok(accessGranted, "requestMIDIAccess resolved"); info("Check that midi-sysex is now set"); ok( await SpecialPowers.testPermission( "midi-sysex", SpecialPowers.Services.perms.ALLOW_ACTION, { url: EXAMPLE_COM_URL } ), "midi-sysex value should have ALLOW permission" ); ok( await SpecialPowers.testPermission( "midi", SpecialPowers.Services.perms.UNKNOWN_ACTION, { url: EXAMPLE_COM_URL } ), "but midi should have UNKNOWN permission" ); info("Check that we don't prompt user again once they installed the addon"); const accessPromiseState = await SpecialPowers.spawn( gBrowser.selectedBrowser, [], async () => { return content.navigator .requestMIDIAccess({ sysex: true }) .then(() => "resolved"); } ); is( accessPromiseState, "resolved", "requestMIDIAccess resolved without user prompt" ); assertSitePermissionInstallTelemetryEvents([ "site_warning", "permissions_prompt", "completed", ]); info("Request midi access without sysex"); onAddonInstallBlockedNotification = waitForNotification( "addon-install-blocked" ); await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { content.midiNoSysexAccessRequestPromise = content.navigator.requestMIDIAccess(); }); info("Accept site permission addon install"); addonInstallPanel = await onAddonInstallBlockedNotification; notification = addonInstallPanel.childNodes[0]; is( notification .querySelector("#addon-install-blocked-info") .getAttribute("href"), Services.urlFormatter.formatURLPref("app.support.baseURL") + "site-permission-addons", "Got the expected SUMO page as a learn more link in the addon-install-blocked panel" ); dialogPromise = waitForInstallDialog(); notification.button.click(); installDialog = await dialogPromise; is( installDialog.querySelector(".popup-notification-description").textContent, l10n.formatValueSync("webext-site-perms-header-with-gated-perms-midi", { hostname: testPageHost, }), "Install dialog has expected header text" ); is( installDialog.querySelector("popupnotificationcontent description") .textContent, l10n.formatValueSync("webext-site-perms-description-gated-perms-midi"), "Install dialog has expected description" ); installDialog.button.click(); info("Wait for the midi access request promise to resolve"); accessGranted = await SpecialPowers.spawn( gBrowser.selectedBrowser, [], async () => { try { await content.midiNoSysexAccessRequestPromise; return true; } catch (e) {} delete content.midiNoSysexAccessRequestPromise; return false; } ); ok(accessGranted, "requestMIDIAccess resolved"); info("Check that both midi-sysex and midi are now set"); ok( await SpecialPowers.testPermission( "midi-sysex", SpecialPowers.Services.perms.ALLOW_ACTION, { url: EXAMPLE_COM_URL } ), "midi-sysex value should have ALLOW permission" ); ok( await SpecialPowers.testPermission( "midi", SpecialPowers.Services.perms.ALLOW_ACTION, { url: EXAMPLE_COM_URL } ), "and midi value should also have ALLOW permission" ); assertSitePermissionInstallTelemetryEvents([ "site_warning", "permissions_prompt", "completed", ]); info("Check that we don't prompt user again when they perm denied"); // remove permission to have a clean state await SpecialPowers.removePermission("midi-sysex", { url: EXAMPLE_COM_URL, }); onAddonInstallBlockedNotification = waitForNotification( "addon-install-blocked" ); await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { content.midiAccessRequestPromise = content.navigator.requestMIDIAccess({ sysex: true, }); }); info("Perm-deny site permission addon install"); addonInstallPanel = await onAddonInstallBlockedNotification; // Click the "Report Suspicious Site" menuitem, which has the same effect as // "Never Allow" and also submits a telemetry event (which we check below). notification.menupopup.querySelectorAll("menuitem")[1].click(); rejectionMessage = await SpecialPowers.spawn( gBrowser.selectedBrowser, [], async () => { let errorMessage; try { await content.midiAccessRequestPromise; } catch (e) { errorMessage = e.name; } delete content.midiAccessRequestPromise; return errorMessage; } ); is(rejectionMessage, "SecurityError", "requestMIDIAccess was rejected"); info("Request midi-sysex access again"); let denyIntervalStart = performance.now(); rejectionMessage = await SpecialPowers.spawn( gBrowser.selectedBrowser, [], async () => { let errorMessage; try { await content.navigator.requestMIDIAccess({ sysex: true, }); } catch (e) { errorMessage = e.name; } return errorMessage; } ); is( rejectionMessage, "SecurityError", "requestMIDIAccess was rejected without user prompt" ); let denyIntervalElapsed = performance.now() - denyIntervalStart; ok( denyIntervalElapsed >= 3000, `Rejection should be delayed by a randomized interval no less than 3 seconds (got ${ denyIntervalElapsed / 1000 } seconds)` ); // Invoking getAMTelemetryEvents resets the mocked event array, and we want // to test two different things here, so we cache it. let events = AddonTestUtils.getAMTelemetryEvents(); Assert.deepEqual( events.filter(evt => evt.method == "reportSuspiciousSite")[0], { method: "reportSuspiciousSite", object: "suspiciousSite", value: "example.com", extra: undefined, } ); assertSitePermissionInstallTelemetryEvents( ["site_warning", "cancelled"], events ); }); add_task(async function testIframeRequestMIDIAccess() { gBrowser.selectedTab = BrowserTestUtils.addTab( gBrowser, PAGE_WITH_IFRAMES_URL ); await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); info("Check that midi-sysex isn't set"); ok( await SpecialPowers.testPermission( "midi-sysex", SpecialPowers.Services.perms.UNKNOWN_ACTION, { url: PAGE_WITH_IFRAMES_URL } ), "midi-sysex value should have UNKNOWN permission" ); info("Request midi-sysex access from the same-origin iframe"); const sameOriginIframeBrowsingContext = await SpecialPowers.spawn( gBrowser.selectedBrowser, [], async () => { return content.document.getElementById("sameOrigin").browsingContext; } ); let onAddonInstallBlockedNotification = waitForNotification( "addon-install-blocked" ); await SpecialPowers.spawn(sameOriginIframeBrowsingContext, [], () => { content.midiAccessRequestPromise = content.navigator.requestMIDIAccess({ sysex: true, }); }); info("Accept site permission addon install"); const addonInstallPanel = await onAddonInstallBlockedNotification; const notification = addonInstallPanel.childNodes[0]; const dialogPromise = waitForInstallDialog(); notification.button.click(); let installDialog = await dialogPromise; installDialog.button.click(); info("Wait for the midi-sysex access request promise to resolve"); const accessGranted = await SpecialPowers.spawn( sameOriginIframeBrowsingContext, [], async () => { try { await content.midiAccessRequestPromise; return true; } catch (e) {} delete content.midiAccessRequestPromise; return false; } ); ok(accessGranted, "requestMIDIAccess resolved"); info("Check that midi-sysex is now set"); ok( await SpecialPowers.testPermission( "midi-sysex", SpecialPowers.Services.perms.ALLOW_ACTION, { url: PAGE_WITH_IFRAMES_URL } ), "midi-sysex value should have ALLOW permission" ); info( "Check that we don't prompt user again once they installed the addon from the same-origin iframe" ); const accessPromiseState = await SpecialPowers.spawn( gBrowser.selectedBrowser, [], async () => { return content.navigator .requestMIDIAccess({ sysex: true }) .then(() => "resolved"); } ); is( accessPromiseState, "resolved", "requestMIDIAccess resolved without user prompt" ); assertSitePermissionInstallTelemetryEvents([ "site_warning", "permissions_prompt", "completed", ]); info("Check that request is rejected when done from a cross-origin iframe"); const crossOriginIframeBrowsingContext = await SpecialPowers.spawn( gBrowser.selectedBrowser, [], async () => { return content.document.getElementById("crossOrigin").browsingContext; } ); const onConsoleErrorMessage = new Promise(resolve => { const errorListener = { observe(error) { if (error.message.includes("WebMIDI access request was denied")) { resolve(error); Services.console.unregisterListener(errorListener); } }, }; Services.console.registerListener(errorListener); }); const rejectionMessage = await SpecialPowers.spawn( crossOriginIframeBrowsingContext, [], async () => { let errorName; try { await content.navigator.requestMIDIAccess({ sysex: true, }); } catch (e) { errorName = e.name; } return errorName; } ); is( rejectionMessage, "SecurityError", "requestMIDIAccess from the remote iframe was rejected" ); const consoleErrorMessage = await onConsoleErrorMessage; ok( consoleErrorMessage.message.includes( `WebMIDI access request was denied: ❝SitePermsAddons can't be installed from cross origin subframes❞`, "an error message is sent to the console" ) ); assertSitePermissionInstallTelemetryEvents([]); }); add_task(async function testRequestMIDIAccessLocalhost() { const httpServer = new HttpServer(); httpServer.start(-1); httpServer.registerPathHandler(`/test`, function (request, response) { response.setStatusLine(request.httpVersion, 200, "OK"); response.write(`

Test requestMIDIAccess on lcoalhost

`); }); const localHostTestUrl = `http://localhost:${httpServer.identity.primaryPort}/test`; registerCleanupFunction(async function cleanup() { await new Promise(resolve => httpServer.stop(resolve)); }); gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser, localHostTestUrl); await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); info("Check that midi-sysex isn't set"); ok( await SpecialPowers.testPermission( "midi-sysex", SpecialPowers.Services.perms.UNKNOWN_ACTION, { url: localHostTestUrl } ), "midi-sysex value should have UNKNOWN permission" ); info( "Request midi-sysex access should not prompt for addon install on locahost, but for permission" ); let popupShown = BrowserTestUtils.waitForEvent( PopupNotifications.panel, "popupshown" ); await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { content.midiAccessRequestPromise = content.navigator.requestMIDIAccess({ sysex: true, }); }); await popupShown; is( PopupNotifications.panel.querySelector("popupnotification").id, "midi-notification", "midi notification was displayed" ); info("Accept permission"); PopupNotifications.panel .querySelector(".popup-notification-primary-button") .click(); info("Wait for the midi-sysex access request promise to resolve"); const accessGranted = await SpecialPowers.spawn( gBrowser.selectedBrowser, [], async () => { try { await content.midiAccessRequestPromise; return true; } catch (e) {} delete content.midiAccessRequestPromise; return false; } ); ok(accessGranted, "requestMIDIAccess resolved"); info("Check that we prompt user again even if they accepted before"); popupShown = BrowserTestUtils.waitForEvent( PopupNotifications.panel, "popupshown" ); await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async () => { content.navigator.requestMIDIAccess({ sysex: true }); }); await popupShown; is( PopupNotifications.panel.querySelector("popupnotification").id, "midi-notification", "midi notification was displayed again" ); assertSitePermissionInstallTelemetryEvents([]); }); add_task(async function testDisabledRequestMIDIAccessFile() { let dir = getChromeDir(getResolvedURI(gTestPath)); dir.append("blank.html"); const fileSchemeTestUri = Services.io.newFileURI(dir).spec; gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser, fileSchemeTestUri); await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); info("Check that requestMIDIAccess isn't set on navigator on file scheme"); const isRequestMIDIAccessDefined = await SpecialPowers.spawn( gBrowser.selectedBrowser, [], () => { return "requestMIDIAccess" in content.wrappedJSObject.navigator; } ); is( isRequestMIDIAccessDefined, false, "navigator.requestMIDIAccess is not defined on file scheme" ); }); // Ignore any additional telemetry events collected in this file. // Unfortunately it doesn't work to have this in a cleanup function. // Keep this as the last task done. add_task(function teardown_telemetry_events() { AddonTestUtils.getAMTelemetryEvents(); }); /** * Check that the expected sitepermission install events are recorded. * * @param {Array} expectedSteps: An array of the expected extra.step values recorded. */ function assertSitePermissionInstallTelemetryEvents( expectedSteps, events = null ) { let amInstallEvents = (events ?? AddonTestUtils.getAMTelemetryEvents()) .filter(evt => evt.method === "install" && evt.object === "sitepermission") .map(evt => evt.extra.step); Assert.deepEqual(amInstallEvents, expectedSteps); } async function waitForInstallDialog(id = "addon-webext-permissions") { let panel = await waitForNotification(id); return panel.childNodes[0]; } /** * Adds an event listener that will listen for post-install dialog event and automatically * close the dialogs. */ function alwaysAcceptAddonPostInstallDialogs() { // Once the addon is installed, a dialog is displayed as a confirmation. // This could interfere with tests running after this one, so we set up a listener // that will always accept post install dialogs so we don't have to deal with them in // the test. const abortController = new AbortController(); const { AppMenuNotifications } = ChromeUtils.importESModule( "resource://gre/modules/AppMenuNotifications.sys.mjs" ); info("Start listening and accept addon post-install notifications"); PanelUI.notificationPanel.addEventListener( "popupshown", async function popupshown() { let notification = AppMenuNotifications.activeNotification; if (!notification || notification.id !== "addon-installed") { return; } let popupnotificationID = PanelUI._getPopupId(notification); if (popupnotificationID) { info("Accept post-install dialog"); let popupnotification = document.getElementById(popupnotificationID); popupnotification?.button.click(); } }, { signal: abortController.signal, } ); registerCleanupFunction(async () => { // Clear the listener at the end of the test file, to prevent it to stay // around when the same browser instance may be running other unrelated // test files. abortController.abort(); }); } const PROGRESS_NOTIFICATION = "addon-progress"; async function waitForNotification(notificationId) { info(`Waiting for ${notificationId} notification`); let topic = getObserverTopic(notificationId); let observerPromise; if (notificationId !== "addon-webext-permissions") { observerPromise = new Promise(resolve => { Services.obs.addObserver(function observer(aSubject, aTopic, aData) { // Ignore the progress notification unless that is the notification we want if ( notificationId != PROGRESS_NOTIFICATION && aTopic == getObserverTopic(PROGRESS_NOTIFICATION) ) { return; } Services.obs.removeObserver(observer, topic); resolve(); }, topic); }); } let panelEventPromise = new Promise(resolve => { window.PopupNotifications.panel.addEventListener( "PanelUpdated", function eventListener(e) { // Skip notifications that are not the one that we are supposed to be looking for if (!e.detail.includes(notificationId)) { return; } window.PopupNotifications.panel.removeEventListener( "PanelUpdated", eventListener ); resolve(); } ); }); await observerPromise; await panelEventPromise; await waitForTick(); info(`Saw a ${notificationId} notification`); await SimpleTest.promiseFocus(window.PopupNotifications.window); return window.PopupNotifications.panel; } // This function is similar to the one in // toolkit/mozapps/extensions/test/xpinstall/browser_doorhanger_installs.js, // please keep both in sync! function getObserverTopic(aNotificationId) { let topic = aNotificationId; if (topic == "xpinstall-disabled") { topic = "addon-install-disabled"; } else if (topic == "addon-progress") { topic = "addon-install-started"; } else if (topic == "addon-installed") { topic = "webextension-install-notify"; } return topic; } function waitForTick() { return new Promise(resolve => executeSoon(resolve)); }