/* 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 serial permission with synthetic site permission addon

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

Test serial 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.importESModule( "resource://testing-common/httpd.sys.mjs" ); ChromeUtils.defineESModuleGetters(this, { AddonTestUtils: "resource://testing-common/AddonTestUtils.sys.mjs", }); add_setup(async function () { await SpecialPowers.pushPrefEnv({ set: [["dom.webserial.gated", true]], }); AddonTestUtils.initMochitest(this); AddonTestUtils.hookAMTelemetryEvents(); alwaysAcceptAddonPostInstallDialogs(); registerCleanupFunction(async () => { await SpecialPowers.removePermission("serial", { url: EXAMPLE_COM_URL, }); await SpecialPowers.removePermission("serial", { 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 testRequestPort() { await BrowserTestUtils.openNewForegroundTab(gBrowser, EXAMPLE_COM_URL); const testPageHost = gBrowser.selectedTab.linkedBrowser.documentURI.host; Services.fog.testResetFOG(); ok( await SpecialPowers.testPermission( "serial", SpecialPowers.Services.perms.UNKNOWN_ACTION, { url: EXAMPLE_COM_URL } ), "serial value should have UNKNOWN permission" ); info("Request serial port access"); let onAddonInstallBlockedNotification = waitForNotification( "addon-install-blocked" ); await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { SpecialPowers.wrap(content.document).notifyUserGestureActivation(); content.portRequestPromise = content.navigator.serial.requestPort(); }); 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-serial-header"), "First popup has expected header text" ); is( installPopupMessage.textContent, l10n.formatValueSync("site-permission-install-first-prompt-serial-message"), "First popup has expected message" ); let notification = addonInstallPanel.childNodes[0]; notification.secondaryButton.click(); let rejectionMessage = await SpecialPowers.spawn( gBrowser.selectedBrowser, [], async () => { let errorMessage; try { await content.portRequestPromise; } catch (e) { errorMessage = `${e.name}: ${e.message}`; } delete content.portRequestPromise; return errorMessage; } ); is( rejectionMessage, "SecurityError: WebSerial 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, [], () => { SpecialPowers.wrap(content.document).notifyUserGestureActivation(); content.portRequestPromise = content.navigator.serial.requestPort(); }); 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-serial", { hostname: testPageHost, }), "Install dialog has expected header text" ); is( installDialog.querySelector("popupnotificationcontent description") .textContent, l10n.formatValueSync("webext-site-perms-description-gated-perms-serial"), "Install dialog has expected description" ); installDialog.secondaryButton.click(); rejectionMessage = await SpecialPowers.spawn( gBrowser.selectedBrowser, [], async () => { let errorMessage; try { await content.portRequestPromise; } catch (e) { errorMessage = `${e.name}: ${e.message}`; } delete content.portRequestPromise; return errorMessage; } ); is( rejectionMessage, "SecurityError: WebSerial requires a site permission add-on to activate", "got expected SecurityError when rejecting add-on" ); assertSitePermissionInstallTelemetryEvents([ "site_warning", "permissions_prompt", "cancelled", ]); info("Request serial port access again"); onAddonInstallBlockedNotification = waitForNotification( "addon-install-blocked" ); await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { content.navigator.serial.autoselectPorts = true; SpecialPowers.wrap(content.document).notifyUserGestureActivation(); content.portRequestPromise = content.navigator.serial.requestPort(); }); info("Accept site permission addon install"); addonInstallPanel = await onAddonInstallBlockedNotification; notification = addonInstallPanel.childNodes[0]; dialogPromise = waitForInstallDialog(); 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" ); notification.button.click(); installDialog = await dialogPromise; installDialog.button.click(); info("Wait for the serial port access request promise to resolve"); let accessGranted = await SpecialPowers.spawn( gBrowser.selectedBrowser, [], async () => { try { await content.portRequestPromise; return true; } catch (e) {} delete content.portRequestPromise; return false; } ); ok(accessGranted, "requestPort resolved"); ok( await SpecialPowers.testPermission( "serial", SpecialPowers.Services.perms.ALLOW_ACTION, { url: EXAMPLE_COM_URL } ), "serial value should have ALLOW permission" ); info("Check that we don't prompt user again once they installed the addon"); const accessPromiseState = await SpecialPowers.spawn( gBrowser.selectedBrowser, [], () => { SpecialPowers.wrap(content.document).notifyUserGestureActivation(); return content.navigator.serial.requestPort().then(() => "resolved"); } ); is( accessPromiseState, "resolved", "requestPort resolved without user prompt" ); assertSitePermissionInstallTelemetryEvents([ "site_warning", "permissions_prompt", "completed", ]); info("Check that we don't prompt user again when they perm denied"); await SpecialPowers.removePermission("serial", { url: EXAMPLE_COM_URL, }); onAddonInstallBlockedNotification = waitForNotification( "addon-install-blocked" ); await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { SpecialPowers.wrap(content.document).notifyUserGestureActivation(); content.portRequestPromise = content.navigator.serial.requestPort(); }); info("Perm-deny site permission addon install"); addonInstallPanel = await onAddonInstallBlockedNotification; notification = addonInstallPanel.childNodes[0]; notification.menupopup.querySelectorAll("menuitem")[1].click(); rejectionMessage = await SpecialPowers.spawn( gBrowser.selectedBrowser, [], async () => { let errorMessage; try { await content.portRequestPromise; } catch (e) { errorMessage = e.name; } delete content.portRequestPromise; return errorMessage; } ); is(rejectionMessage, "SecurityError", "requestPort was rejected"); info("Request serial port access again"); let denyIntervalStart = performance.now(); rejectionMessage = await SpecialPowers.spawn( gBrowser.selectedBrowser, [], async () => { SpecialPowers.wrap(content.document).notifyUserGestureActivation(); let errorMessage; try { await content.navigator.serial.requestPort(); } catch (e) { errorMessage = e.name; } return errorMessage; } ); is( rejectionMessage, "SecurityError", "requestPort was rejected without user prompt" ); let denyIntervalElapsed = performance.now() - denyIntervalStart; Assert.greaterOrEqual( denyIntervalElapsed, 3000, `Rejection should be delayed by a randomized interval no less than 3 seconds (got ${ denyIntervalElapsed / 1000 } seconds)` ); Assert.deepEqual( [{ suspicious_site: "example.com" }], AddonTestUtils.getAMGleanEvents("reportSuspiciousSite"), "Expected Glean event recorded." ); assertSitePermissionInstallTelemetryEvents(["site_warning", "cancelled"]); }); add_task(async function testIframeRequestPort() { await BrowserTestUtils.openNewForegroundTab(gBrowser, PAGE_WITH_IFRAMES_URL); info("Check that serial permission isn't set"); ok( await SpecialPowers.testPermission( "serial", SpecialPowers.Services.perms.UNKNOWN_ACTION, { url: PAGE_WITH_IFRAMES_URL } ), "serial value should have UNKNOWN permission" ); info("Request serial port 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.navigator.serial.autoselectPorts = true; SpecialPowers.wrap(content.document).notifyUserGestureActivation(); content.portRequestPromise = content.navigator.serial.requestPort(); }); 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 serial port access request promise to resolve"); const accessGranted = await SpecialPowers.spawn( sameOriginIframeBrowsingContext, [], async () => { try { await content.portRequestPromise; return true; } catch (e) {} delete content.portRequestPromise; return false; } ); ok(accessGranted, "requestPort resolved"); info("Check that serial permission is now set"); ok( await SpecialPowers.testPermission( "serial", SpecialPowers.Services.perms.ALLOW_ACTION, { url: PAGE_WITH_IFRAMES_URL } ), "serial 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, [], () => { content.navigator.serial.autoselectPorts = true; SpecialPowers.wrap(content.document).notifyUserGestureActivation(); return content.navigator.serial.requestPort().then(() => "resolved"); } ); is( accessPromiseState, "resolved", "requestPort 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("WebSerial access request was denied")) { resolve(error); Services.console.unregisterListener(errorListener); } }, }; Services.console.registerListener(errorListener); }); const rejectionMessage = await SpecialPowers.spawn( crossOriginIframeBrowsingContext, [], async () => { SpecialPowers.wrap(content.document).notifyUserGestureActivation(); let errorName; try { await content.navigator.serial.requestPort(); } catch (e) { errorName = e.name; } return errorName; } ); is( rejectionMessage, "SecurityError", "requestPort from the remote iframe was rejected" ); const consoleErrorMessage = await onConsoleErrorMessage; ok( consoleErrorMessage.message.includes( "WebSerial access request was denied:" ), "an error message is sent to the console" ); assertSitePermissionInstallTelemetryEvents([]); }); add_task(async function testRequestPortLocalhost() { const httpServer = new HttpServer(); httpServer.start(-1); httpServer.registerPathHandler(`/test`, function (request, response) { response.setStatusLine(request.httpVersion, 200, "OK"); response.write(`

Test requestPort on localhost

`); }); const localHostTestUrl = `http://localhost:${httpServer.identity.primaryPort}/test`; registerCleanupFunction(async function cleanup() { await new Promise(resolve => httpServer.stop(resolve)); }); await BrowserTestUtils.openNewForegroundTab(gBrowser, localHostTestUrl); ok( await SpecialPowers.testPermission( "serial", SpecialPowers.Services.perms.UNKNOWN_ACTION, { url: localHostTestUrl } ), "serial value should have UNKNOWN permission" ); info( "Request serial port access should not prompt for addon install on localhost, but for permission" ); let popupShown = BrowserTestUtils.waitForEvent( PopupNotifications.panel, "popupshown" ); await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { content.navigator.serial.autoselectPorts = false; SpecialPowers.wrap(content.document).notifyUserGestureActivation(); content.portRequestPromise = content.navigator.serial.requestPort(); }); await popupShown; is( PopupNotifications.panel.querySelector("popupnotification").id, "webSerial-choosePort-notification", "webserial notification was displayed" ); info("Accept permission"); PopupNotifications.panel .querySelector(".popup-notification-primary-button") .click(); info("Wait for the serial port access request promise to resolve"); let accessGranted = await SpecialPowers.spawn( gBrowser.selectedBrowser, [], async () => { try { await content.portRequestPromise; return true; } catch (e) {} delete content.portRequestPromise; return false; } ); ok(accessGranted, "requestPort resolved"); info( "Check that requestPort() shows the chooser again (per spec, it always requires user interaction)" ); popupShown = BrowserTestUtils.waitForEvent( PopupNotifications.panel, "popupshown" ); await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { SpecialPowers.wrap(content.document).notifyUserGestureActivation(); content.portRequestPromise = content.navigator.serial.requestPort(); }); await popupShown; is( PopupNotifications.panel.querySelector("popupnotification").id, "webSerial-choosePort-notification", "webserial notification was displayed again" ); info("Accept permission again"); PopupNotifications.panel .querySelector(".popup-notification-primary-button") .click(); accessGranted = await SpecialPowers.spawn( gBrowser.selectedBrowser, [], async () => { try { await content.portRequestPromise; return true; } catch (e) {} delete content.portRequestPromise; return false; } ); ok(accessGranted, "requestPort resolved after user interaction"); info("Check that blocking the requestPort() chooser returns the right error"); popupShown = BrowserTestUtils.waitForEvent( PopupNotifications.panel, "popupshown" ); await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { SpecialPowers.wrap(content.document).notifyUserGestureActivation(); content.portRequestPromise = content.navigator.serial.requestPort(); }); await popupShown; is( PopupNotifications.panel.querySelector("popupnotification").id, "webSerial-choosePort-notification", "webserial notification was displayed again" ); info("Block permission"); PopupNotifications.panel .querySelector(".popup-notification-secondary-button") .click(); let errorInfo = await SpecialPowers.spawn( gBrowser.selectedBrowser, [], async () => { let errorName, errorMessage; try { await content.portRequestPromise; } catch (e) { errorName = e.name; errorMessage = e.message; } delete content.portRequestPromise; return { name: errorName, message: errorMessage }; } ); is(errorInfo.name, "NotFoundError", "Rejection is NotFoundError"); is(errorInfo.message, "No port selected", "Error message is correct"); assertSitePermissionInstallTelemetryEvents([]); }); add_task(async function testRequestPortFile() { let dir = getChromeDir(getResolvedURI(gTestPath)); dir.append("blank.html"); const fileSchemeTestUri = Services.io.newFileURI(dir).spec; await BrowserTestUtils.openNewForegroundTab(gBrowser, fileSchemeTestUri); ok( await SpecialPowers.testPermission( "serial", SpecialPowers.Services.perms.UNKNOWN_ACTION, { url: fileSchemeTestUri } ), "serial value should have UNKNOWN permission" ); info( "Request serial port access should not prompt for addon install on file, but for permission" ); let popupShown = BrowserTestUtils.waitForEvent( PopupNotifications.panel, "popupshown" ); await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { content.navigator.serial.autoselectPorts = false; SpecialPowers.wrap(content.document).notifyUserGestureActivation(); content.portRequestPromise = content.navigator.serial.requestPort(); }); await popupShown; is( PopupNotifications.panel.querySelector("popupnotification").id, "webSerial-choosePort-notification", "webserial notification was displayed" ); info("Accept permission"); PopupNotifications.panel .querySelector(".popup-notification-primary-button") .click(); info("Wait for the serial port access request promise to resolve"); let accessGranted = await SpecialPowers.spawn( gBrowser.selectedBrowser, [], async () => { try { await content.portRequestPromise; return true; } catch (e) {} delete content.portRequestPromise; return false; } ); ok(accessGranted, "requestPort resolved"); info( "Check that requestPort() shows the chooser again (per spec, it always requires user interaction)" ); popupShown = BrowserTestUtils.waitForEvent( PopupNotifications.panel, "popupshown" ); await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { SpecialPowers.wrap(content.document).notifyUserGestureActivation(); content.portRequestPromise = content.navigator.serial.requestPort(); }); await popupShown; is( PopupNotifications.panel.querySelector("popupnotification").id, "webSerial-choosePort-notification", "webserial notification was displayed again" ); info("Accept permission again"); PopupNotifications.panel .querySelector(".popup-notification-primary-button") .click(); accessGranted = await SpecialPowers.spawn( gBrowser.selectedBrowser, [], async () => { try { await content.portRequestPromise; return true; } catch (e) {} delete content.portRequestPromise; return false; } ); ok(accessGranted, "requestPort resolved after user interaction"); info("Check that blocking the requestPort() chooser returns the right error"); popupShown = BrowserTestUtils.waitForEvent( PopupNotifications.panel, "popupshown" ); await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { SpecialPowers.wrap(content.document).notifyUserGestureActivation(); content.portRequestPromise = content.navigator.serial.requestPort(); }); await popupShown; is( PopupNotifications.panel.querySelector("popupnotification").id, "webSerial-choosePort-notification", "webserial notification was displayed again" ); info("Block permission"); PopupNotifications.panel .querySelector(".popup-notification-secondary-button") .click(); let errorInfo = await SpecialPowers.spawn( gBrowser.selectedBrowser, [], async () => { let errorName, errorMessage; try { await content.portRequestPromise; } catch (e) { errorName = e.name; errorMessage = e.message; } delete content.portRequestPromise; return { name: errorName, message: errorMessage }; } ); is(errorInfo.name, "NotFoundError", "Rejection is NotFoundError"); is(errorInfo.message, "No port selected", "Error message is correct"); assertSitePermissionInstallTelemetryEvents([]); }); add_task(function teardown_telemetry_events() { AddonTestUtils.getAMTelemetryEvents(); }); function assertSitePermissionInstallTelemetryEvents(expectedSteps) { let amInstallEvents = AddonTestUtils.getAMTelemetryEvents() .filter(evt => evt.method === "install" && evt.object === "sitepermission") .map(evt => evt.extra.step); Assert.deepEqual( amInstallEvents, expectedSteps, "got expected site permission install telemetry events" ); } async function waitForInstallDialog(id = "addon-webext-permissions") { let panel = await waitForNotification(id); return panel.childNodes[0]; } function alwaysAcceptAddonPostInstallDialogs() { 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 () => { abortController.abort(); }); } 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) { Services.obs.removeObserver(observer, topic); resolve(); }, topic); }); } let panelEventPromise = new Promise(resolve => { window.PopupNotifications.panel.addEventListener( "PanelUpdated", function eventListener(e) { 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-installed") { topic = "webextension-install-notify"; } return topic; } function waitForTick() { return new Promise(resolve => executeSoon(resolve)); }