diff options
Diffstat (limited to 'dom/midi/tests/browser_midi_permission_gated.js')
-rw-r--r-- | dom/midi/tests/browser_midi_permission_gated.js | 827 |
1 files changed, 827 insertions, 0 deletions
diff --git a/dom/midi/tests/browser_midi_permission_gated.js b/dom/midi/tests/browser_midi_permission_gated.js new file mode 100644 index 0000000000..35efd30fc7 --- /dev/null +++ b/dom/midi/tests/browser_midi_permission_gated.js @@ -0,0 +1,827 @@ +/* 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=<h1>Test midi permission with synthetic site permission addon</h1>"; +const PAGE_WITH_IFRAMES_URL = `https://example.org/document-builder.sjs?html= + <h1>Test midi permission with synthetic site permission addon in iframes</h1> + <iframe id=sameOrigin src="${encodeURIComponent( + 'https://example.org/document-builder.sjs?html=SameOrigin"' + )}"></iframe> + <iframe id=crossOrigin src="${encodeURIComponent( + 'https://example.net/document-builder.sjs?html=CrossOrigin"' + )}"></iframe>`; + +const { HttpServer } = ChromeUtils.import("resource://testing-common/httpd.js"); +ChromeUtils.defineModuleGetter( + this, + "AddonTestUtils", + "resource://testing-common/AddonTestUtils.jsm" +); + +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, + gNavigatorBundle.getString("sitePermissionInstallFirstPrompt.header"), + "First popup has expected header text" + ); + is( + installPopupMessage.textContent, + gNavigatorBundle.getString("sitePermissionInstallFirstPrompt.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, + gNavigatorBundle.getFormattedString( + "webextSitePerms.headerWithGatedPerms.midi-sysex", + [testPageHost] + ), + "Install dialog has expected header text" + ); + is( + installDialog.querySelector("popupnotificationcontent description") + .textContent, + gNavigatorBundle.getFormattedString( + "webextSitePerms.descriptionGatedPerms", + [testPageHost] + ), + "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, + gNavigatorBundle.getFormattedString( + "webextSitePerms.headerWithGatedPerms.midi", + [testPageHost] + ), + "Install dialog has expected header text" + ); + is( + installDialog.querySelector("popupnotificationcontent description") + .textContent, + gNavigatorBundle.getFormattedString( + "webextSitePerms.descriptionGatedPerms", + [testPageHost] + ), + "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(` + <!DOCTYPE html> + <meta charset=utf8> + <h1>Test requestMIDIAccess on lcoalhost</h1>`); + }); + 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<String>} 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; +} + +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-install-restart") { + topic = "addon-install-complete"; + } else if (topic == "addon-installed") { + topic = "webextension-install-notify"; + } + return topic; +} + +function waitForTick() { + return new Promise(resolve => executeSoon(resolve)); +} |