diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 19:33:14 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 19:33:14 +0000 |
commit | 36d22d82aa202bb199967e9512281e9a53db42c9 (patch) | |
tree | 105e8c98ddea1c1e4784a60a5a6410fa416be2de /uriloader/exthandler/tests/mochitest | |
parent | Initial commit. (diff) | |
download | firefox-esr-36d22d82aa202bb199967e9512281e9a53db42c9.tar.xz firefox-esr-36d22d82aa202bb199967e9512281e9a53db42c9.zip |
Adding upstream version 115.7.0esr.upstream/115.7.0esr
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to '')
81 files changed, 9039 insertions, 0 deletions
diff --git a/uriloader/exthandler/tests/mochitest/FTPprotocolHandler.html b/uriloader/exthandler/tests/mochitest/FTPprotocolHandler.html new file mode 100644 index 0000000000..d0e98abace --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/FTPprotocolHandler.html @@ -0,0 +1,16 @@ +<!DOCTYPE html> +<html> + <head> + <title>Protocol handler</title> + <meta content="text/html;charset=utf-8" http-equiv="Content-Type"> + <meta content="utf-8" http-equiv="encoding"> + </head> + <body> + <script type="text/javascript"> + navigator.registerProtocolHandler("ftp", + "https://example.com/browser/uriloader/exthandler/tests/mochitest/blank.html?uri=%s", + "Test Protocol"); + </script> + <a id="link" href="ftp://user:password@domain.com/path">ftp link</a> + </body> +</html> diff --git a/uriloader/exthandler/tests/mochitest/HelperAppLauncherDialog_chromeScript.js b/uriloader/exthandler/tests/mochitest/HelperAppLauncherDialog_chromeScript.js new file mode 100644 index 0000000000..4f66c99dc6 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/HelperAppLauncherDialog_chromeScript.js @@ -0,0 +1,104 @@ +/* eslint-env mozilla/chrome-script */ + +const { ComponentUtils } = ChromeUtils.importESModule( + "resource://gre/modules/ComponentUtils.sys.mjs" +); + +const { Downloads } = ChromeUtils.importESModule( + "resource://gre/modules/Downloads.sys.mjs" +); + +let gMIMEService = Cc["@mozilla.org/mime;1"].getService(Ci.nsIMIMEService); +let gHandlerService = Cc["@mozilla.org/uriloader/handler-service;1"].getService( + Ci.nsIHandlerService +); + +const HELPERAPP_DIALOG_CONTRACT = "@mozilla.org/helperapplauncherdialog;1"; +const HELPERAPP_DIALOG_CID = Components.ID( + Cc[HELPERAPP_DIALOG_CONTRACT].number +); + +let tmpDir = Services.dirsvc.get("TmpD", Ci.nsIFile); +tmpDir.append("testsavedir" + Math.floor(Math.random() * 2 ** 32)); +// Create this dir if it doesn't exist (ignores existing dirs) +try { + tmpDir.create(Ci.nsIFile.DIRECTORY_TYPE, 0o777, true); +} catch (ex) { + if (ex.result != Cr.NS_ERROR_FILE_ALREADY_EXISTS) { + throw ex; + } +} +Services.prefs.setIntPref("browser.download.folderList", 2); +Services.prefs.setCharPref("browser.download.dir", tmpDir.path); + +const FAKE_CID = Services.uuid.generateUUID(); +function HelperAppLauncherDialog() {} +HelperAppLauncherDialog.prototype = { + show(aLauncher, aWindowContext, aReason) { + if ( + Services.prefs.getBoolPref( + "browser.download.always_ask_before_handling_new_types" + ) + ) { + let f = tmpDir.clone(); + f.append(aLauncher.suggestedFileName); + aLauncher.saveDestinationAvailable(f); + sendAsyncMessage("suggestedFileName", aLauncher.suggestedFileName); + } else { + sendAsyncMessage("wrongAPICall", "show"); + } + aLauncher.cancel(Cr.NS_BINDING_ABORTED); + }, + promptForSaveToFileAsync( + appLauncher, + parent, + filename, + extension, + forceSave + ) { + if ( + !Services.prefs.getBoolPref( + "browser.download.always_ask_before_handling_new_types" + ) + ) { + let f = tmpDir.clone(); + f.append(filename); + appLauncher.saveDestinationAvailable(f); + sendAsyncMessage("suggestedFileName", filename); + } else { + sendAsyncMessage("wrongAPICall", "promptForSaveToFileAsync"); + } + appLauncher.cancel(Cr.NS_BINDING_ABORTED); + }, + QueryInterface: ChromeUtils.generateQI(["nsIHelperAppLauncherDialog"]), +}; + +var registrar = Components.manager.QueryInterface(Ci.nsIComponentRegistrar); +registrar.registerFactory( + FAKE_CID, + "", + HELPERAPP_DIALOG_CONTRACT, + ComponentUtils.generateSingletonFactory(HelperAppLauncherDialog) +); + +addMessageListener("unregister", async function () { + registrar.registerFactory( + HELPERAPP_DIALOG_CID, + "", + HELPERAPP_DIALOG_CONTRACT, + null + ); + let list = await Downloads.getList(Downloads.ALL); + let downloads = await list.getAll(); + for (let dl of downloads) { + await dl.refresh(); + if (dl.target.exists || dl.target.partFileExists) { + dump("Finalizing download.\n"); + await dl.finalize(true).catch(console.error); + } + } + await list.removeFinished(); + dump("Clearing " + tmpDir.path + "\n"); + tmpDir.remove(true); + sendAsyncMessage("unregistered"); +}); diff --git a/uriloader/exthandler/tests/mochitest/blank.html b/uriloader/exthandler/tests/mochitest/blank.html new file mode 100644 index 0000000000..5417098d72 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/blank.html @@ -0,0 +1,11 @@ +<!DOCTYPE html> +<html> +<head> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <title></title> +</head> +<body> + +</body> +</html> diff --git a/uriloader/exthandler/tests/mochitest/browser.ini b/uriloader/exthandler/tests/mochitest/browser.ini new file mode 100644 index 0000000000..22bae61c78 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/browser.ini @@ -0,0 +1,130 @@ +[DEFAULT] +head = head.js +support-files = + protocolHandler.html + + +[browser_auto_close_window.js] +support-files = + download_page.html + download.bin + download.sjs +[browser_auto_close_window_nodialog.js] +support-files = + download_page.html + download.bin + download.sjs +[browser_bad_download_dir.js] +run-if = os == 'linux' +support-files = download.bin +[browser_download_always_ask_preferred_app.js] +[browser_download_idn_blocklist.js] +support-files = download.bin +[browser_download_open_with_internal_handler.js] +support-files = + file_image_svgxml.svg + file_image_svgxml.svg^headers^ + file_pdf_application_pdf.pdf + file_pdf_application_pdf.pdf^headers^ + file_pdf_application_unknown.pdf + file_pdf_application_unknown.pdf^headers^ + file_pdf_application_octet_stream.pdf + file_pdf_application_octet_stream.pdf^headers^ + file_pdf_binary_octet_stream.pdf + file_pdf_binary_octet_stream.pdf^headers^ + file_txt_attachment_test.txt + file_txt_attachment_test.txt^headers^ + file_xml_attachment_binary_octet_stream.xml + file_xml_attachment_binary_octet_stream.xml^headers^ + file_xml_attachment_test.xml + file_xml_attachment_test.xml^headers^ + file_green.webp + file_green.webp^headers^ +[browser_download_preferred_action.js] +support-files = + mime_type_download.sjs +skip-if = + os == "win" && os_version == "6.1" # Skip on Azure - frequent failure +[browser_download_privatebrowsing.js] +[browser_download_skips_dialog.js] +support-files = + file_green.webp + file_green.webp^headers^ +[browser_download_spam_permissions.js] +support-files = + test_spammy_page.html +[browser_download_urlescape.js] +support-files = + file_with@@funny_name.png + file_with@@funny_name.png^headers^ + file_with[funny_name.webm + file_with[funny_name.webm^headers^ +[browser_extension_correction.js] +support-files = + file_as.exe + file_as.exe^headers^ +[browser_filehandling_loop.js] +[browser_launched_app_save_directory.js] +support-files = + file_pdf_application_pdf.pdf + file_pdf_application_pdf.pdf^headers^ + file_green.webp + file_green.webp^headers^ +[browser_local_files_no_save_without_asking.js] +support-files = + file_pdf_binary_octet_stream.pdf +[browser_local_files_open_doesnt_duplicate.js] +support-files = + file_pdf_binary_octet_stream.pdf +[browser_shows_where_to_save_dialog.js] +support-files = + file_green.webp + file_green.webp^headers^ + file_pdf_application_pdf.pdf + file_pdf_application_pdf.pdf^headers^ + file_txt_attachment_test.txt + file_txt_attachment_test.txt^headers^ +[browser_open_internal_choice_persistence.js] +skip-if = + apple_silicon # bug 1752482 +support-files = + file_pdf_application_pdf.pdf + file_pdf_application_pdf.pdf^headers^ +[browser_pdf_save_as.js] +[browser_protocol_ask_dialog.js] +support-files = + file_nested_protocol_request.html +[browser_protocol_custom_sandbox.js] +support-files = + protocol_custom_sandbox_helper.sjs +[browser_protocol_custom_sandbox_csp.js] +support-files = + protocol_custom_sandbox_helper.sjs +[browser_first_prompt_not_blocked_without_user_interaction.js] +support-files = + file_external_protocol_iframe.html +[browser_protocol_ask_dialog_external.js] +support-files = + redirect_helper.sjs +[browser_protocol_ask_dialog_permission.js] +support-files = + redirect_helper.sjs + script_redirect.html +[browser_protocolhandler_loop.js] +[browser_remember_download_option.js] +[browser_save_filenames.js] +support-files = + save_filenames.html +[browser_txt_download_save_as.js] +support-files = + file_txt_attachment_test.txt + file_txt_attachment_test.txt^headers^ + !/toolkit/content/tests/browser/common/mockTransfer.js +[browser_web_handler_app_pinned_tab.js] +support-files = + mailto.html +[browser_web_protocol_handlers.js] +[browser_ftp_protocol_handlers.js] +support-files = + FTPprotocolHandler.html + blank.html diff --git a/uriloader/exthandler/tests/mochitest/browser_auto_close_window.js b/uriloader/exthandler/tests/mochitest/browser_auto_close_window.js new file mode 100644 index 0000000000..af60b59c4b --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/browser_auto_close_window.js @@ -0,0 +1,342 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { ComponentUtils } = ChromeUtils.importESModule( + "resource://gre/modules/ComponentUtils.sys.mjs" +); + +const ROOT = getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "https://example.com" +); +const PAGE_URL = ROOT + "download_page.html"; +const SJS_URL = ROOT + "download.sjs"; + +const HELPERAPP_DIALOG_CONTRACT_ID = "@mozilla.org/helperapplauncherdialog;1"; +const HELPERAPP_DIALOG_CID = Components.ID( + Cc[HELPERAPP_DIALOG_CONTRACT_ID].number +); +const MOCK_HELPERAPP_DIALOG_CID = Components.ID( + "{2f372b6f-56c9-46d5-af0d-9f09bb69860c}" +); + +let registrar = Components.manager.QueryInterface(Ci.nsIComponentRegistrar); +let curDialogResolve = null; + +function HelperAppLauncherDialog() {} + +HelperAppLauncherDialog.prototype = { + show(aLauncher, aWindowContext, aReason) { + ok(true, "Showing the helper app dialog"); + curDialogResolve(aWindowContext); + executeSoon(() => { + aLauncher.cancel(Cr.NS_ERROR_ABORT); + }); + }, + QueryInterface: ChromeUtils.generateQI(["nsIHelperAppLauncherDialog"]), +}; + +function promiseHelperAppDialog() { + return new Promise(resolve => { + curDialogResolve = resolve; + }); +} + +let mockHelperAppService; + +add_setup(async function () { + // Replace the real helper app dialog with our own. + mockHelperAppService = ComponentUtils.generateSingletonFactory( + HelperAppLauncherDialog + ); + registrar.registerFactory( + MOCK_HELPERAPP_DIALOG_CID, + "", + HELPERAPP_DIALOG_CONTRACT_ID, + mockHelperAppService + ); + + // Ensure we always prompt for these downloads. + const HandlerService = Cc[ + "@mozilla.org/uriloader/handler-service;1" + ].getService(Ci.nsIHandlerService); + + const MIMEService = Cc["@mozilla.org/mime;1"].getService(Ci.nsIMIMEService); + const mimeInfo = MIMEService.getFromTypeAndExtension( + "application/octet-stream", + "bin" + ); + mimeInfo.alwaysAskBeforeHandling = true; + HandlerService.store(mimeInfo); + + // On Mac, .bin is application/macbinary + let mimeInfoMac; + if (AppConstants.platform == "macosx") { + mimeInfoMac = MIMEService.getFromTypeAndExtension( + "application/macbinary", + "bin" + ); + mimeInfoMac.alwaysAskBeforeHandling = true; + HandlerService.store(mimeInfoMac); + } + + registerCleanupFunction(() => { + HandlerService.remove(mimeInfo); + if (mimeInfoMac) { + HandlerService.remove(mimeInfoMac); + } + }); +}); + +add_task(async function simple_navigation() { + // Tests that simple navigation gives us the right windowContext (that is, + // the window that we're using). + await BrowserTestUtils.withNewTab( + { gBrowser, url: PAGE_URL }, + async function (browser) { + let dialogAppeared = promiseHelperAppDialog(); + await BrowserTestUtils.synthesizeMouseAtCenter( + "#regular_load", + {}, + browser + ); + let windowContext = await dialogAppeared; + + is(windowContext, browser.ownerGlobal, "got the right windowContext"); + } + ); +}); + +add_task(async function accel_navigation() { + await BrowserTestUtils.withNewTab( + { gBrowser, url: PAGE_URL }, + async function (browser) { + let dialogAppeared = promiseHelperAppDialog(); + let tabOpened = BrowserTestUtils.waitForEvent( + gBrowser.tabContainer, + "TabOpen" + ).then(event => { + return [event.target, BrowserTestUtils.waitForTabClosing(event.target)]; + }); + + await BrowserTestUtils.synthesizeMouseAtCenter( + "#regular_load", + { accelKey: true }, + browser + ); + + let windowContext = await dialogAppeared; + is(windowContext, browser.ownerGlobal, "got the right windowContext"); + let [tab, closingPromise] = await tabOpened; + await closingPromise; + is(tab.linkedBrowser, null, "tab was opened and closed"); + } + ); +}); + +// Given a browser pointing to download_page.html, clicks on the link that +// opens with target="_blank" (i.e. a new tab) and ensures that we +// automatically open and close that tab. +async function testNewTab(browser) { + let dialogAppeared = promiseHelperAppDialog(); + let tabOpened = BrowserTestUtils.waitForEvent( + gBrowser.tabContainer, + "TabOpen" + ).then(event => { + return [event.target, BrowserTestUtils.waitForTabClosing(event.target)]; + }); + + await BrowserTestUtils.synthesizeMouseAtCenter("#target_blank", {}, browser); + + let windowContext = await dialogAppeared; + is(windowContext, browser.ownerGlobal, "got the right windowContext"); + let [tab, closingPromise] = await tabOpened; + await closingPromise; + is(tab.linkedBrowser, null, "tab was opened and closed"); +} + +add_task(async function target_blank() { + // Tests that a link with target=_blank opens a new tab and closes it, + // returning the window that we're using for navigation. + await BrowserTestUtils.withNewTab( + { gBrowser, url: PAGE_URL }, + async function (browser) { + await testNewTab(browser); + } + ); +}); + +add_task(async function target_blank_no_opener() { + // Tests that a link with target=_blank and no opener opens a new tab + // and closes it, returning the window that we're using for navigation. + await BrowserTestUtils.withNewTab( + { gBrowser, url: PAGE_URL }, + async function (browser) { + let dialogAppeared = promiseHelperAppDialog(); + let tabOpened = BrowserTestUtils.waitForEvent( + gBrowser.tabContainer, + "TabOpen" + ).then(event => { + return [event.target, BrowserTestUtils.waitForTabClosing(event.target)]; + }); + + await BrowserTestUtils.synthesizeMouseAtCenter( + "#target_blank_no_opener", + {}, + browser + ); + + let windowContext = await dialogAppeared; + is(windowContext, browser.ownerGlobal, "got the right windowContext"); + let [tab, closingPromise] = await tabOpened; + await closingPromise; + is(tab.linkedBrowser, null, "tab was opened and closed"); + } + ); +}); + +add_task(async function open_in_new_tab_no_opener() { + // Tests that a link with target=_blank and no opener opens a new tab + // and closes it, returning the window that we're using for navigation. + await BrowserTestUtils.withNewTab( + { gBrowser, url: PAGE_URL }, + async function (browser) { + let dialogAppeared = promiseHelperAppDialog(); + let tabOpened = BrowserTestUtils.waitForEvent( + gBrowser.tabContainer, + "TabOpen" + ).then(event => { + return [event.target, BrowserTestUtils.waitForTabClosing(event.target)]; + }); + + await BrowserTestUtils.synthesizeMouseAtCenter( + "#open_in_new_tab_no_opener", + {}, + browser + ); + + let windowContext = await dialogAppeared; + is(windowContext, browser.ownerGlobal, "got the right windowContext"); + let [tab, closingPromise] = await tabOpened; + await closingPromise; + is(tab.linkedBrowser, null, "tab was opened and closed"); + } + ); +}); + +add_task(async function new_window() { + // Tests that a link that forces us to open a new window (by specifying a + // width and a height in window.open) opens a new window for the load, + // realizes that we need to close that window and returns the *original* + // window as the window context. + await BrowserTestUtils.withNewTab( + { gBrowser, url: PAGE_URL }, + async function (browser) { + let dialogAppeared = promiseHelperAppDialog(); + let windowOpened = BrowserTestUtils.waitForNewWindow(); + + await BrowserTestUtils.synthesizeMouseAtCenter( + "#new_window", + {}, + browser + ); + let win = await windowOpened; + // Now allow request to complete: + fetch(SJS_URL + "?finish"); + + let windowContext = await dialogAppeared; + is(windowContext, browser.ownerGlobal, "got the right windowContext"); + + // The window should close on its own. If not, this test will time out. + await BrowserTestUtils.domWindowClosed(win); + ok(win.closed, "window was opened and closed"); + + is( + await fetch(SJS_URL + "?reset").then(r => r.text()), + "OK", + "Test reseted" + ); + } + ); +}); + +add_task(async function new_window_no_opener() { + // Tests that a link that forces us to open a new window (by specifying a + // width and a height in window.open) opens a new window for the load, + // realizes that we need to close that window and returns the *original* + // window as the window context. + await BrowserTestUtils.withNewTab( + { gBrowser, url: PAGE_URL }, + async function (browser) { + let dialogAppeared = promiseHelperAppDialog(); + let windowOpened = BrowserTestUtils.waitForNewWindow(); + + await BrowserTestUtils.synthesizeMouseAtCenter( + "#new_window_no_opener", + {}, + browser + ); + let win = await windowOpened; + // Now allow request to complete: + fetch(SJS_URL + "?finish"); + + await dialogAppeared; + + // The window should close on its own. If not, this test will time out. + await BrowserTestUtils.domWindowClosed(win); + ok(win.closed, "window was opened and closed"); + + is( + await fetch(SJS_URL + "?reset").then(r => r.text()), + "OK", + "Test reseted" + ); + } + ); +}); + +add_task(async function nested_window_opens() { + // Tests that the window auto-closing feature works if the download is + // initiated by a window that, itself, has an opener (see bug 1373109). + await BrowserTestUtils.withNewTab( + { gBrowser, url: PAGE_URL }, + async function (outerBrowser) { + let secondTabPromise = BrowserTestUtils.waitForNewTab( + gBrowser, + `${PAGE_URL}?newwin`, + true + ); + BrowserTestUtils.synthesizeMouseAtCenter( + "#open_in_new_tab", + {}, + outerBrowser + ); + let secondTab = await secondTabPromise; + let nestedBrowser = secondTab.linkedBrowser; + + await SpecialPowers.spawn(nestedBrowser, [], function () { + ok(content.opener, "this window has an opener"); + }); + + await testNewTab(nestedBrowser); + + isnot( + secondTab.linkedBrowser, + null, + "the page that triggered the download is still open" + ); + BrowserTestUtils.removeTab(secondTab); + } + ); +}); + +add_task(async function cleanup() { + // Unregister our factory from XPCOM and restore the original CID. + registrar.unregisterFactory(MOCK_HELPERAPP_DIALOG_CID, mockHelperAppService); + registrar.registerFactory( + HELPERAPP_DIALOG_CID, + "", + HELPERAPP_DIALOG_CONTRACT_ID, + null + ); +}); diff --git a/uriloader/exthandler/tests/mochitest/browser_auto_close_window_nodialog.js b/uriloader/exthandler/tests/mochitest/browser_auto_close_window_nodialog.js new file mode 100644 index 0000000000..4f1e8ab18e --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/browser_auto_close_window_nodialog.js @@ -0,0 +1,308 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { ComponentUtils } = ChromeUtils.importESModule( + "resource://gre/modules/ComponentUtils.sys.mjs" +); + +const ROOT = getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "https://example.com" +); +const PAGE_URL = ROOT + "download_page.html"; +const SJS_URL = ROOT + "download.sjs"; + +const HELPERAPP_DIALOG_CONTRACT_ID = "@mozilla.org/helperapplauncherdialog;1"; +const HELPERAPP_DIALOG_CID = Components.ID( + Cc[HELPERAPP_DIALOG_CONTRACT_ID].number +); +const MOCK_HELPERAPP_DIALOG_CID = Components.ID( + "{2f372b6f-56c9-46d5-af0d-9f09bb69860c}" +); + +let registrar = Components.manager.QueryInterface(Ci.nsIComponentRegistrar); +let curSaveResolve = null; + +function HelperAppLauncherDialog() {} + +HelperAppLauncherDialog.prototype = { + show(aLauncher, aWindowContext, aReason) { + ok(false, "Shouldn't be showing the helper app dialog"); + executeSoon(() => { + aLauncher.cancel(Cr.NS_ERROR_ABORT); + }); + }, + promptForSaveToFileAsync(aLauncher, aWindowContext, aReason) { + ok(true, "Shouldn't be showing the helper app dialog"); + curSaveResolve(aWindowContext); + executeSoon(() => { + aLauncher.cancel(Cr.NS_ERROR_ABORT); + }); + }, + QueryInterface: ChromeUtils.generateQI(["nsIHelperAppLauncherDialog"]), +}; + +function promiseSave() { + return new Promise(resolve => { + curSaveResolve = resolve; + }); +} + +let mockHelperAppService; + +add_setup(async function () { + // Replace the real helper app dialog with our own. + mockHelperAppService = ComponentUtils.generateSingletonFactory( + HelperAppLauncherDialog + ); + registrar.registerFactory( + MOCK_HELPERAPP_DIALOG_CID, + "", + HELPERAPP_DIALOG_CONTRACT_ID, + mockHelperAppService + ); + + // Ensure we always prompt for these downloads. + const HandlerService = Cc[ + "@mozilla.org/uriloader/handler-service;1" + ].getService(Ci.nsIHandlerService); + + const MIMEService = Cc["@mozilla.org/mime;1"].getService(Ci.nsIMIMEService); + const mimeInfo = MIMEService.getFromTypeAndExtension( + "application/octet-stream", + "bin" + ); + mimeInfo.alwaysAskBeforeHandling = false; + mimeInfo.preferredAction = Ci.nsIHandlerInfo.saveToDisk; + HandlerService.store(mimeInfo); + + registerCleanupFunction(() => { + HandlerService.remove(mimeInfo); + }); +}); + +add_task(async function simple_navigation() { + // Tests that simple navigation gives us the right windowContext (that is, + // the window that we're using). + await BrowserTestUtils.withNewTab( + { gBrowser, url: PAGE_URL }, + async function (browser) { + let saveHappened = promiseSave(); + await BrowserTestUtils.synthesizeMouseAtCenter( + "#regular_load", + {}, + browser + ); + let windowContext = await saveHappened; + + is(windowContext, browser.ownerGlobal, "got the right windowContext"); + } + ); +}); + +// Given a browser pointing to download_page.html, clicks on the link that +// opens with target="_blank" (i.e. a new tab) and ensures that we +// automatically open and close that tab. +async function testNewTab(browser) { + let saveHappened = promiseSave(); + let tabOpened = BrowserTestUtils.waitForEvent( + gBrowser.tabContainer, + "TabOpen" + ).then(event => { + return [event.target, BrowserTestUtils.waitForTabClosing(event.target)]; + }); + + await BrowserTestUtils.synthesizeMouseAtCenter("#target_blank", {}, browser); + + let windowContext = await saveHappened; + is(windowContext, browser.ownerGlobal, "got the right windowContext"); + let [tab, closingPromise] = await tabOpened; + await closingPromise; + is(tab.linkedBrowser, null, "tab was opened and closed"); +} + +add_task(async function target_blank() { + // Tests that a link with target=_blank opens a new tab and closes it, + // returning the window that we're using for navigation. + await BrowserTestUtils.withNewTab( + { gBrowser, url: PAGE_URL }, + async function (browser) { + await testNewTab(browser); + } + ); +}); + +add_task(async function target_blank_no_opener() { + // Tests that a link with target=_blank and no opener opens a new tab + // and closes it, returning the window that we're using for navigation. + await BrowserTestUtils.withNewTab( + { gBrowser, url: PAGE_URL }, + async function (browser) { + let saveHappened = promiseSave(); + let tabOpened = BrowserTestUtils.waitForEvent( + gBrowser.tabContainer, + "TabOpen" + ).then(event => { + return [event.target, BrowserTestUtils.waitForTabClosing(event.target)]; + }); + + await BrowserTestUtils.synthesizeMouseAtCenter( + "#target_blank_no_opener", + {}, + browser + ); + + let windowContext = await saveHappened; + is(windowContext, browser.ownerGlobal, "got the right windowContext"); + let [tab, closingPromise] = await tabOpened; + await closingPromise; + is(tab.linkedBrowser, null, "tab was opened and closed"); + } + ); +}); + +add_task(async function open_in_new_tab_no_opener() { + // Tests that a link with target=_blank and no opener opens a new tab + // and closes it, returning the window that we're using for navigation. + await BrowserTestUtils.withNewTab( + { gBrowser, url: PAGE_URL }, + async function (browser) { + let saveHappened = promiseSave(); + let tabOpened = BrowserTestUtils.waitForEvent( + gBrowser.tabContainer, + "TabOpen" + ).then(event => { + return [event.target, BrowserTestUtils.waitForTabClosing(event.target)]; + }); + + await BrowserTestUtils.synthesizeMouseAtCenter( + "#open_in_new_tab_no_opener", + {}, + browser + ); + + let windowContext = await saveHappened; + is(windowContext, browser.ownerGlobal, "got the right windowContext"); + let [tab, closingPromise] = await tabOpened; + await closingPromise; + is(tab.linkedBrowser, null, "tab was opened and closed"); + } + ); +}); + +add_task(async function new_window() { + // Tests that a link that forces us to open a new window (by specifying a + // width and a height in window.open) opens a new window for the load, + // realizes that we need to close that window and returns the *original* + // window as the window context. + await BrowserTestUtils.withNewTab( + { gBrowser, url: PAGE_URL }, + async function (browser) { + let saveHappened = promiseSave(); + let windowOpened = BrowserTestUtils.waitForNewWindow(); + + await BrowserTestUtils.synthesizeMouseAtCenter( + "#new_window", + {}, + browser + ); + let win = await windowOpened; + // Now allow request to complete: + fetch(SJS_URL + "?finish"); + + let windowContext = await saveHappened; + is(windowContext, browser.ownerGlobal, "got the right windowContext"); + + // The window should close on its own. If not, this test will time out. + await BrowserTestUtils.domWindowClosed(win); + ok(win.closed, "window was opened and closed"); + + is( + await fetch(SJS_URL + "?reset").then(r => r.text()), + "OK", + "Test reseted" + ); + } + ); +}); + +add_task(async function new_window_no_opener() { + // Tests that a link that forces us to open a new window (by specifying a + // width and a height in window.open) opens a new window for the load, + // realizes that we need to close that window and returns the *original* + // window as the window context. + await BrowserTestUtils.withNewTab( + { gBrowser, url: PAGE_URL }, + async function (browser) { + let saveHappened = promiseSave(); + let windowOpened = BrowserTestUtils.waitForNewWindow(); + + await BrowserTestUtils.synthesizeMouseAtCenter( + "#new_window_no_opener", + {}, + browser + ); + let win = await windowOpened; + // Now allow request to complete: + fetch(SJS_URL + "?finish"); + + await saveHappened; + + // The window should close on its own. If not, this test will time out. + await BrowserTestUtils.domWindowClosed(win); + ok(win.closed, "window was opened and closed"); + + is( + await fetch(SJS_URL + "?reset").then(r => r.text()), + "OK", + "Test reseted" + ); + } + ); +}); + +add_task(async function nested_window_opens() { + // Tests that the window auto-closing feature works if the download is + // initiated by a window that, itself, has an opener (see bug 1373109). + await BrowserTestUtils.withNewTab( + { gBrowser, url: PAGE_URL }, + async function (outerBrowser) { + let secondTabPromise = BrowserTestUtils.waitForNewTab( + gBrowser, + `${PAGE_URL}?newwin`, + true + ); + BrowserTestUtils.synthesizeMouseAtCenter( + "#open_in_new_tab", + {}, + outerBrowser + ); + let secondTab = await secondTabPromise; + let nestedBrowser = secondTab.linkedBrowser; + + await SpecialPowers.spawn(nestedBrowser, [], function () { + ok(content.opener, "this window has an opener"); + }); + + await testNewTab(nestedBrowser); + + isnot( + secondTab.linkedBrowser, + null, + "the page that triggered the download is still open" + ); + BrowserTestUtils.removeTab(secondTab); + } + ); +}); + +add_task(async function cleanup() { + // Unregister our factory from XPCOM and restore the original CID. + registrar.unregisterFactory(MOCK_HELPERAPP_DIALOG_CID, mockHelperAppService); + registrar.registerFactory( + HELPERAPP_DIALOG_CID, + "", + HELPERAPP_DIALOG_CONTRACT_ID, + null + ); +}); diff --git a/uriloader/exthandler/tests/mochitest/browser_bad_download_dir.js b/uriloader/exthandler/tests/mochitest/browser_bad_download_dir.js new file mode 100644 index 0000000000..7fe22e0661 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/browser_bad_download_dir.js @@ -0,0 +1,90 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +let { FileTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/FileTestUtils.sys.mjs" +); + +const ROOT = getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "https://example.com" +); +const TEST_FILE = ROOT + "download.bin"; + +add_task(async function test_check_download_dir() { + // Force XDG dir to somewhere that has no config files, causing lookups of the + // system download dir to fail: + let newXDGRoot = FileTestUtils.getTempFile("xdgstuff"); + newXDGRoot.create(Ci.nsIFile.DIRECTORY_TYPE, 0o755); + let oldXDG = Services.env.exists("XDG_CONFIG_HOME") + ? Services.env.get("XDG_CONFIG_HOME") + : ""; + registerCleanupFunction(() => Services.env.set("XDG_CONFIG_HOME", oldXDG)); + Services.env.set("XDG_CONFIG_HOME", newXDGRoot.path + "/"); + + let propBundle = Services.strings.createBundle( + "chrome://mozapps/locale/downloads/downloads.properties" + ); + let dlRoot = PathUtils.join( + Services.dirsvc.get("Home", Ci.nsIFile).path, + propBundle.GetStringFromName("downloadsFolder") + ); + + // Check lookups fail: + Assert.throws( + () => Services.dirsvc.get("DfltDwnld", Ci.nsIFile), + /NS_ERROR_FAILURE/, + "Should throw when asking for downloads dir." + ); + + await SpecialPowers.pushPrefEnv({ + set: [ + // Avoid opening dialogs + ["browser.download.always_ask_before_handling_new_types", false], + // Switch back to default OS downloads dir (changed in head.js): + ["browser.download.folderList", 1], + ], + }); + + let publicList = await Downloads.getList(Downloads.PUBLIC); + let downloadFinished = promiseDownloadFinished(publicList); + let tab = BrowserTestUtils.addTab(gBrowser, TEST_FILE); + let dl = await downloadFinished; + ok(dl.succeeded, "Download should succeed."); + Assert.stringContains( + dl.target.path, + dlRoot, + "Should store download under DL folder root." + ); + let dlKids = await IOUtils.getChildren(dlRoot); + ok( + dlKids.includes(dl.target.path), + "Download should be a direct child of the DL folder." + ); + await IOUtils.remove(dl.target.path); + + BrowserTestUtils.removeTab(tab); + + // Download a second file to make sure we're not continuously adding filenames + // onto the download folder path. + downloadFinished = promiseDownloadFinished(publicList); + tab = BrowserTestUtils.addTab(gBrowser, TEST_FILE); + dl = await downloadFinished; + Assert.stringContains( + dl.target.path, + dlRoot, + "Second download should store download under new DL folder root." + ); + dlKids = await IOUtils.getChildren(dlRoot); + ok( + dlKids.includes(dl.target.path), + "Second download should be a direct child of the new DL folder." + ); + BrowserTestUtils.removeTab(tab); + await IOUtils.remove(dl.target.path); + + await publicList.removeFinished(); + await IOUtils.remove(newXDGRoot.path); +}); diff --git a/uriloader/exthandler/tests/mochitest/browser_download_always_ask_preferred_app.js b/uriloader/exthandler/tests/mochitest/browser_download_always_ask_preferred_app.js new file mode 100644 index 0000000000..b4bfe5e51a --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/browser_download_always_ask_preferred_app.js @@ -0,0 +1,25 @@ +add_task(async function () { + // Create mocked objects for test + let launcher = createMockedObjects(false); + // Open helper app dialog with mocked launcher + let dlg = await openHelperAppDialog(launcher); + let doc = dlg.document; + let location = doc.getElementById("source"); + let expectedValue = launcher.source.prePath; + if (location.value != expectedValue) { + info("Waiting for dialog to be populated."); + await BrowserTestUtils.waitForAttribute("value", location, expectedValue); + } + is( + doc.getElementById("mode").selectedItem.id, + "open", + "Should be opening the file." + ); + ok( + !dlg.document.getElementById("openHandler").selectedItem.hidden, + "Should not have selected a hidden item." + ); + let helperAppDialogHiddenPromise = BrowserTestUtils.windowClosed(dlg); + doc.getElementById("unknownContentType").cancelDialog(); + await helperAppDialogHiddenPromise; +}); diff --git a/uriloader/exthandler/tests/mochitest/browser_download_idn_blocklist.js b/uriloader/exthandler/tests/mochitest/browser_download_idn_blocklist.js new file mode 100644 index 0000000000..0d49a898a0 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/browser_download_idn_blocklist.js @@ -0,0 +1,47 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const TEST_HOST = "example.org"; +const TEST_FILE = "\u3002.bin"; +const TEST_URL = `http://${TEST_HOST}/${TEST_FILE}`; + +const { XPCShellContentUtils } = ChromeUtils.importESModule( + "resource://testing-common/XPCShellContentUtils.sys.mjs" +); +XPCShellContentUtils.initMochitest(this); +const server = XPCShellContentUtils.createHttpServer({ + hosts: [TEST_HOST], +}); +let file = getChromeDir(getResolvedURI(gTestPath)); +file.append("download.bin"); +server.registerFile(`/${encodeURIComponent(TEST_FILE)}`, file); + +/** + * Check that IDN blocklisted characters are not escaped in + * download file names. + */ +add_task(async function test_idn_blocklisted_char_not_escaped() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.download.always_ask_before_handling_new_types", false]], + }); + + info("Testing with " + TEST_URL); + let publicList = await Downloads.getList(Downloads.PUBLIC); + registerCleanupFunction(async () => { + await publicList.removeFinished(); + }); + let downloadFinished = promiseDownloadFinished(publicList); + var tab = BrowserTestUtils.addTab(gBrowser, TEST_URL); + let dl = await downloadFinished; + ok(dl.succeeded, "Download should succeed."); + Assert.equal( + PathUtils.filename(dl.target.path), + TEST_FILE, + "Should not escape a download file name." + ); + await IOUtils.remove(dl.target.path); + + BrowserTestUtils.removeTab(tab); +}); diff --git a/uriloader/exthandler/tests/mochitest/browser_download_open_with_internal_handler.js b/uriloader/exthandler/tests/mochitest/browser_download_open_with_internal_handler.js new file mode 100644 index 0000000000..e39d9ff04f --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/browser_download_open_with_internal_handler.js @@ -0,0 +1,818 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +requestLongerTimeout(2); + +const { Downloads } = ChromeUtils.importESModule( + "resource://gre/modules/Downloads.sys.mjs" +); +const { DownloadIntegration } = ChromeUtils.importESModule( + "resource://gre/modules/DownloadIntegration.sys.mjs" +); + +const TEST_PATH = getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "https://example.com" +); + +const MimeSvc = Cc["@mozilla.org/mime;1"].getService(Ci.nsIMIMEService); +const HandlerSvc = Cc["@mozilla.org/uriloader/handler-service;1"].getService( + Ci.nsIHandlerService +); + +let MockFilePicker = SpecialPowers.MockFilePicker; +MockFilePicker.init(window); + +function waitForAcceptButtonToGetEnabled(doc) { + let dialog = doc.querySelector("#unknownContentType"); + let button = dialog.getButton("accept"); + return TestUtils.waitForCondition( + () => !button.disabled, + "Wait for Accept button to get enabled" + ); +} + +async function waitForPdfJS(browser, url) { + await SpecialPowers.pushPrefEnv({ + set: [["pdfjs.eventBusDispatchToDOM", true]], + }); + // Runs tests after all "load" event handlers have fired off + let loadPromise = BrowserTestUtils.waitForContentEvent( + browser, + "documentloaded", + false, + null, + true + ); + BrowserTestUtils.loadURIString(browser, url); + return loadPromise; +} + +/** + * This test covers which choices are presented for downloaded files and how + * those choices are handled. Unless a pref is enabled + * (browser.download.always_ask_before_handling_new_types) the unknown content + * dialog will be skipped altogether by default when downloading. + * To retain coverage for the non-default scenario, each task sets `alwaysAskBeforeHandling` + * to true for the relevant mime-type and extensions. + */ +function alwaysAskForHandlingTypes(typeExtensions, ask = true) { + let mimeInfos = []; + for (let [type, ext] of Object.entries(typeExtensions)) { + const mimeInfo = MimeSvc.getFromTypeAndExtension(type, ext); + mimeInfo.alwaysAskBeforeHandling = ask; + if (!ask) { + mimeInfo.preferredAction = mimeInfo.handleInternally; + } + HandlerSvc.store(mimeInfo); + mimeInfos.push(mimeInfo); + } + return mimeInfos; +} + +add_setup(async function () { + // Remove the security delay for the dialog during the test. + await SpecialPowers.pushPrefEnv({ + set: [ + ["security.dialog_enable_delay", 0], + ["browser.helperApps.showOpenOptionForPdfJS", true], + ["browser.helperApps.showOpenOptionForViewableInternally", true], + ], + }); + + // Restore handlers after the whole test has run + const registerRestoreHandler = function (type, ext) { + const mimeInfo = MimeSvc.getFromTypeAndExtension(type, ext); + const existed = HandlerSvc.exists(mimeInfo); + + registerCleanupFunction(() => { + if (existed) { + HandlerSvc.store(mimeInfo); + } else { + HandlerSvc.remove(mimeInfo); + } + }); + }; + registerRestoreHandler("application/pdf", "pdf"); + registerRestoreHandler("binary/octet-stream", "pdf"); + registerRestoreHandler("application/unknown", "pdf"); + registerRestoreHandler("image/webp", "webp"); +}); + +/** + * Check that loading a PDF file with content-disposition: attachment + * shows an option to open with the internal handler, and that the + * internal option handler is not present when the download button + * is clicked from pdf.js. + */ +add_task(async function test_check_open_with_internal_handler() { + const mimeInfosToRestore = alwaysAskForHandlingTypes({ + "application/pdf": "pdf", + "binary/octet-stream": "pdf", + }); + + for (let file of [ + "file_pdf_application_pdf.pdf", + "file_pdf_binary_octet_stream.pdf", + ]) { + info("Testing with " + file); + let publicList = await Downloads.getList(Downloads.PUBLIC); + registerCleanupFunction(async () => { + await publicList.removeFinished(); + }); + let dialogWindowPromise = BrowserTestUtils.domWindowOpenedAndLoaded(); + let loadingTab = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + opening: TEST_PATH + file, + waitForLoad: false, + waitForStateStop: true, + }); + // Add an extra tab after the loading tab so we can test that + // pdf.js is opened in the adjacent tab and not at the end of + // the tab strip. + let extraTab = await BrowserTestUtils.addTab(gBrowser, "about:blank"); + let dialogWindow = await dialogWindowPromise; + is( + dialogWindow.location.href, + "chrome://mozapps/content/downloads/unknownContentType.xhtml", + "Should have seen the unknown content dialogWindow." + ); + let doc = dialogWindow.document; + let internalHandlerRadio = doc.querySelector("#handleInternally"); + + await waitForAcceptButtonToGetEnabled(doc); + + ok(!internalHandlerRadio.hidden, "The option should be visible for PDF"); + ok(internalHandlerRadio.selected, "The option should be selected"); + + let downloadFinishedPromise = promiseDownloadFinished(publicList); + let newTabPromise = BrowserTestUtils.waitForNewTab(gBrowser); + let dialog = doc.querySelector("#unknownContentType"); + let button = dialog.getButton("accept"); + button.disabled = false; + dialog.acceptDialog(); + info("waiting for new tab to open"); + let newTab = await newTabPromise; + + is( + newTab._tPos - 1, + loadingTab._tPos, + "pdf.js should be opened in an adjacent tab" + ); + + await ContentTask.spawn(newTab.linkedBrowser, null, async () => { + await ContentTaskUtils.waitForCondition( + () => content.document.readyState == "complete" + ); + }); + + let publicDownloads = await publicList.getAll(); + is( + publicDownloads.length, + 1, + "download should appear in publicDownloads list" + ); + + let download = await downloadFinishedPromise; + + let subdialogPromise = BrowserTestUtils.domWindowOpenedAndLoaded(); + // Current tab has file: URI and TEST_PATH is http uri, so uri will be different + BrowserTestUtils.loadURIString(newTab.linkedBrowser, TEST_PATH + file); + let subDialogWindow = await subdialogPromise; + let subDoc = subDialogWindow.document; + + // Prevent racing with initialization of the dialog and make sure that + // the final state of the dialog has the correct visibility of the internal-handler option. + await waitForAcceptButtonToGetEnabled(subDoc); + let subInternalHandlerRadio = subDoc.querySelector("#handleInternally"); + ok( + !subInternalHandlerRadio.hidden, + "This option should be shown when the dialog is shown for another PDF" + ); + // Cancel dialog + subDoc.querySelector("#unknownContentType").cancelDialog(); + + let filepickerPromise = new Promise(resolve => { + MockFilePicker.showCallback = function (fp) { + setTimeout(() => { + resolve(fp.defaultString); + }, 0); + return Ci.nsIFilePicker.returnCancel; + }; + }); + + subdialogPromise = BrowserTestUtils.domWindowOpenedAndLoaded(); + await SpecialPowers.spawn(newTab.linkedBrowser, [], async () => { + let downloadButton; + await ContentTaskUtils.waitForCondition(() => { + downloadButton = content.document.querySelector("#download"); + return !!downloadButton; + }); + ok(downloadButton, "Download button should be present in pdf.js"); + downloadButton.click(); + }); + info( + "Waiting for unknown content type dialog to appear from pdf.js download button click" + ); + let filename = await filepickerPromise; + is(filename, file, "filename was set in filepicker"); + + // Remove the first file (can't do this sooner or the second load fails): + if (download?.target.exists) { + try { + info("removing " + download.target.path); + await IOUtils.remove(download.target.path); + } catch (ex) { + /* ignore */ + } + } + + BrowserTestUtils.removeTab(loadingTab); + BrowserTestUtils.removeTab(newTab); + BrowserTestUtils.removeTab(extraTab); + + await publicList.removeFinished(); + } + for (let mimeInfo of mimeInfosToRestore) { + HandlerSvc.remove(mimeInfo); + } +}); + +/** + * Test that choosing to open in an external application doesn't + * open the PDF into pdf.js + */ +add_task(async function test_check_open_with_external_application() { + const mimeInfosToRestore = alwaysAskForHandlingTypes({ + "application/pdf": "pdf", + "binary/octet-stream": "pdf", + }); + + for (let file of [ + "file_pdf_application_pdf.pdf", + "file_pdf_binary_octet_stream.pdf", + ]) { + info("Testing with " + file); + let publicList = await Downloads.getList(Downloads.PUBLIC); + registerCleanupFunction(async () => { + await publicList.removeFinished(); + }); + let dialogWindowPromise = BrowserTestUtils.domWindowOpenedAndLoaded(); + let loadingTab = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + opening: TEST_PATH + file, + waitForLoad: false, + waitForStateStop: true, + }); + let dialogWindow = await dialogWindowPromise; + is( + dialogWindow.location.href, + "chrome://mozapps/content/downloads/unknownContentType.xhtml", + "Should have seen the unknown content dialogWindow." + ); + + let oldLaunchFile = DownloadIntegration.launchFile; + let waitForLaunchFileCalled = new Promise(resolve => { + DownloadIntegration.launchFile = () => { + ok(true, "The file should be launched with an external application"); + resolve(); + }; + }); + + let doc = dialogWindow.document; + await waitForAcceptButtonToGetEnabled(doc); + let dialog = doc.querySelector("#unknownContentType"); + doc.querySelector("#open").click(); + let button = dialog.getButton("accept"); + button.disabled = false; + info("Accepting the dialog"); + dialog.acceptDialog(); + info("Waiting until DownloadIntegration.launchFile is called"); + await waitForLaunchFileCalled; + DownloadIntegration.launchFile = oldLaunchFile; + + let publicDownloads = await publicList.getAll(); + is( + publicDownloads.length, + 1, + "download should appear in publicDownloads list" + ); + let download = publicDownloads[0]; + ok( + !download.launchWhenSucceeded, + "launchWhenSucceeded should be false after launchFile is called" + ); + + BrowserTestUtils.removeTab(loadingTab); + if (download?.target.exists) { + try { + info("removing " + download.target.path); + await IOUtils.remove(download.target.path); + } catch (ex) { + /* ignore */ + } + } + await publicList.removeFinished(); + } + for (let mimeInfo of mimeInfosToRestore) { + HandlerSvc.remove(mimeInfo); + } +}); + +/** + * Test that choosing to open a PDF with an external application works and + * then downloading the same file again and choosing Open with Firefox opens + * the download in Firefox. + */ +add_task(async function test_check_open_with_external_then_internal() { + // This test only runs on Windows because appPicker.xhtml is only used on Windows. + if (AppConstants.platform != "win") { + return; + } + + // This test covers a bug that only occurs when the mimeInfo is set to Always Ask + const mimeInfo = MimeSvc.getFromTypeAndExtension("application/pdf", "pdf"); + console.log( + "mimeInfo.preferredAction is currently:", + mimeInfo.preferredAction + ); + mimeInfo.preferredAction = mimeInfo.alwaysAsk; + mimeInfo.alwaysAskBeforeHandling = true; + HandlerSvc.store(mimeInfo); + + for (let [file, mimeType] of [ + ["file_pdf_application_pdf.pdf", "application/pdf"], + ["file_pdf_binary_octet_stream.pdf", "binary/octet-stream"], + ["file_pdf_application_unknown.pdf", "application/unknown"], + ]) { + info("Testing with " + file); + let originalMimeInfo = MimeSvc.getFromTypeAndExtension(mimeType, "pdf"); + + let publicList = await Downloads.getList(Downloads.PUBLIC); + registerCleanupFunction(async () => { + await publicList.removeFinished(); + }); + let dialogWindowPromise = BrowserTestUtils.domWindowOpenedAndLoaded(); + // Open a new tab to the PDF file which will trigger the Unknown Content Type dialog + // and choose to open the PDF with an external application. + let loadingTab = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + opening: TEST_PATH + file, + waitForLoad: false, + waitForStateStop: true, + }); + let dialogWindow = await dialogWindowPromise; + is( + dialogWindow.location.href, + "chrome://mozapps/content/downloads/unknownContentType.xhtml", + "Should have seen the unknown content dialogWindow." + ); + + let oldLaunchFile = DownloadIntegration.launchFile; + let waitForLaunchFileCalled = new Promise(resolve => { + DownloadIntegration.launchFile = () => { + ok(true, "The file should be launched with an external application"); + resolve(); + }; + }); + + let doc = dialogWindow.document; + await waitForAcceptButtonToGetEnabled(doc); + let dialog = doc.querySelector("#unknownContentType"); + let openHandlerMenulist = doc.querySelector("#openHandler"); + let originalDefaultHandler = openHandlerMenulist.label; + doc.querySelector("#open").click(); + doc.querySelector("#openHandlerPopup").click(); + let oldOpenDialog = dialogWindow.openDialog; + dialogWindow.openDialog = (location, unused2, unused3, params) => { + is(location, "chrome://global/content/appPicker.xhtml", "app picker"); + let handlerApp = params.mimeInfo.possibleLocalHandlers.queryElementAt( + 0, + Ci.nsILocalHandlerApp + ); + ok(handlerApp.executable, "handlerApp should be executable"); + ok(handlerApp.executable.isFile(), "handlerApp should be a file"); + params.handlerApp = handlerApp; + }; + doc.querySelector("#choose").click(); + dialogWindow.openDialog = oldOpenDialog; + await TestUtils.waitForCondition( + () => originalDefaultHandler != openHandlerMenulist.label, + "waiting for openHandler to get updated" + ); + let newDefaultHandler = openHandlerMenulist.label; + info(`was ${originalDefaultHandler}, now ${newDefaultHandler}`); + let button = dialog.getButton("accept"); + button.disabled = false; + info("Accepting the dialog"); + dialog.acceptDialog(); + info("Waiting until DownloadIntegration.launchFile is called"); + await waitForLaunchFileCalled; + BrowserTestUtils.removeTab(loadingTab); + + // Now, open a new tab to the PDF file which will trigger the Unknown Content Type dialog + // and choose to open the PDF internally. The previously used external application should be shown as + // the external option. + dialogWindowPromise = BrowserTestUtils.domWindowOpenedAndLoaded(); + loadingTab = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + opening: TEST_PATH + file, + waitForLoad: false, + waitForStateStop: true, + }); + dialogWindow = await dialogWindowPromise; + is( + dialogWindow.location.href, + "chrome://mozapps/content/downloads/unknownContentType.xhtml", + "Should have seen the unknown content dialogWindow." + ); + + DownloadIntegration.launchFile = () => { + ok(false, "The file should not be launched with an external application"); + }; + + doc = dialogWindow.document; + await waitForAcceptButtonToGetEnabled(doc); + openHandlerMenulist = doc.querySelector("#openHandler"); + is(openHandlerMenulist.label, newDefaultHandler, "'new' handler"); + dialog = doc.querySelector("#unknownContentType"); + doc.querySelector("#handleInternally").click(); + info("Accepting the dialog"); + button = dialog.getButton("accept"); + button.disabled = false; + let newTabPromise = BrowserTestUtils.waitForNewTab(gBrowser); + dialog.acceptDialog(); + + info("waiting for new tab to open"); + let newTab = await newTabPromise; + + await ContentTask.spawn(newTab.linkedBrowser, null, async () => { + await ContentTaskUtils.waitForCondition( + () => content.document.readyState == "complete" + ); + }); + + is( + newTab.linkedBrowser.contentPrincipal.origin, + "resource://pdf.js", + "PDF should be opened with pdf.js" + ); + + BrowserTestUtils.removeTab(loadingTab); + BrowserTestUtils.removeTab(newTab); + + // Now trigger the dialog again and select the system + // default option to reset the state for the next iteration of the test. + // Reset the state for the next iteration of the test. + HandlerSvc.store(originalMimeInfo); + DownloadIntegration.launchFile = oldLaunchFile; + let [download] = await publicList.getAll(); + if (download?.target.exists) { + try { + info("removing " + download.target.path); + await IOUtils.remove(download.target.path); + } catch (ex) { + /* ignore */ + } + } + await publicList.removeFinished(); + } +}); + +/** + * Check that the "Open with internal handler" option is presented + * for other viewable internally types. + */ +add_task( + async function test_internal_handler_hidden_with_viewable_internally_type() { + await SpecialPowers.pushPrefEnv({ + set: [["image.webp.enabled", true]], + }); + + const mimeInfosToRestore = alwaysAskForHandlingTypes({ + "binary/octet-stream": "xml", + "image/webp": "webp", + }); + + for (let [file, checkDefault] of [ + // The default for binary/octet-stream is changed by the PDF tests above, + // this may change given bug 1659008, so I'm just ignoring the default for now. + ["file_xml_attachment_binary_octet_stream.xml", false], + ["file_green.webp", true], + ]) { + let dialogWindowPromise = BrowserTestUtils.domWindowOpenedAndLoaded(); + let loadingTab = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + opening: TEST_PATH + file, + waitForLoad: false, + waitForStateStop: true, + }); + let dialogWindow = await dialogWindowPromise; + is( + dialogWindow.location.href, + "chrome://mozapps/content/downloads/unknownContentType.xhtml", + "Should have seen the unknown content dialogWindow." + ); + let doc = dialogWindow.document; + let internalHandlerRadio = doc.querySelector("#handleInternally"); + + // Prevent racing with initialization of the dialog and make sure that + // the final state of the dialog has the correct visibility of the internal-handler option. + await waitForAcceptButtonToGetEnabled(doc); + + let fileDesc = file.substring(file.lastIndexOf(".") + 1); + + ok( + !internalHandlerRadio.hidden, + `The option should be visible for ${fileDesc}` + ); + if (checkDefault) { + ok( + internalHandlerRadio.selected, + `The option should be selected for ${fileDesc}` + ); + } + + let dialog = doc.querySelector("#unknownContentType"); + dialog.cancelDialog(); + BrowserTestUtils.removeTab(loadingTab); + } + for (let mimeInfo of mimeInfosToRestore) { + HandlerSvc.remove(mimeInfo); + } + } +); + +/** + * Check that the "Open with internal handler" option is not presented + * for non-PDF, non-viewable-internally types. + */ +add_task(async function test_internal_handler_hidden_with_other_type() { + const mimeInfosToRestore = alwaysAskForHandlingTypes({ + "text/plain": "txt", + }); + + let dialogWindowPromise = BrowserTestUtils.domWindowOpenedAndLoaded(); + let loadingTab = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + opening: TEST_PATH + "file_txt_attachment_test.txt", + waitForLoad: false, + waitForStateStop: true, + }); + let dialogWindow = await dialogWindowPromise; + is( + dialogWindow.location.href, + "chrome://mozapps/content/downloads/unknownContentType.xhtml", + "Should have seen the unknown content dialogWindow." + ); + let doc = dialogWindow.document; + + // Prevent racing with initialization of the dialog and make sure that + // the final state of the dialog has the correct visibility of the internal-handler option. + await waitForAcceptButtonToGetEnabled(doc); + + let internalHandlerRadio = doc.querySelector("#handleInternally"); + ok( + internalHandlerRadio.hidden, + "The option should be hidden for unknown file type" + ); + + let dialog = doc.querySelector("#unknownContentType"); + dialog.cancelDialog(); + BrowserTestUtils.removeTab(loadingTab); + for (let mimeInfo of mimeInfosToRestore) { + HandlerSvc.remove(mimeInfo); + } +}); + +/** + * Check that the "Open with internal handler" option is not presented + * when the feature is disabled for PDFs. + */ +add_task(async function test_internal_handler_hidden_with_pdf_pref_disabled() { + const mimeInfosToRestore = alwaysAskForHandlingTypes({ + "application/pdf": "pdf", + "binary/octet-stream": "pdf", + }); + await SpecialPowers.pushPrefEnv({ + set: [["browser.helperApps.showOpenOptionForPdfJS", false]], + }); + for (let file of [ + "file_pdf_application_pdf.pdf", + "file_pdf_binary_octet_stream.pdf", + ]) { + let dialogWindowPromise = BrowserTestUtils.domWindowOpenedAndLoaded(); + let loadingTab = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + opening: TEST_PATH + file, + waitForLoad: false, + waitForStateStop: true, + }); + let dialogWindow = await dialogWindowPromise; + is( + dialogWindow.location.href, + "chrome://mozapps/content/downloads/unknownContentType.xhtml", + "Should have seen the unknown content dialogWindow." + ); + let doc = dialogWindow.document; + + await waitForAcceptButtonToGetEnabled(doc); + + let internalHandlerRadio = doc.querySelector("#handleInternally"); + ok( + internalHandlerRadio.hidden, + "The option should be hidden for PDF when the pref is false" + ); + + let dialog = doc.querySelector("#unknownContentType"); + dialog.cancelDialog(); + BrowserTestUtils.removeTab(loadingTab); + } + for (let mimeInfo of mimeInfosToRestore) { + HandlerSvc.remove(mimeInfo); + } +}); + +/** + * Check that the "Open with internal handler" option is not presented + * for other viewable internally types when disabled. + */ +add_task( + async function test_internal_handler_hidden_with_viewable_internally_pref_disabled() { + const mimeInfosToRestore = alwaysAskForHandlingTypes({ + "text/xml": "xml", + }); + await SpecialPowers.pushPrefEnv({ + set: [["browser.helperApps.showOpenOptionForViewableInternally", false]], + }); + let dialogWindowPromise = BrowserTestUtils.domWindowOpenedAndLoaded(); + let loadingTab = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + opening: TEST_PATH + "file_xml_attachment_test.xml", + waitForLoad: false, + waitForStateStop: true, + }); + let dialogWindow = await dialogWindowPromise; + is( + dialogWindow.location.href, + "chrome://mozapps/content/downloads/unknownContentType.xhtml", + "Should have seen the unknown content dialogWindow." + ); + let doc = dialogWindow.document; + + await waitForAcceptButtonToGetEnabled(doc); + + let internalHandlerRadio = doc.querySelector("#handleInternally"); + ok( + internalHandlerRadio.hidden, + "The option should be hidden for XML when the pref is false" + ); + + let dialog = doc.querySelector("#unknownContentType"); + dialog.cancelDialog(); + BrowserTestUtils.removeTab(loadingTab); + for (let mimeInfo of mimeInfosToRestore) { + HandlerSvc.remove(mimeInfo); + } + } +); + +/* + * This test sets the action to internal. The files should open directly without asking. + */ +add_task(async function test_check_open_with_internal_handler_noask() { + const mimeInfosToRestore = alwaysAskForHandlingTypes( + { + "application/pdf": "pdf", + "binary/octet-stream": "pdf", + "application/octet-stream": "pdf", + }, + false + ); + + // Build the matrix of tests to perform. + let matrix = { + alwaysOpenPDFInline: [false, true], + file: [ + "file_pdf_application_pdf.pdf", + "file_pdf_binary_octet_stream.pdf", + "file_pdf_application_octet_stream.pdf", + ], + where: ["top", "popup", "frame"], + }; + let tests = [{}]; + for (let [key, values] of Object.entries(matrix)) { + tests = tests.flatMap(test => + values.map(value => ({ [key]: value, ...test })) + ); + } + + for (let test of tests) { + info(`test case: ${JSON.stringify(test)}`); + let { alwaysOpenPDFInline, file, where } = test; + + // These are the cases that can be opened inline. binary/octet-stream + // isn't handled by pdfjs. + let canHandleInline = + file == "file_pdf_application_pdf.pdf" || + (file == "file_pdf_application_octet_stream.pdf" && where != "frame"); + + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.helperApps.showOpenOptionForPdfJS", true], + ["browser.helperApps.showOpenOptionForViewableInternally", true], + ["browser.download.open_pdf_attachments_inline", alwaysOpenPDFInline], + ], + }); + + async function doNavigate(browser) { + await SpecialPowers.spawn( + browser, + [TEST_PATH + file, where], + async (contentUrl, where_) => { + switch (where_) { + case "top": + content.location = contentUrl; + break; + case "popup": + content.open(contentUrl); + break; + case "frame": + let frame = content.document.createElement("iframe"); + frame.setAttribute("src", contentUrl); + content.document.body.appendChild(frame); + break; + default: + ok(false, "Unknown where value"); + break; + } + } + ); + } + + // If this is true, the pdf is opened directly without downloading it. + // Otherwise, it must first be downloaded and optionally displayed in + // a tab with a file url. + let openPDFDirectly = alwaysOpenPDFInline && canHandleInline; + + await BrowserTestUtils.withNewTab( + { gBrowser, url: TEST_PATH + "blank.html" }, + async browser => { + let readyPromise = BrowserTestUtils.waitForNewTab( + gBrowser, + null, + false, + !openPDFDirectly + ); + + await doNavigate(browser); + + await readyPromise; + + is( + gBrowser.selectedBrowser.currentURI.scheme, + openPDFDirectly ? "https" : "file", + "Loaded PDF uri has the correct scheme" + ); + + // intentionally don't bother checking session history without ship to + // keep complexity down. + if (Services.appinfo.sessionHistoryInParent) { + let shistory = browser.browsingContext.sessionHistory; + is(shistory.count, 1, "should a single shentry"); + is(shistory.index, 0, "should be on the first entry"); + let shentry = shistory.getEntryAtIndex(shistory.index); + is(shentry.URI.spec, TEST_PATH + "blank.html"); + } + + await SpecialPowers.spawn( + browser, + [TEST_PATH + "blank.html"], + async blankUrl => { + ok( + !docShell.isAttemptingToNavigate, + "should not still be attempting to navigate" + ); + is( + content.location.href, + blankUrl, + "original browser hasn't navigated" + ); + } + ); + + await BrowserTestUtils.removeTab(gBrowser.selectedTab); + } + ); + } + + for (let mimeInfo of mimeInfosToRestore) { + HandlerSvc.remove(mimeInfo); + } +}); + +add_task(async () => { + MockFilePicker.cleanup(); +}); diff --git a/uriloader/exthandler/tests/mochitest/browser_download_preferred_action.js b/uriloader/exthandler/tests/mochitest/browser_download_preferred_action.js new file mode 100644 index 0000000000..5ebcc568fd --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/browser_download_preferred_action.js @@ -0,0 +1,296 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { DownloadIntegration } = ChromeUtils.importESModule( + "resource://gre/modules/DownloadIntegration.sys.mjs" +); +const { FileTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/FileTestUtils.sys.mjs" +); +const gHandlerService = Cc[ + "@mozilla.org/uriloader/handler-service;1" +].getService(Ci.nsIHandlerService); +const gMIMEService = Cc["@mozilla.org/mime;1"].getService(Ci.nsIMIMEService); +const localHandlerAppFactory = Cc["@mozilla.org/uriloader/local-handler-app;1"]; + +const ROOT_URL = getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "https://example.com" +); + +const FILE_TYPES_MIME_SETTINGS = {}; + +// File types to test +const FILE_TYPES_TO_TEST = [ + // application/ms-word files cannot render in the browser so + // handleInternally does not work for it + { + extension: "doc", + mimeType: "application/ms-word", + blockHandleInternally: true, + }, + { + extension: "pdf", + mimeType: "application/pdf", + }, + { + extension: "pdf", + mimeType: "application/unknown", + }, + { + extension: "pdf", + mimeType: "binary/octet-stream", + }, + // text/plain files automatically render in the browser unless + // the CD header explicitly tells the browser to download it + { + extension: "txt", + mimeType: "text/plain", + requireContentDispositionHeader: true, + }, + { + extension: "xml", + mimeType: "binary/octet-stream", + }, +].map(file => { + return { + ...file, + url: `${ROOT_URL}mime_type_download.sjs?contentType=${file.mimeType}&extension=${file.extension}`, + }; +}); + +// Preferred action types to apply to each downloaded file +const PREFERRED_ACTIONS = [ + "saveToDisk", + "alwaysAsk", + "useHelperApp", + "handleInternally", + "useSystemDefault", +].map(property => { + let label = property.replace(/([A-Z])/g, " $1"); + label = label.charAt(0).toUpperCase() + label.slice(1); + return { + id: Ci.nsIHandlerInfo[property], + label, + }; +}); + +async function createDownloadTest( + downloadList, + localHandlerApp, + file, + action, + useContentDispositionHeader +) { + // Skip handleInternally case for files that cannot be handled internally + if ( + action.id === Ci.nsIHandlerInfo.handleInternally && + file.blockHandleInternally + ) { + return; + } + let skipDownload = + action.id === Ci.nsIHandlerInfo.handleInternally && + file.mimeType === "application/pdf"; + // Types that require the CD header only display as handleInternally + // when the CD header is missing + if (file.requireContentDispositionHeader && !useContentDispositionHeader) { + if (action.id === Ci.nsIHandlerInfo.handleInternally) { + skipDownload = true; + } else { + return; + } + } + info( + `Testing download with mime-type ${file.mimeType} and extension ${ + file.extension + }, preferred action "${action.label}," and ${ + useContentDispositionHeader + ? "Content-Disposition: attachment" + : "no Content-Disposition" + } header.` + ); + info("Preparing for download..."); + // apply preferredAction settings + let mimeSettings = gMIMEService.getFromTypeAndExtension( + file.mimeType, + file.extension + ); + mimeSettings.preferredAction = action.id; + mimeSettings.alwaysAskBeforeHandling = + action.id === Ci.nsIHandlerInfo.alwaysAsk; + if (action.id === Ci.nsIHandlerInfo.useHelperApp) { + mimeSettings.preferredApplicationHandler = localHandlerApp; + } + gHandlerService.store(mimeSettings); + // delayed check for files opened in a new tab, except for skipped downloads + let expectViewInBrowserTab = + action.id === Ci.nsIHandlerInfo.handleInternally && !skipDownload; + let viewInBrowserTabOpened = null; + if (expectViewInBrowserTab) { + viewInBrowserTabOpened = BrowserTestUtils.waitForNewTab( + gBrowser, + uri => uri.includes("file://"), + true + ); + } + // delayed check for launched files + let expectLaunch = + action.id === Ci.nsIHandlerInfo.useSystemDefault || + action.id === Ci.nsIHandlerInfo.useHelperApp; + let oldLaunchFile = DownloadIntegration.launchFile; + let fileLaunched = null; + if (expectLaunch) { + fileLaunched = PromiseUtils.defer(); + DownloadIntegration.launchFile = () => { + ok( + expectLaunch, + `The file ${file.mimeType} should be launched with an external application.` + ); + fileLaunched.resolve(); + }; + } + info(`Start download of ${file.url}`); + let dialogWindowPromise = BrowserTestUtils.domWindowOpenedAndLoaded(); + let downloadFinishedPromise = skipDownload + ? null + : promiseDownloadFinished(downloadList); + BrowserTestUtils.loadURIString(gBrowser.selectedBrowser, file.url); + if (action.id === Ci.nsIHandlerInfo.alwaysAsk) { + info("Check Always Ask dialog."); + let dialogWindow = await dialogWindowPromise; + is( + dialogWindow.location.href, + "chrome://mozapps/content/downloads/unknownContentType.xhtml", + "Should show unknownContentType dialog for Always Ask preferred actions." + ); + let doc = dialogWindow.document; + let dialog = doc.querySelector("#unknownContentType"); + let acceptButton = dialog.getButton("accept"); + acceptButton.disabled = false; + let saveItem = doc.querySelector("#save"); + saveItem.disabled = false; + saveItem.click(); + dialog.acceptDialog(); + } + let download = null; + let downloadPath = null; + if (!skipDownload) { + info("Wait for download to finish..."); + download = await downloadFinishedPromise; + downloadPath = download.target.path; + } + // check delayed assertions + if (expectLaunch) { + info("Wait for file to be launched in external application..."); + await fileLaunched.promise; + } + if (expectViewInBrowserTab) { + info("Wait for file to be opened in new tab..."); + let viewInBrowserTab = await viewInBrowserTabOpened; + ok( + viewInBrowserTab, + `The file ${file.mimeType} should be opened in a new tab.` + ); + BrowserTestUtils.removeTab(viewInBrowserTab); + } + info("Checking for saved file..."); + let saveFound = downloadPath && (await IOUtils.exists(downloadPath)); + info("Cleaning up..."); + if (saveFound) { + try { + info(`Deleting file ${downloadPath}...`); + await IOUtils.remove(downloadPath); + } catch (ex) { + info(`Error: ${ex}`); + } + } + info("Removing download from list..."); + await downloadList.removeFinished(); + info("Clearing settings..."); + DownloadIntegration.launchFile = oldLaunchFile; + info("Asserting results..."); + if (download) { + ok(download.succeeded, "Download should complete successfully"); + ok( + !download._launchedFromPanel, + "Download should never be launched from panel" + ); + } + if (skipDownload) { + ok(!saveFound, "Download should not be saved to disk"); + } else { + ok(saveFound, "Download should be saved to disk"); + } +} + +add_task(async function test_download_preferred_action() { + // Prepare tests + for (const index in FILE_TYPES_TO_TEST) { + let file = FILE_TYPES_TO_TEST[index]; + let originalMimeSettings = gMIMEService.getFromTypeAndExtension( + file.mimeType, + file.extension + ); + if (gHandlerService.exists(originalMimeSettings)) { + FILE_TYPES_MIME_SETTINGS[index] = originalMimeSettings; + } + } + let downloadList = await Downloads.getList(Downloads.PUBLIC); + let oldLaunchFile = DownloadIntegration.launchFile; + registerCleanupFunction(async function () { + await removeAllDownloads(); + DownloadIntegration.launchFile = oldLaunchFile; + Services.prefs.clearUserPref( + "browser.download.always_ask_before_handling_new_types" + ); + BrowserTestUtils.loadURIString(gBrowser.selectedBrowser, "about:home"); + for (const index in FILE_TYPES_TO_TEST) { + let file = FILE_TYPES_TO_TEST[index]; + let mimeSettings = gMIMEService.getFromTypeAndExtension( + file.mimeType, + file.extension + ); + if (FILE_TYPES_MIME_SETTINGS[index]) { + gHandlerService.store(FILE_TYPES_MIME_SETTINGS[index]); + } else { + gHandlerService.remove(mimeSettings); + } + } + }); + await SpecialPowers.pushPrefEnv({ + set: [["browser.download.always_ask_before_handling_new_types", false]], + }); + let launcherPath = FileTestUtils.getTempFile("app-launcher").path; + let localHandlerApp = localHandlerAppFactory.createInstance( + Ci.nsILocalHandlerApp + ); + localHandlerApp.executable = new FileUtils.File(launcherPath); + localHandlerApp.executable.create(Ci.nsIFile.NORMAL_FILE_TYPE, 0o755); + // run tests + for (const file of FILE_TYPES_TO_TEST) { + // The CD header specifies the download file extension on download + let fileNoHeader = file; + let fileWithHeader = structuredClone(file); + fileWithHeader.url += "&withHeader"; + for (const action of PREFERRED_ACTIONS) { + // Clone file objects to prevent side-effects between iterations + await createDownloadTest( + downloadList, + localHandlerApp, + structuredClone(fileWithHeader), + action, + true + ); + await createDownloadTest( + downloadList, + localHandlerApp, + structuredClone(fileNoHeader), + action, + false + ); + } + } +}); diff --git a/uriloader/exthandler/tests/mochitest/browser_download_privatebrowsing.js b/uriloader/exthandler/tests/mochitest/browser_download_privatebrowsing.js new file mode 100644 index 0000000000..8ded5e6401 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/browser_download_privatebrowsing.js @@ -0,0 +1,78 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests that downloads started from a private window by clicking on a link end + * up in the global list of private downloads (see bug 1367581). + */ + +"use strict"; + +const { Downloads } = ChromeUtils.importESModule( + "resource://gre/modules/Downloads.sys.mjs" +); +const { DownloadPaths } = ChromeUtils.importESModule( + "resource://gre/modules/DownloadPaths.sys.mjs" +); +const { FileTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/FileTestUtils.sys.mjs" +); +const { MockRegistrar } = ChromeUtils.importESModule( + "resource://testing-common/MockRegistrar.sys.mjs" +); + +add_task(async function test_setup() { + // Save downloads to disk without showing the dialog. + let cid = MockRegistrar.register("@mozilla.org/helperapplauncherdialog;1", { + QueryInterface: ChromeUtils.generateQI(["nsIHelperAppLauncherDialog"]), + show(launcher) { + launcher.promptForSaveDestination(); + }, + promptForSaveToFileAsync(launcher) { + // The dialog should create the empty placeholder file. + let file = FileTestUtils.getTempFile(); + file.create(Ci.nsIFile.NORMAL_FILE_TYPE, FileUtils.PERMS_FILE); + launcher.saveDestinationAvailable(file); + }, + }); + registerCleanupFunction(() => { + MockRegistrar.unregister(cid); + }); +}); + +add_task(async function test_download_privatebrowsing() { + let privateList = await Downloads.getList(Downloads.PRIVATE); + let publicList = await Downloads.getList(Downloads.PUBLIC); + + let win = await BrowserTestUtils.openNewBrowserWindow({ private: true }); + try { + let tab = await BrowserTestUtils.openNewForegroundTab( + win.gBrowser, + `data:text/html,<a download href="data:text/plain,">download</a>` + ); + + let promiseNextPrivateDownload = new Promise(resolve => { + privateList.addView({ + onDownloadAdded(download) { + privateList.removeView(this); + resolve(download); + }, + }); + }); + + await SpecialPowers.spawn(tab.linkedBrowser, [], async function () { + content.document.querySelector("a").click(); + }); + + // Wait for the download to finish so the file can be safely deleted later. + let download = await promiseNextPrivateDownload; + await download.whenSucceeded(); + + // Clean up after checking that there are no new public downloads either. + let publicDownloads = await publicList.getAll(); + Assert.equal(publicDownloads.length, 0); + await privateList.removeFinished(); + } finally { + await BrowserTestUtils.closeWindow(win); + } +}); diff --git a/uriloader/exthandler/tests/mochitest/browser_download_skips_dialog.js b/uriloader/exthandler/tests/mochitest/browser_download_skips_dialog.js new file mode 100644 index 0000000000..8cc9d68a07 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/browser_download_skips_dialog.js @@ -0,0 +1,60 @@ +const { DownloadIntegration } = ChromeUtils.importESModule( + "resource://gre/modules/DownloadIntegration.sys.mjs" +); + +const TEST_PATH = getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "https://example.com" +); + +// New file is being downloaded and no dialogs are shown in the way. +add_task(async function skipDialogAndDownloadFile() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.download.always_ask_before_handling_new_types", false], + ["browser.download.useDownloadDir", true], + ["image.webp.enabled", true], + ], + }); + + let publicList = await Downloads.getList(Downloads.PUBLIC); + registerCleanupFunction(async () => { + await publicList.removeFinished(); + }); + let downloadFinishedPromise = promiseDownloadFinished(publicList); + + let initialTabsCount = gBrowser.tabs.length; + + let loadingTab = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + opening: TEST_PATH + "file_green.webp", + waitForLoad: false, + waitForStateStop: true, + }); + + // We just open the file to be downloaded... and wait for it to be downloaded! + // We see no dialogs to be accepted in the process. + let download = await downloadFinishedPromise; + await BrowserTestUtils.waitForCondition( + () => gBrowser.tabs.length == initialTabsCount + 2 + ); + + gBrowser.removeCurrentTab(); + BrowserTestUtils.removeTab(loadingTab); + + Assert.ok( + await IOUtils.exists(download.target.path), + "The file should have been downloaded." + ); + + try { + info("removing " + download.target.path); + if (Services.appinfo.OS === "WINNT") { + // We need to make the file writable to delete it on Windows. + await IOUtils.setPermissions(download.target.path, 0o600); + } + await IOUtils.remove(download.target.path); + } catch (ex) { + info("The file " + download.target.path + " is not removed, " + ex); + } +}); diff --git a/uriloader/exthandler/tests/mochitest/browser_download_spam_permissions.js b/uriloader/exthandler/tests/mochitest/browser_download_spam_permissions.js new file mode 100644 index 0000000000..0f80af5ac5 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/browser_download_spam_permissions.js @@ -0,0 +1,158 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { PermissionTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/PermissionTestUtils.sys.mjs" +); + +const TEST_URI = "https://example.com"; + +const TEST_PATH = getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + TEST_URI +); + +const AUTOMATIC_DOWNLOAD_TOPIC = "blocked-automatic-download"; + +var MockFilePicker = SpecialPowers.MockFilePicker; +MockFilePicker.init(window); +registerCleanupFunction(() => MockFilePicker.cleanup()); + +let gTempDownloadDir; + +add_setup(async function () { + // Create temp directory + let time = new Date().getTime(); + let tempDir = Services.dirsvc.get("TmpD", Ci.nsIFile); + tempDir.append(time); + gTempDownloadDir = tempDir; + Services.prefs.setIntPref("browser.download.folderList", 2); + Services.prefs.setComplexValue("browser.download.dir", Ci.nsIFile, tempDir); + + PermissionTestUtils.add( + TEST_URI, + "automatic-download", + Services.perms.UNKNOWN_ACTION + ); + await SpecialPowers.pushPrefEnv({ + set: [ + // We disable browser.download.always_ask_before_handling_new_types here + // since the test expects the download to be saved directly to disk and + // not prompted by the UnknownContentType window. + ["browser.download.always_ask_before_handling_new_types", false], + ["browser.download.enable_spam_prevention", true], + ], + }); + + registerCleanupFunction(async () => { + Services.prefs.clearUserPref("browser.download.folderList"); + Services.prefs.clearUserPref("browser.download.dir"); + await IOUtils.remove(tempDir.path, { recursive: true }); + }); +}); + +add_task(async function check_download_spam_permissions() { + const INITIAL_TABS_COUNT = gBrowser.tabs.length; + let publicList = await Downloads.getList(Downloads.PUBLIC); + let downloadFinishedPromise = promiseDownloadFinished( + publicList, + true /* stop the download from openning */ + ); + let blockedDownloadsCount = 0; + let blockedDownloadsURI = ""; + let automaticDownloadObserver = { + observe: function automatic_download_observe(aSubject, aTopic, aData) { + if (aTopic === AUTOMATIC_DOWNLOAD_TOPIC) { + blockedDownloadsCount++; + blockedDownloadsURI = aData; + } + }, + }; + Services.obs.addObserver(automaticDownloadObserver, AUTOMATIC_DOWNLOAD_TOPIC); + + let newTab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + TEST_PATH + "test_spammy_page.html" + ); + registerCleanupFunction(async () => { + DownloadIntegration.downloadSpamProtection.removeDownloadSpamForWindow( + TEST_URI, + window + ); + DownloadsPanel.hidePanel(); + await publicList.removeFinished(); + BrowserTestUtils.removeTab(newTab); + Services.obs.removeObserver( + automaticDownloadObserver, + AUTOMATIC_DOWNLOAD_TOPIC + ); + }); + + let download = await downloadFinishedPromise; + await TestUtils.waitForCondition( + () => gBrowser.tabs.length == INITIAL_TABS_COUNT + 1 + ); + is( + PermissionTestUtils.testPermission(TEST_URI, "automatic-download"), + Services.perms.PROMPT_ACTION, + "The permission to prompt the user should be stored." + ); + + ok( + await IOUtils.exists(download.target.path), + "One file should be downloaded" + ); + + let aCopyFilePath = download.target.path.replace(".pdf", "(1).pdf"); + is( + await IOUtils.exists(aCopyFilePath), + false, + "An other file should be blocked" + ); + + info("Will wait for blockedDownloadsCount to be >= 99"); + await TestUtils.waitForCondition(() => blockedDownloadsCount >= 99); + is(blockedDownloadsCount, 99, "Browser should block 99 downloads"); + is( + blockedDownloadsURI, + TEST_URI, + "The test URI should have blocked automatic downloads" + ); + + await savelink(); +}); + +// Check to ensure that a link saved manually is not blocked. +async function savelink() { + let publicList = await Downloads.getList(Downloads.PUBLIC); + let menu = document.getElementById("contentAreaContextMenu"); + let popupShown = BrowserTestUtils.waitForEvent(menu, "popupshown"); + BrowserTestUtils.synthesizeMouse( + "#image", + 5, + 5, + { type: "contextmenu", button: 2 }, + gBrowser.selectedBrowser + ); + await popupShown; + + await new Promise(resolve => { + MockFilePicker.showCallback = function (fp) { + resolve(); + let file = gTempDownloadDir.clone(); + file.append("file_with__funny_name.png"); + MockFilePicker.setFiles([file]); + return Ci.nsIFilePicker.returnOK; + }; + let menuitem = menu.querySelector("#context-savelink"); + menu.activateItem(menuitem); + }); + + await promiseDownloadFinished( + publicList, + true // stop the download from openning + ); +} diff --git a/uriloader/exthandler/tests/mochitest/browser_download_urlescape.js b/uriloader/exthandler/tests/mochitest/browser_download_urlescape.js new file mode 100644 index 0000000000..a2d10f69aa --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/browser_download_urlescape.js @@ -0,0 +1,75 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const TEST_PATH = getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "https://example.com" +); + +var MockFilePicker = SpecialPowers.MockFilePicker; +MockFilePicker.init(window); +registerCleanupFunction(() => MockFilePicker.cleanup()); + +/** + * Check downloading files URL-escapes content-disposition + * information when necessary. + */ +add_task(async function test_check_filename_urlescape() { + let pendingPromise; + let pendingTest = ""; + let expectedFileName = ""; + MockFilePicker.showCallback = function (fp) { + info(`${pendingTest} - Filepicker shown, checking filename`); + is( + fp.defaultString, + expectedFileName, + `${pendingTest} - Should have escaped filename` + ); + ok( + pendingPromise, + `${pendingTest} - Should have expected this picker open.` + ); + if (pendingPromise) { + pendingPromise.resolve(); + } + return Ci.nsIFilePicker.returnCancel; + }; + function runTestFor(fileName, selector) { + return BrowserTestUtils.withNewTab(TEST_PATH + fileName, async browser => { + expectedFileName = fileName; + let tabLabel = gBrowser.getTabForBrowser(browser).getAttribute("label"); + ok( + tabLabel.startsWith(fileName), + `"${tabLabel}" should have been escaped.` + ); + + pendingTest = "save browser"; + pendingPromise = PromiseUtils.defer(); + // First try to save the browser + saveBrowser(browser); + await pendingPromise.promise; + + // Next, try the context menu: + pendingTest = "save from context menu"; + pendingPromise = PromiseUtils.defer(); + let menu = document.getElementById("contentAreaContextMenu"); + let menuShown = BrowserTestUtils.waitForEvent(menu, "popupshown"); + BrowserTestUtils.synthesizeMouse( + selector, + 5, + 5, + { type: "contextmenu", button: 2 }, + browser + ); + await menuShown; + gContextMenu.saveMedia(); + menu.hidePopup(); + await pendingPromise.promise; + pendingPromise = null; + }); + } + await runTestFor("file_with@@funny_name.png", "img"); + await runTestFor("file_with[funny_name.webm", "video"); +}); diff --git a/uriloader/exthandler/tests/mochitest/browser_extension_correction.js b/uriloader/exthandler/tests/mochitest/browser_extension_correction.js new file mode 100644 index 0000000000..9d67bf7213 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/browser_extension_correction.js @@ -0,0 +1,222 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const TEST_PATH = getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "https://example.com" +); + +let gPathsToRemove = []; + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [["browser.download.useDownloadDir", true]], + }); + registerCleanupFunction(async () => { + for (let path of gPathsToRemove) { + // IOUtils.remove ignores non-existing files out of the box. + await IOUtils.remove(path); + } + let publicList = await Downloads.getList(Downloads.PUBLIC); + await publicList.removeFinished(); + }); +}); + +async function testLinkWithoutExtension(type, shouldHaveExtension) { + info("Checking " + type); + + let task = function () { + return SpecialPowers.spawn(gBrowser.selectedBrowser, [type], mimetype => { + let link = content.document.createElement("a"); + link.textContent = "Click me"; + link.href = "data:" + mimetype + ",hello"; + link.download = "somefile"; + content.document.body.appendChild(link); + link.click(); + }); + }; + + await checkDownloadWithExtensionState(task, { + type, + shouldHaveExtension, + alwaysViewPDFInline: false, + }); + + if (type == "application/pdf") { + // For PDF, try again with the always open inline preference set + await SpecialPowers.pushPrefEnv({ + set: [["browser.download.open_pdf_attachments_inline", true]], + }); + + await checkDownloadWithExtensionState(task, { + type, + shouldHaveExtension, + alwaysViewPDFInline: true, + }); + + await SpecialPowers.popPrefEnv(); + } +} + +async function checkDownloadWithExtensionState( + task, + { type, shouldHaveExtension, expectedName = null, alwaysViewPDFInline } +) { + const shouldExpectDialog = Services.prefs.getBoolPref( + "browser.download.always_ask_before_handling_new_types", + false + ); + + let winPromise; + if (shouldExpectDialog) { + winPromise = BrowserTestUtils.domWindowOpenedAndLoaded(); + } + + let publicList = await Downloads.getList(Downloads.PUBLIC); + let shouldCheckFilename = shouldHaveExtension || !shouldExpectDialog; + + let downloadFinishedPromise = shouldCheckFilename + ? promiseDownloadFinished(publicList) + : null; + + // PDF should load using the internal viewer without downloading it. + let waitForLoad; + if ( + (!shouldExpectDialog || alwaysViewPDFInline) && + type == "application/pdf" + ) { + waitForLoad = BrowserTestUtils.waitForNewTab(gBrowser); + } + + await task(); + await waitForLoad; + + let win; + if (shouldExpectDialog) { + info("Waiting for dialog."); + win = await winPromise; + } + + expectedName ??= shouldHaveExtension + ? "somefile." + getMIMEInfoForType(type).primaryExtension + : "somefile"; + + let closedPromise = true; + if (shouldExpectDialog) { + let actualName = win.document.getElementById("location").value; + closedPromise = BrowserTestUtils.windowClosed(win); + + if (shouldHaveExtension) { + is(actualName, expectedName, `${type} should get an extension`); + } else { + is(actualName, expectedName, `${type} should not get an extension`); + } + } + + if (shouldExpectDialog && shouldHaveExtension) { + // Then pick "save" in the dialog, if we have a dialog. + let dialog = win.document.getElementById("unknownContentType"); + win.document.getElementById("save").click(); + let button = dialog.getButton("accept"); + button.disabled = false; + dialog.acceptDialog(); + } + + if (!shouldExpectDialog && type == "application/pdf") { + if (alwaysViewPDFInline) { + is( + gURLBar.inputField.value, + "data:application/pdf,hello", + "url is correct for " + type + ); + } else { + ok( + gURLBar.inputField.value.startsWith("file://") && + gURLBar.inputField.value.endsWith("somefile.pdf"), + "url is correct for " + type + ); + } + + BrowserTestUtils.removeTab(gBrowser.selectedTab); + } + + if (shouldExpectDialog || !alwaysViewPDFInline || type != "application/pdf") { + // Wait for the download if it exists (may produce null). + let download = await downloadFinishedPromise; + if (download) { + // Check the download's extension is correct. + is( + PathUtils.filename(download.target.path), + expectedName, + `Downloaded file should match ${expectedName}` + ); + gPathsToRemove.push(download.target.path); + let pathToRemove = download.target.path; + // Avoid one file interfering with subsequent files. + await publicList.removeFinished(); + await IOUtils.remove(pathToRemove); + } else if (win) { + // We just cancel out for files that would end up without a path, as we'd + // prompt for a filename. + win.close(); + } + } + + return closedPromise; +} + +/** + * Check that for document types, images, videos and audio files, + * we enforce a useful extension. + */ +add_task(async function test_enforce_useful_extension() { + await BrowserTestUtils.withNewTab("data:text/html,", async browser => { + await testLinkWithoutExtension("image/png", true); + await testLinkWithoutExtension("audio/ogg", true); + await testLinkWithoutExtension("video/webm", true); + await testLinkWithoutExtension("application/pdf", true); + + await testLinkWithoutExtension("application/x-nonsense", false); + await testLinkWithoutExtension("application/octet-stream", false); + await testLinkWithoutExtension("binary/octet-stream", false); + await testLinkWithoutExtension("application/x-msdownload", false); + }); +}); + +/** + * Check that we still use URL extension info when we don't have anything else, + * despite bogus local info. + */ +add_task(async function test_broken_saved_handlerinfo_and_useless_mimetypes() { + let bogusType = getMIMEInfoForType("binary/octet-stream"); + registerCleanupFunction(() => { + handlerSvc.remove(bogusType); + }); + bogusType.setFileExtensions(["jpg"]); + let handlerSvc = Cc["@mozilla.org/uriloader/handler-service;1"].getService( + Ci.nsIHandlerService + ); + handlerSvc.store(bogusType); + let tabToClean = null; + let task = function () { + return BrowserTestUtils.openNewForegroundTab({ + gBrowser, + opening: TEST_PATH + "file_as.exe?foo=bar", + waitForLoad: false, + waitForStateStop: true, + }).then(tab => { + return (tabToClean = tab); + }); + }; + await checkDownloadWithExtensionState(task, { + type: "binary/octet-stream", + shouldHaveExtension: true, + expectedName: "file_as.exe", + }); + // Downloads should really close their tabs... + if (tabToClean?.isConnected) { + BrowserTestUtils.removeTab(tabToClean); + } +}); diff --git a/uriloader/exthandler/tests/mochitest/browser_filehandling_loop.js b/uriloader/exthandler/tests/mochitest/browser_filehandling_loop.js new file mode 100644 index 0000000000..0aabb222d9 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/browser_filehandling_loop.js @@ -0,0 +1,93 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * If the user has set Firefox itself as a helper app, + * we should force prompting what to do, rather than ending up + * in an infinite loop. + * In an ideal world, we'd also test the case where we are the OS + * default handler app, but that would require test infrastructure + * to make ourselves the OS default (or at least fool ourselves into + * believing we are) which we don't have... + */ +add_task(async function test_helperapp() { + // Set up the test infrastructure: + const mimeSvc = Cc["@mozilla.org/mime;1"].getService(Ci.nsIMIMEService); + const handlerSvc = Cc["@mozilla.org/uriloader/handler-service;1"].getService( + Ci.nsIHandlerService + ); + let handlerInfo = mimeSvc.getFromTypeAndExtension("application/x-foo", "foo"); + registerCleanupFunction(() => { + handlerSvc.remove(handlerInfo); + }); + // Say we want to use a specific app: + handlerInfo.preferredAction = Ci.nsIHandlerInfo.useHelperApp; + handlerInfo.alwaysAskBeforeHandling = false; + + // Say it's us: + let selfFile = Services.dirsvc.get("XREExeF", Ci.nsIFile); + // Make sure it's the .app + if (AppConstants.platform == "macosx") { + while ( + !selfFile.leafName.endsWith(".app") && + !selfFile.leafName.endsWith(".app/") + ) { + selfFile = selfFile.parent; + } + } + let selfHandlerApp = Cc[ + "@mozilla.org/uriloader/local-handler-app;1" + ].createInstance(Ci.nsILocalHandlerApp); + selfHandlerApp.executable = selfFile; + handlerInfo.possibleApplicationHandlers.appendElement(selfHandlerApp); + handlerInfo.preferredApplicationHandler = selfHandlerApp; + handlerSvc.store(handlerInfo); + + await BrowserTestUtils.withNewTab("about:blank", async browser => { + // Now, do some safety stubbing. If we do end up recursing we spawn + // infinite tabs. We definitely don't want that. Avoid it by stubbing + // our external URL handling bits: + let oldAddTab = gBrowser.addTab; + registerCleanupFunction(() => (gBrowser.addTab = oldAddTab)); + let wrongThingHappenedPromise = new Promise(resolve => { + gBrowser.addTab = function (aURI) { + ok(false, "Tried to open unexpected URL in a tab: " + aURI); + resolve(null); + // Pass a dummy object to avoid upsetting BrowserContentHandler - + // if it thinks opening the tab failed, it tries to open a window instead, + // which we can't prevent as easily, and at which point we still end up + // with runaway tabs. + return {}; + }; + }); + + let askedUserPromise = BrowserTestUtils.domWindowOpenedAndLoaded(); + + info("Clicking a link that should open the unknown content type dialog"); + await SpecialPowers.spawn(browser, [], () => { + let link = content.document.createElement("a"); + link.download = "foo.foo"; + link.textContent = "Foo file"; + link.href = "data:application/x-foo,hello"; + content.document.body.append(link); + link.click(); + }); + let dialog = await Promise.race([ + wrongThingHappenedPromise, + askedUserPromise, + ]); + ok(dialog, "Should have gotten a dialog"); + Assert.stringContains( + dialog.document.location.href, + "unknownContentType", + "Should have opened correct dialog." + ); + + let closePromise = BrowserTestUtils.windowClosed(dialog); + dialog.close(); + await closePromise; + askedUserPromise = null; + }); +}); diff --git a/uriloader/exthandler/tests/mochitest/browser_first_prompt_not_blocked_without_user_interaction.js b/uriloader/exthandler/tests/mochitest/browser_first_prompt_not_blocked_without_user_interaction.js new file mode 100644 index 0000000000..db92946d44 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/browser_first_prompt_not_blocked_without_user_interaction.js @@ -0,0 +1,70 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const TEST_PATH = getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "https://example.com" +); + +add_task(setupMailHandler); + +add_task(async function test_open_without_user_interaction() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["dom.disable_open_during_load", true], + ["dom.block_external_protocol_in_iframes", true], + ["dom.delay.block_external_protocol_in_iframes.enabled", false], + ], + }); + + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + + let dialogWindowPromise = waitForProtocolAppChooserDialog( + tab.linkedBrowser, + true + ); + + BrowserTestUtils.loadURIString( + tab.linkedBrowser, + TEST_PATH + "file_external_protocol_iframe.html" + ); + + let dialog = await dialogWindowPromise; + ok(dialog, "Should show the dialog even without user interaction"); + + let dialogClosedPromise = waitForProtocolAppChooserDialog( + tab.linkedBrowser, + false + ); + + // Adding another iframe without user interaction should be blocked. + let blockedWarning = new Promise(resolve => { + Services.console.registerListener(function onMessage(msg) { + let { message, logLevel } = msg; + if (logLevel != Ci.nsIConsoleMessage.warn) { + return; + } + if (!message.includes("Iframe with external protocol was blocked")) { + return; + } + Services.console.unregisterListener(onMessage); + resolve(); + }); + }); + + info("Adding another frame without user interaction"); + + await SpecialPowers.spawn(tab.linkedBrowser, [], async function () { + let frame = content.document.createElement("iframe"); + frame.src = "mailto:foo@baz.com"; + content.document.body.appendChild(frame); + }); + + await blockedWarning; + + info("Removing tab to close the dialog."); + gBrowser.removeTab(tab); + await dialogClosedPromise; +}); diff --git a/uriloader/exthandler/tests/mochitest/browser_ftp_protocol_handlers.js b/uriloader/exthandler/tests/mochitest/browser_ftp_protocol_handlers.js new file mode 100644 index 0000000000..2234034555 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/browser_ftp_protocol_handlers.js @@ -0,0 +1,105 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +let testURL = + "https://example.com/browser/" + + "uriloader/exthandler/tests/mochitest/FTPprotocolHandler.html"; + +add_task(async function () { + await SpecialPowers.pushPrefEnv({ + set: [["security.external_protocol_requires_permission", false]], + }); + + // Load a page registering a protocol handler. + let browser = gBrowser.selectedBrowser; + BrowserTestUtils.loadURIString(browser, testURL); + await BrowserTestUtils.browserLoaded(browser, false, testURL); + + // Register the protocol handler by clicking the notificationbar button. + let notificationValue = "Protocol Registration: ftp"; + let getNotification = () => + gBrowser.getNotificationBox().getNotificationWithValue(notificationValue); + await BrowserTestUtils.waitForCondition(getNotification); + let notification = getNotification(); + let button = notification.buttonContainer.querySelector("button"); + ok(button, "got registration button"); + button.click(); + + // Set the new handler as default. + const protoSvc = Cc[ + "@mozilla.org/uriloader/external-protocol-service;1" + ].getService(Ci.nsIExternalProtocolService); + let protoInfo = protoSvc.getProtocolHandlerInfo("ftp"); + ok(!protoInfo.preferredApplicationHandler, "no preferred handler is set"); + let handlers = protoInfo.possibleApplicationHandlers; + is(1, handlers.length, "only one handler registered for ftp"); + let handler = handlers.queryElementAt(0, Ci.nsIHandlerApp); + ok(handler instanceof Ci.nsIWebHandlerApp, "the handler is a web handler"); + is( + handler.uriTemplate, + "https://example.com/browser/uriloader/exthandler/tests/mochitest/blank.html?uri=%s", + "correct url template" + ); + protoInfo.preferredAction = protoInfo.useHelperApp; + protoInfo.preferredApplicationHandler = handler; + protoInfo.alwaysAskBeforeHandling = false; + const handlerSvc = Cc["@mozilla.org/uriloader/handler-service;1"].getService( + Ci.nsIHandlerService + ); + handlerSvc.store(protoInfo); + + const expectedURL = + "https://example.com/browser/uriloader/exthandler/tests/mochitest/blank.html?uri=ftp%3A%2F%2Fdomain.com%2Fpath"; + + // Middle-click a testprotocol link and check the new tab is correct + let link = "#link"; + + let promiseTabOpened = BrowserTestUtils.waitForNewTab(gBrowser, expectedURL); + await BrowserTestUtils.synthesizeMouseAtCenter(link, { button: 1 }, browser); + let tab = await promiseTabOpened; + gBrowser.selectedTab = tab; + is( + gURLBar.value, + expectedURL, + "the expected URL is displayed in the location bar" + ); + BrowserTestUtils.removeTab(tab); + + // Shift-click the testprotocol link and check the new window. + let newWindowPromise = BrowserTestUtils.waitForNewWindow({ + url: expectedURL, + }); + await BrowserTestUtils.synthesizeMouseAtCenter( + link, + { shiftKey: true }, + browser + ); + let win = await newWindowPromise; + await BrowserTestUtils.waitForCondition( + () => win.gBrowser.currentURI.spec == expectedURL + ); + is( + win.gURLBar.value, + expectedURL, + "the expected URL is displayed in the location bar" + ); + await BrowserTestUtils.closeWindow(win); + + // Click the testprotocol link and check the url in the current tab. + let loadPromise = BrowserTestUtils.browserLoaded(browser); + await BrowserTestUtils.synthesizeMouseAtCenter(link, {}, browser); + await loadPromise; + await BrowserTestUtils.waitForCondition(() => gURLBar.value != testURL); + is( + gURLBar.value, + expectedURL, + "the expected URL is displayed in the location bar" + ); + + // Cleanup. + protoInfo.preferredApplicationHandler = null; + handlers.removeElementAt(0); + handlerSvc.store(protoInfo); +}); diff --git a/uriloader/exthandler/tests/mochitest/browser_launched_app_save_directory.js b/uriloader/exthandler/tests/mochitest/browser_launched_app_save_directory.js new file mode 100644 index 0000000000..993a4f162b --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/browser_launched_app_save_directory.js @@ -0,0 +1,114 @@ +const { DownloadIntegration } = ChromeUtils.importESModule( + "resource://gre/modules/DownloadIntegration.sys.mjs" +); + +const { TestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TestUtils.sys.mjs" +); + +const TEST_PATH = getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "https://example.com" +); + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.download.always_ask_before_handling_new_types", false], + ["image.webp.enabled", true], + ], + }); + const allowDirectoriesVal = DownloadIntegration.allowDirectories; + DownloadIntegration.allowDirectories = true; + registerCleanupFunction(() => { + DownloadIntegration.allowDirectories = allowDirectoriesVal; + Services.prefs.clearUserPref("browser.download.dir"); + Services.prefs.clearUserPref("browser.download.folderList"); + }); +}); + +async function aDownloadLaunchedWithAppIsSavedInFolder(downloadDir) { + let publicList = await Downloads.getList(Downloads.PUBLIC); + registerCleanupFunction(async () => { + await publicList.removeFinished(); + }); + + let downloadFinishedPromise = promiseDownloadFinished(publicList); + let initialTabsCount = gBrowser.tabs.length; + + let loadingTab = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + opening: TEST_PATH + "file_green.webp", + waitForLoad: false, + waitForStateStop: true, + }); + + let download = await downloadFinishedPromise; + await BrowserTestUtils.waitForCondition( + () => gBrowser.tabs.length == initialTabsCount + 2 + ); + + gBrowser.removeCurrentTab(); + BrowserTestUtils.removeTab(loadingTab); + + ok( + download.target.path.startsWith(downloadDir), + "Download should be placed in default download directory: " + + downloadDir + + ", and it's located in " + + download.target.path + ); + + Assert.ok( + await IOUtils.exists(download.target.path), + "The file should not have been deleted." + ); + + try { + info("removing " + download.target.path); + if (Services.appinfo.OS === "WINNT") { + // We need to make the file writable to delete it on Windows. + await IOUtils.setPermissions(download.target.path, 0o600); + } + await IOUtils.remove(download.target.path); + } catch (ex) { + info("The file " + download.target.path + " is not removed, " + ex); + } +} + +add_task(async function aDownloadLaunchedWithAppIsSavedInCustomDir() { + //Test the temp dir. + let time = new Date().getTime(); + let tempDir = Services.dirsvc.get("TmpD", Ci.nsIFile); + tempDir.append(time); + Services.prefs.setComplexValue("browser.download.dir", Ci.nsIFile, tempDir); + let downloadDir = await DownloadIntegration.getPreferredDownloadsDirectory(); + Assert.notEqual(downloadDir, ""); + Assert.equal(downloadDir, tempDir.path); + Assert.ok(await IOUtils.exists(downloadDir)); + registerCleanupFunction(async () => { + await IOUtils.remove(tempDir.path, { recursive: true }); + }); + await aDownloadLaunchedWithAppIsSavedInFolder(downloadDir); +}); + +add_task(async function aDownloadLaunchedWithAppIsSavedInDownloadsDir() { + // Test the system downloads directory. + Services.prefs.setIntPref("browser.download.folderList", 1); + let systemDir = await DownloadIntegration.getSystemDownloadsDirectory(); + let downloadDir = await DownloadIntegration.getPreferredDownloadsDirectory(); + Assert.notEqual(downloadDir, ""); + Assert.equal(downloadDir, systemDir); + + await aDownloadLaunchedWithAppIsSavedInFolder(downloadDir); +}); + +add_task(async function aDownloadLaunchedWithAppIsSavedInDesktopDir() { + // Test the desktop directory. + Services.prefs.setIntPref("browser.download.folderList", 0); + let downloadDir = await DownloadIntegration.getPreferredDownloadsDirectory(); + Assert.notEqual(downloadDir, ""); + Assert.equal(downloadDir, Services.dirsvc.get("Desk", Ci.nsIFile).path); + + await aDownloadLaunchedWithAppIsSavedInFolder(downloadDir); +}); diff --git a/uriloader/exthandler/tests/mochitest/browser_local_files_no_save_without_asking.js b/uriloader/exthandler/tests/mochitest/browser_local_files_no_save_without_asking.js new file mode 100644 index 0000000000..cfa4788a43 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/browser_local_files_no_save_without_asking.js @@ -0,0 +1,61 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Check that loading a local PDF file + * prompts the user when pdfjs.disabled is set to true, + * and alwaysAsk is false; + */ +add_task( + async function test_check_browser_local_files_no_save_without_asking() { + // Get a ref to the pdf we want to open. + let file = getChromeDir(getResolvedURI(gTestPath)); + file.append("file_pdf_binary_octet_stream.pdf"); + + await SpecialPowers.pushPrefEnv({ set: [["pdfjs.disabled", true]] }); + + const mimeSvc = Cc["@mozilla.org/mime;1"].getService(Ci.nsIMIMEService); + const handlerSvc = Cc[ + "@mozilla.org/uriloader/handler-service;1" + ].getService(Ci.nsIHandlerService); + const mimeInfo = mimeSvc.getFromTypeAndExtension("application/pdf", "pdf"); + // This test covers a bug that only occurs when the mimeInfo is set to Always Ask = false + // Here we check if we ask the user what to do for local files, if the file is set to save to disk automatically; + // that is, we check that we prompt the user despite the user's preference. + mimeInfo.preferredAction = mimeInfo.saveToDisk; + mimeInfo.alwaysAskBeforeHandling = false; + handlerSvc.store(mimeInfo); + + info("Testing with " + file.path); + let publicList = await Downloads.getList(Downloads.PUBLIC); + registerCleanupFunction(async () => { + await publicList.removeFinished(); + }); + + let publicDownloads = await publicList.getAll(); + is( + publicDownloads.length, + 0, + "download should not appear in publicDownloads list" + ); + + let dialogWindowPromise = BrowserTestUtils.domWindowOpenedAndLoaded(); + var loadingTab = BrowserTestUtils.addTab(gBrowser, file.path); + + let dialogWindow = await dialogWindowPromise; + + is( + dialogWindow.location.href, + "chrome://mozapps/content/downloads/unknownContentType.xhtml", + "Should have seen the unknown content dialogWindow." + ); + + let doc = dialogWindow.document; + + let dialog = doc.querySelector("#unknownContentType"); + dialog.cancelDialog(); + BrowserTestUtils.removeTab(loadingTab); + } +); diff --git a/uriloader/exthandler/tests/mochitest/browser_local_files_open_doesnt_duplicate.js b/uriloader/exthandler/tests/mochitest/browser_local_files_open_doesnt_duplicate.js new file mode 100644 index 0000000000..d8e7c87c10 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/browser_local_files_open_doesnt_duplicate.js @@ -0,0 +1,121 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const mimeSvc = Cc["@mozilla.org/mime;1"].getService(Ci.nsIMIMEService); +const handlerSvc = Cc["@mozilla.org/uriloader/handler-service;1"].getService( + Ci.nsIHandlerService +); +let mimeInfo = mimeSvc.getFromTypeAndExtension("application/pdf", "pdf"); + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ set: [["pdfjs.disabled", true]] }); + + let oldAsk = mimeInfo.alwaysAskBeforeHandling; + let oldPreferredAction = mimeInfo.preferredAction; + let oldPreferredApp = mimeInfo.preferredApplicationHandler; + registerCleanupFunction(() => { + mimeInfo.preferredApplicationHandler = oldPreferredApp; + mimeInfo.preferredAction = oldPreferredAction; + mimeInfo.alwaysAskBeforeHandling = oldAsk; + handlerSvc.store(mimeInfo); + }); + + if (!mimeInfo.preferredApplicationHandler) { + let handlerApp = Cc[ + "@mozilla.org/uriloader/local-handler-app;1" + ].createInstance(Ci.nsILocalHandlerApp); + handlerApp.executable = Services.dirsvc.get("TmpD", Ci.nsIFile); + handlerApp.executable.append("foopydoo.exe"); + mimeInfo.possibleApplicationHandlers.appendElement(handlerApp); + mimeInfo.preferredApplicationHandler = handlerApp; + } +}); + +add_task(async function open_from_dialog() { + // Force PDFs to prompt: + mimeInfo.preferredAction = mimeInfo.useHelperApp; + mimeInfo.alwaysAskBeforeHandling = true; + handlerSvc.store(mimeInfo); + + let openingPromise = TestUtils.topicObserved( + "test-only-opening-downloaded-file", + (subject, data) => { + subject.QueryInterface(Ci.nsISupportsPRBool); + // Block opening the file: + subject.data = false; + return true; + } + ); + + let dialogWindowPromise = BrowserTestUtils.domWindowOpenedAndLoaded(); + let openedFile = getChromeDir(getResolvedURI(gTestPath)); + openedFile.append("file_pdf_binary_octet_stream.pdf"); + let expectedPath = openedFile.isSymlink() + ? openedFile.target + : openedFile.path; + let loadingTab = BrowserTestUtils.addTab(gBrowser, expectedPath); + + let dialogWindow = await dialogWindowPromise; + is( + dialogWindow.location.href, + "chrome://mozapps/content/downloads/unknownContentType.xhtml", + "Should have seen the unknown content dialogWindow." + ); + + let doc = dialogWindow.document; + + // Select the 'open' entry. + doc.querySelector("#open").click(); + let dialog = doc.querySelector("#unknownContentType"); + dialog.getButton("accept").removeAttribute("disabled"); + dialog.acceptDialog(); + let [, openedPath] = await openingPromise; + is( + openedPath, + expectedPath, + "Should have opened file directly (not created a copy)." + ); + if (openedPath != expectedPath) { + await IOUtils.setPermissions(openedPath, 0o666); + await IOUtils.remove(openedPath); + } + BrowserTestUtils.removeTab(loadingTab); +}); + +add_task(async function open_directly() { + // Force PDFs to open immediately: + mimeInfo.preferredAction = mimeInfo.useHelperApp; + mimeInfo.alwaysAskBeforeHandling = false; + handlerSvc.store(mimeInfo); + + let openingPromise = TestUtils.topicObserved( + "test-only-opening-downloaded-file", + (subject, data) => { + subject.QueryInterface(Ci.nsISupportsPRBool); + // Block opening the file: + subject.data = false; + return true; + } + ); + + let openedFile = getChromeDir(getResolvedURI(gTestPath)); + openedFile.append("file_pdf_binary_octet_stream.pdf"); + let expectedPath = openedFile.isSymlink() + ? openedFile.target + : openedFile.path; + let loadingTab = BrowserTestUtils.addTab(gBrowser, expectedPath); + + let [, openedPath] = await openingPromise; + is( + openedPath, + expectedPath, + "Should have opened file directly (not created a copy)." + ); + if (openedPath != expectedPath) { + await IOUtils.setPermissions(openedPath, 0o666); + await IOUtils.remove(openedPath); + } + BrowserTestUtils.removeTab(loadingTab); +}); diff --git a/uriloader/exthandler/tests/mochitest/browser_open_internal_choice_persistence.js b/uriloader/exthandler/tests/mochitest/browser_open_internal_choice_persistence.js new file mode 100644 index 0000000000..cbbf5ddedf --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/browser_open_internal_choice_persistence.js @@ -0,0 +1,310 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { Downloads } = ChromeUtils.importESModule( + "resource://gre/modules/Downloads.sys.mjs" +); +const { DownloadIntegration } = ChromeUtils.importESModule( + "resource://gre/modules/DownloadIntegration.sys.mjs" +); + +const TEST_PATH = getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "https://example.com" +); + +const { + handleInternally, + saveToDisk, + useSystemDefault, + alwaysAsk, + useHelperApp, +} = Ci.nsIHandlerInfo; + +function waitForAcceptButtonToGetEnabled(doc) { + let dialog = doc.querySelector("#unknownContentType"); + let button = dialog.getButton("accept"); + return TestUtils.waitForCondition( + () => !button.disabled, + "Wait for Accept button to get enabled" + ); +} + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [ + // Remove the security delay for the dialog during the test. + ["security.dialog_enable_delay", 0], + ["browser.helperApps.showOpenOptionForViewableInternally", true], + // Make sure we don't open a file picker dialog somehow. + ["browser.download.useDownloadDir", true], + ], + }); + + // Restore handlers after the whole test has run + const registerRestoreHandler = function (type, ext) { + const mimeInfo = gMimeSvc.getFromTypeAndExtension(type, ext); + const existed = gHandlerSvc.exists(mimeInfo); + registerCleanupFunction(() => { + if (existed) { + gHandlerSvc.store(mimeInfo); + } else { + gHandlerSvc.remove(mimeInfo); + } + }); + }; + registerRestoreHandler("image/svg+xml", "svg"); +}); + +function ensureMIMEState({ preferredAction, alwaysAskBeforeHandling }) { + const mimeInfo = gMimeSvc.getFromTypeAndExtension("image/svg+xml", "svg"); + mimeInfo.preferredAction = preferredAction; + mimeInfo.alwaysAskBeforeHandling = alwaysAskBeforeHandling; + gHandlerSvc.store(mimeInfo); +} + +function waitDelay(delay) { + return new Promise((resolve, reject) => { + /* eslint-disable mozilla/no-arbitrary-setTimeout */ + window.setTimeout(resolve, delay); + }); +} + +function promisePanelOpened() { + if (DownloadsPanel.panel && DownloadsPanel.panel.state == "open") { + return Promise.resolve(); + } + return BrowserTestUtils.waitForEvent(DownloadsPanel.panel, "popupshown"); +} + +const kTestCasesPrefEnabled = [ + { + description: + "Pref enabled - internal handling as default should not change prefs", + preDialogState: { + preferredAction: handleInternally, + alwaysAskBeforeHandling: false, + }, + expectTab: true, + expectLaunch: false, + expectedPreferredAction: handleInternally, + expectedAlwaysAskBeforeHandling: false, + expectUCT: false, + }, + { + description: + "Pref enabled - external handling as default should not change prefs", + preDialogState: { + preferredAction: useSystemDefault, + alwaysAskBeforeHandling: false, + }, + expectTab: false, + expectLaunch: true, + expectedPreferredAction: useSystemDefault, + expectedAlwaysAskBeforeHandling: false, + expectUCT: false, + }, + { + description: "Pref enabled - saveToDisk as default should not change prefs", + preDialogState: { + preferredAction: saveToDisk, + alwaysAskBeforeHandling: false, + }, + expectTab: false, + expectLaunch: false, + expectedPreferredAction: saveToDisk, + expectedAlwaysAskBeforeHandling: false, + expectUCT: false, + }, + { + description: + "Pref enabled - choose internal + alwaysAsk default + checkbox should update persisted default", + preDialogState: { + preferredAction: alwaysAsk, + alwaysAskBeforeHandling: false, + }, + dialogActions(doc) { + let handleItem = doc.querySelector("#handleInternally"); + handleItem.click(); + ok(handleItem.selected, "The 'open' option should now be selected"); + let checkbox = doc.querySelector("#rememberChoice"); + checkbox.checked = true; + checkbox.doCommand(); + }, + // new tab will not launch in test environment when alwaysAsk is preferredAction + expectTab: false, + expectLaunch: false, + expectedPreferredAction: handleInternally, + expectedAlwaysAskBeforeHandling: false, + expectUCT: true, + }, + { + description: + "Pref enabled - saveToDisk with alwaysAsk default should update persisted default", + preDialogState: { + preferredAction: alwaysAsk, + alwaysAskBeforeHandling: false, + }, + dialogActions(doc) { + let saveItem = doc.querySelector("#save"); + saveItem.click(); + ok(saveItem.selected, "The 'save' option should now be selected"); + }, + expectTab: false, + expectLaunch: false, + expectedPreferredAction: saveToDisk, + expectedAlwaysAskBeforeHandling: false, + expectUCT: true, + }, +]; + +add_task( + async function test_check_saving_handler_choices_with_always_ask_before_handling_new_types_pref_enabled() { + SpecialPowers.pushPrefEnv({ + set: [["browser.download.always_ask_before_handling_new_types", false]], + }); + + let publicList = await Downloads.getList(Downloads.PUBLIC); + registerCleanupFunction(async () => { + await publicList.removeFinished(); + }); + let file = "file_image_svgxml.svg"; + + for (let testCase of kTestCasesPrefEnabled) { + info("Testing with " + file + "; " + testCase.description); + ensureMIMEState(testCase.preDialogState); + const { expectTab, expectLaunch, description, expectUCT } = testCase; + + let oldLaunchFile = DownloadIntegration.launchFile; + let fileLaunched = PromiseUtils.defer(); + DownloadIntegration.launchFile = () => { + ok( + expectLaunch, + `The file should ${ + expectLaunch ? "" : "not " + }be launched with an external application - ${description}` + ); + fileLaunched.resolve(); + }; + + info("Load window and tabs"); + let dialogWindowPromise = BrowserTestUtils.domWindowOpenedAndLoaded(); + let loadingTab = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + opening: TEST_PATH + file, + waitForLoad: false, + waitForStateStop: true, + }); + + // See if UCT window appears in loaded tab. + let dialogWindow = await Promise.race([ + waitDelay(1000), + dialogWindowPromise, + ]); + + is( + !!dialogWindow, + expectUCT, + `UCT window should${expectUCT ? "" : " not"} have appeared` + ); + + let download; + + if (dialogWindow) { + is( + dialogWindow.location.href, + "chrome://mozapps/content/downloads/unknownContentType.xhtml", + "Unknown content dialogWindow should be loaded correctly." + ); + let doc = dialogWindow.document; + let internalHandlerRadio = doc.querySelector("#handleInternally"); + + info("Waiting for accept button to get enabled"); + await waitForAcceptButtonToGetEnabled(doc); + + ok( + !internalHandlerRadio.hidden, + "The option should be visible for SVG" + ); + + info("Running UCT dialog options before downloading file"); + await testCase.dialogActions(doc); + + let dialog = doc.querySelector("#unknownContentType"); + dialog.acceptDialog(); + + info("Waiting for downloads to finish"); + let downloadFinishedPromise = promiseDownloadFinished(publicList); + download = await downloadFinishedPromise; + } else { + let downloadPanelPromise = promisePanelOpened(); + await downloadPanelPromise; + is( + DownloadsPanel.isPanelShowing, + true, + "DownloadsPanel should be open" + ); + + info("Skipping UCT dialog options"); + info("Waiting for downloads to finish"); + // Unlike when the UCT window opens, the download immediately starts. + let downloadList = await publicList; + [download] = downloadList._downloads; + } + + if (expectLaunch) { + info("Waiting for launch to finish"); + await fileLaunched.promise; + } + DownloadIntegration.launchFile = oldLaunchFile; + + is( + download.contentType, + "image/svg+xml", + "File contentType should be correct" + ); + is( + download.source.url, + `${TEST_PATH + file}`, + "File name should be correct." + ); + is( + (await publicList.getAll()).length, + 1, + "download should appear in public list" + ); + + // Check mime info: + const mimeInfo = gMimeSvc.getFromTypeAndExtension("image/svg+xml", "svg"); + gHandlerSvc.fillHandlerInfo(mimeInfo, ""); + is( + mimeInfo.preferredAction, + testCase.expectedPreferredAction, + "preferredAction - " + description + ); + is( + mimeInfo.alwaysAskBeforeHandling, + testCase.expectedAlwaysAskBeforeHandling, + "alwaysAskBeforeHandling - " + description + ); + + info("Cleaning up"); + BrowserTestUtils.removeTab(loadingTab); + // By default, if internal is default with pref enabled, we view the svg file in + // in a new tab. Close this tab in order for the test case to pass. + if (expectTab && testCase.preferredAction !== alwaysAsk) { + BrowserTestUtils.removeTab(gBrowser.selectedTab); + } + await publicList.removeFinished(); + if (download?.target.exists) { + try { + await IOUtils.remove(download.target.path); + } catch (ex) { + /* ignore */ + } + } + } + } +); diff --git a/uriloader/exthandler/tests/mochitest/browser_pdf_save_as.js b/uriloader/exthandler/tests/mochitest/browser_pdf_save_as.js new file mode 100644 index 0000000000..a08fe342cc --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/browser_pdf_save_as.js @@ -0,0 +1,121 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const HandlerService = Cc[ + "@mozilla.org/uriloader/handler-service;1" +].getService(Ci.nsIHandlerService); +const MIMEService = Cc["@mozilla.org/mime;1"].getService(Ci.nsIMIMEService); + +const TEST_PATH = getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "https://example.com" +); + +const { saveToDisk, alwaysAsk, handleInternally, useSystemDefault } = + Ci.nsIHandlerInfo; +const MockFilePicker = SpecialPowers.MockFilePicker; +MockFilePicker.init(window); + +async function testPdfFilePicker(mimeInfo) { + await BrowserTestUtils.withNewTab( + `data:text/html,<a id="test-link" href="${TEST_PATH}/file_pdf_application_pdf.pdf">Test PDF Link</a>`, + async browser => { + let menu = document.getElementById("contentAreaContextMenu"); + ok(menu, "Context menu exists on the page"); + + let popupShown = BrowserTestUtils.waitForEvent(menu, "popupshown"); + BrowserTestUtils.synthesizeMouseAtCenter( + "a#test-link", + { type: "contextmenu", button: 2 }, + browser + ); + await popupShown; + info("Context menu popup was successfully displayed"); + + let filePickerPromise = new Promise(resolve => { + MockFilePicker.showCallback = fp => { + ok(true, "filepicker should be visible"); + ok( + fp.defaultExtension === "pdf", + "Default extension in filepicker should be pdf" + ); + ok( + fp.defaultString === "file_pdf_application_pdf.pdf", + "Default string name in filepicker should have the correct pdf file name" + ); + setTimeout(resolve, 0); + return Ci.nsIFilePicker.returnCancel; + }; + }); + + let menuitem = menu.querySelector("#context-savelink"); + menu.activateItem(menuitem); + await filePickerPromise; + } + ); +} + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.download.always_ask_before_handling_new_types", false], + ["browser.download.useDownloadDir", false], + ], + }); + + registerCleanupFunction(async () => { + let publicList = await Downloads.getList(Downloads.PUBLIC); + await publicList.removeFinished(); + + if (DownloadsPanel.isVisible) { + info("Closing downloads panel"); + let hiddenPromise = BrowserTestUtils.waitForEvent( + DownloadsPanel.panel, + "popuphidden" + ); + DownloadsPanel.hidePanel(); + await hiddenPromise; + } + + let mimeInfo = MIMEService.getFromTypeAndExtension( + "application/pdf", + "pdf" + ); + let existed = HandlerService.exists(mimeInfo); + if (existed) { + HandlerService.store(mimeInfo); + } else { + HandlerService.remove(mimeInfo); + } + + // We only want to run MockFilerPicker.cleanup after the entire test is run. + // Otherwise, we cannot use MockFilePicker for each preferredAction. + MockFilePicker.cleanup(); + }); +}); + +/** + * Tests that selecting the context menu item `Save Link As…` on a PDF link + * opens the file picker when always_ask_before_handling_new_types is disabled, + * regardless of preferredAction. + */ +add_task(async function test_pdf_save_as_link() { + let mimeInfo; + + for (let preferredAction of [ + saveToDisk, + alwaysAsk, + handleInternally, + useSystemDefault, + ]) { + mimeInfo = MIMEService.getFromTypeAndExtension("application/pdf", "pdf"); + mimeInfo.alwaysAskBeforeHandling = preferredAction === alwaysAsk; + mimeInfo.preferredAction = preferredAction; + HandlerService.store(mimeInfo); + + info(`Testing filepicker for preferredAction ${preferredAction}`); + await testPdfFilePicker(mimeInfo); + } +}); diff --git a/uriloader/exthandler/tests/mochitest/browser_protocol_ask_dialog.js b/uriloader/exthandler/tests/mochitest/browser_protocol_ask_dialog.js new file mode 100644 index 0000000000..74fa5004a5 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/browser_protocol_ask_dialog.js @@ -0,0 +1,464 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +requestLongerTimeout(2); + +const TEST_PATH = getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "https://example.com" +); + +const CONTENT_HANDLING_URL = + "chrome://mozapps/content/handling/appChooser.xhtml"; + +add_task(setupMailHandler); + +/** + * Check that if we open the protocol handler dialog from a subframe, we close + * it when closing the tab. + */ +add_task(async function test_closed_by_tab_closure() { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + TEST_PATH + "file_nested_protocol_request.html" + ); + + // Wait for the window and then click the link. + let dialogWindowPromise = waitForProtocolAppChooserDialog( + tab.linkedBrowser, + true + ); + + BrowserTestUtils.synthesizeMouseAtCenter( + "a:link", + {}, + tab.linkedBrowser.browsingContext.children[0] + ); + + let dialog = await dialogWindowPromise; + + is( + dialog._frame.contentDocument.location.href, + CONTENT_HANDLING_URL, + "Dialog URL is as expected" + ); + let dialogClosedPromise = waitForProtocolAppChooserDialog( + tab.linkedBrowser, + false + ); + + info("Removing tab to close the dialog."); + gBrowser.removeTab(tab); + await dialogClosedPromise; + ok(!dialog._frame.contentWindow, "The dialog should have been closed."); +}); + +/** + * Check that if we open the protocol handler dialog from a subframe, we close + * it when navigating the tab to a non-same-origin URL. + */ +add_task(async function test_closed_by_tab_navigation() { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + TEST_PATH + "file_nested_protocol_request.html" + ); + + // Wait for the window and then click the link. + let dialogWindowPromise = waitForProtocolAppChooserDialog( + tab.linkedBrowser, + true + ); + + BrowserTestUtils.synthesizeMouseAtCenter( + "a:link", + {}, + tab.linkedBrowser.browsingContext.children[0] + ); + let dialog = await dialogWindowPromise; + + is( + dialog._frame.contentDocument.location.href, + CONTENT_HANDLING_URL, + "Dialog URL is as expected" + ); + let dialogClosedPromise = waitForProtocolAppChooserDialog( + tab.linkedBrowser, + false + ); + info( + "Set up unload handler to ensure we don't break when the window global gets cleared" + ); + await SpecialPowers.spawn(tab.linkedBrowser, [], async function () { + content.addEventListener("unload", function () {}); + }); + + info("Navigating tab to a different but same origin page."); + BrowserTestUtils.loadURIString(tab.linkedBrowser, TEST_PATH); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser, false, TEST_PATH); + ok(dialog._frame.contentWindow, "Dialog should stay open."); + + // The use of weak references in various parts of the code means that we're + // susceptible to dropping crucial bits of our implementation on the floor, + // if they get GC'd, and then the test hangs. + // Do a bunch of GC/CC runs so that if we ever break, it's deterministic. + let numCycles = 3; + for (let i = 0; i < numCycles; i++) { + Cu.forceGC(); + Cu.forceCC(); + } + + info("Now navigate to a cross-origin page."); + const CROSS_ORIGIN_TEST_PATH = TEST_PATH.replace(".com", ".org"); + BrowserTestUtils.loadURIString(tab.linkedBrowser, CROSS_ORIGIN_TEST_PATH); + let loadPromise = BrowserTestUtils.browserLoaded( + tab.linkedBrowser, + false, + CROSS_ORIGIN_TEST_PATH + ); + await dialogClosedPromise; + ok(!dialog._frame.contentWindow, "The dialog should have been closed."); + + // Avoid errors from aborted loads by waiting for it to finish. + await loadPromise; + gBrowser.removeTab(tab); +}); + +/** + * Check that we cannot open more than one of these dialogs. + */ +add_task(async function test_multiple_dialogs() { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + TEST_PATH + "file_nested_protocol_request.html" + ); + + // Wait for the window and then click the link. + let dialogWindowPromise = waitForProtocolAppChooserDialog( + tab.linkedBrowser, + true + ); + BrowserTestUtils.synthesizeMouseAtCenter( + "a:link", + {}, + tab.linkedBrowser.browsingContext.children[0] + ); + let dialog = await dialogWindowPromise; + + is( + dialog._frame.contentDocument.location.href, + CONTENT_HANDLING_URL, + "Dialog URL is as expected" + ); + + // Navigate the parent frame: + await ContentTask.spawn(tab.linkedBrowser, [], () => + content.eval("location.href = 'mailto:help@example.com'") + ); + + // Wait for a few ticks: + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(r => setTimeout(r, 100)); + // Check we only have one dialog + + let tabDialogBox = gBrowser.getTabDialogBox(tab.linkedBrowser); + let dialogs = tabDialogBox + .getTabDialogManager() + ._dialogs.filter(d => d._openedURL == CONTENT_HANDLING_URL); + + is(dialogs.length, 1, "Should only have 1 dialog open"); + + // Close the dialog: + let dialogClosedPromise = waitForProtocolAppChooserDialog( + tab.linkedBrowser, + false + ); + dialog.close(); + dialog = await dialogClosedPromise; + + ok(!dialog._openedURL, "The dialog should have been closed."); + + // Then reopen the dialog again, to make sure we don't keep blocking: + dialogWindowPromise = waitForProtocolAppChooserDialog( + tab.linkedBrowser, + true + ); + BrowserTestUtils.synthesizeMouseAtCenter( + "a:link", + {}, + tab.linkedBrowser.browsingContext.children[0] + ); + dialog = await dialogWindowPromise; + + is( + dialog._frame.contentDocument.location.href, + CONTENT_HANDLING_URL, + "Second dialog URL is as expected" + ); + + dialogClosedPromise = waitForProtocolAppChooserDialog( + tab.linkedBrowser, + false + ); + info("Removing tab to close the dialog."); + gBrowser.removeTab(tab); + await dialogClosedPromise; + ok(!dialog._frame.contentWindow, "The dialog should have been closed again."); +}); + +/** + * Check that navigating invisible frames to external-proto URLs + * is handled correctly. + */ +add_task(async function invisible_iframes() { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "https://example.com/" + ); + + // Ensure we notice the dialog opening: + let dialogWindowPromise = waitForProtocolAppChooserDialog( + tab.linkedBrowser, + true + ); + await SpecialPowers.spawn(tab.linkedBrowser, [], function () { + let frame = content.document.createElement("iframe"); + frame.style.display = "none"; + frame.src = "mailto:help@example.com"; + content.document.body.append(frame); + }); + let dialog = await dialogWindowPromise; + + is( + dialog._frame.contentDocument.location.href, + CONTENT_HANDLING_URL, + "Dialog opens as expected for invisible iframe" + ); + // Close the dialog: + let dialogClosedPromise = waitForProtocolAppChooserDialog( + tab.linkedBrowser, + false + ); + dialog.close(); + await dialogClosedPromise; + gBrowser.removeTab(tab); +}); + +/** + * Check that nested iframes are handled correctly. + */ +add_task(async function nested_iframes() { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "https://example.com/" + ); + + // Ensure we notice the dialog opening: + let dialogWindowPromise = waitForProtocolAppChooserDialog( + tab.linkedBrowser, + true + ); + let innerLoaded = BrowserTestUtils.browserLoaded( + tab.linkedBrowser, + true, + "https://example.org/" + ); + info("Constructing top frame"); + await SpecialPowers.spawn(tab.linkedBrowser, [], function () { + let frame = content.document.createElement("iframe"); + frame.src = "https://example.org/"; // cross-origin frame. + content.document.body.prepend(frame); + + content.eval( + `window.addEventListener("message", e => e.source.location = "mailto:help@example.com");` + ); + }); + + await innerLoaded; + let parentBC = tab.linkedBrowser.browsingContext; + + info("Creating innermost frame"); + await SpecialPowers.spawn(parentBC.children[0], [], async function () { + let innerFrame = content.document.createElement("iframe"); + let frameLoaded = ContentTaskUtils.waitForEvent(innerFrame, "load", true); + content.document.body.prepend(innerFrame); + await frameLoaded; + }); + + info("Posting event from innermost frame"); + await SpecialPowers.spawn( + parentBC.children[0].children[0], + [], + async function () { + // Top browsing context needs reference to the innermost, which is cross origin. + content.eval("top.postMessage('hello', '*')"); + } + ); + + let dialog = await dialogWindowPromise; + + is( + dialog._frame.contentDocument.location.href, + CONTENT_HANDLING_URL, + "Dialog opens as expected for deeply nested cross-origin iframe" + ); + // Close the dialog: + let dialogClosedPromise = waitForProtocolAppChooserDialog( + tab.linkedBrowser, + false + ); + dialog.close(); + await dialogClosedPromise; + gBrowser.removeTab(tab); +}); + +add_task(async function test_oop_iframe() { + const URI = `data:text/html,<div id="root"><iframe src="http://example.com/document-builder.sjs?html=<a href='mailto:help@example.com'>Mail it</a>"></iframe></div>`; + + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, URI); + + // Wait for the window and then click the link. + let dialogWindowPromise = waitForProtocolAppChooserDialog( + tab.linkedBrowser, + true + ); + + BrowserTestUtils.synthesizeMouseAtCenter( + "a:link", + {}, + tab.linkedBrowser.browsingContext.children[0] + ); + + let dialog = await dialogWindowPromise; + + is( + dialog._frame.contentDocument.location.href, + CONTENT_HANDLING_URL, + "Dialog URL is as expected" + ); + let dialogClosedPromise = waitForProtocolAppChooserDialog( + tab.linkedBrowser, + false + ); + + info("Removing tab to close the dialog."); + gBrowser.removeTab(tab); + await dialogClosedPromise; + ok(!dialog._frame.contentWindow, "The dialog should have been closed."); +}); + +/** + * Check that a cross-origin iframe can navigate the top frame + * to an external protocol. + */ +add_task(async function xorigin_iframe_can_navigate_top() { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "https://example.com/" + ); + + // Ensure we notice the dialog opening: + let dialogWindowPromise = waitForProtocolAppChooserDialog( + tab.linkedBrowser, + true + ); + let innerLoaded = BrowserTestUtils.browserLoaded( + tab.linkedBrowser, + true, + "https://example.org/" + ); + info("Constructing frame"); + await SpecialPowers.spawn(tab.linkedBrowser, [], function () { + let frame = content.document.createElement("iframe"); + frame.src = "https://example.org/"; // cross-origin frame. + content.document.body.prepend(frame); + }); + await innerLoaded; + + info("Navigating top bc from frame"); + let parentBC = tab.linkedBrowser.browsingContext; + await SpecialPowers.spawn(parentBC.children[0], [], async function () { + content.eval("window.top.location.href = 'mailto:example@example.com';"); + }); + + let dialog = await dialogWindowPromise; + + is( + dialog._frame.contentDocument.location.href, + CONTENT_HANDLING_URL, + "Dialog opens as expected for navigating the top frame from an x-origin frame." + ); + // Close the dialog: + let dialogClosedPromise = waitForProtocolAppChooserDialog( + tab.linkedBrowser, + false + ); + dialog.close(); + await dialogClosedPromise; + gBrowser.removeTab(tab); +}); + +/** + * Check that when navigating to an external protocol from an iframe in a + * background tab, we show the dialog in the correct tab. + */ +add_task(async function iframe_background_tab() { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "https://example.com/" + ); + + let innerLoaded = BrowserTestUtils.browserLoaded( + tab.linkedBrowser, + true, + "https://example.org/" + ); + info("Constructing frame"); + await SpecialPowers.spawn(tab.linkedBrowser, [], function () { + let frame = content.document.createElement("iframe"); + frame.src = "https://example.org/"; + content.document.body.prepend(frame); + }); + await innerLoaded; + + info("Switching to new tab"); + let newTab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "https://example.net/" + ); + + // Wait for the chooser dialog to open in the background tab. It should not + // open in the foreground tab which is unrelated to the external protocol + // navigation. + let dialogWindowPromise = waitForProtocolAppChooserDialog(gBrowser, true); + + info("Navigating to external proto from frame in background tab"); + let parentBC = tab.linkedBrowser.browsingContext; + await SpecialPowers.spawn(parentBC.children[0], [], async function () { + content.eval("location.href = 'mailto:example@example.com';"); + }); + + // Wait for dialog to open in one of the tabs. + let dialog = await dialogWindowPromise; + + is( + gBrowser.getTabDialogBox(tab.linkedBrowser)._tabDialogManager._topDialog, + dialog, + "Dialog opened in the background tab" + ); + + is( + dialog._frame.contentDocument.location.href, + CONTENT_HANDLING_URL, + "Opened dialog is appChooser dialog." + ); + + // Close the dialog: + let dialogClosedPromise = waitForProtocolAppChooserDialog(gBrowser, false); + dialog.close(); + await dialogClosedPromise; + + gBrowser.removeTab(tab); + gBrowser.removeTab(newTab); +}); diff --git a/uriloader/exthandler/tests/mochitest/browser_protocol_ask_dialog_external.js b/uriloader/exthandler/tests/mochitest/browser_protocol_ask_dialog_external.js new file mode 100644 index 0000000000..591f1afbc5 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/browser_protocol_ask_dialog_external.js @@ -0,0 +1,199 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +let gHandlerService = Cc["@mozilla.org/uriloader/handler-service;1"].getService( + Ci.nsIHandlerService +); + +const TEST_PATH = getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "https://example.com" +); + +/** + * Creates dummy protocol handler + */ +function initTestHandlers() { + let handlerInfoThatAsks = + HandlerServiceTestUtils.getBlankHandlerInfo("local-app-test"); + + let appHandler = Cc[ + "@mozilla.org/uriloader/local-handler-app;1" + ].createInstance(Ci.nsILocalHandlerApp); + // This is a dir and not executable, but that's enough for here. + appHandler.executable = Services.dirsvc.get("XCurProcD", Ci.nsIFile); + handlerInfoThatAsks.possibleApplicationHandlers.appendElement(appHandler); + handlerInfoThatAsks.preferredApplicationHandler = appHandler; + handlerInfoThatAsks.preferredAction = handlerInfoThatAsks.useHelperApp; + handlerInfoThatAsks.alwaysAskBeforeHandling = false; + gHandlerService.store(handlerInfoThatAsks); + + let webHandlerInfo = + HandlerServiceTestUtils.getBlankHandlerInfo("web+somesite"); + let webHandler = Cc[ + "@mozilla.org/uriloader/web-handler-app;1" + ].createInstance(Ci.nsIWebHandlerApp); + webHandler.name = "Somesite"; + webHandler.uriTemplate = "https://example.com/handle_url?u=%s"; + webHandlerInfo.possibleApplicationHandlers.appendElement(webHandler); + webHandlerInfo.preferredApplicationHandler = webHandler; + webHandlerInfo.preferredAction = webHandlerInfo.useHelperApp; + webHandlerInfo.alwaysAskBeforeHandling = false; + gHandlerService.store(webHandlerInfo); + + registerCleanupFunction(() => { + gHandlerService.remove(webHandlerInfo); + gHandlerService.remove(handlerInfoThatAsks); + }); +} + +function makeCmdLineHelper(url) { + return Cu.createCommandLine( + ["-url", url], + null, + Ci.nsICommandLine.STATE_REMOTE_EXPLICIT + ); +} + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [["network.protocol-handler.prompt-from-external", true]], + }); + initTestHandlers(); +}); + +/** + * Check that if we get a direct request from another app / the OS to open a + * link, we always prompt, even if we think we know what the correct answer + * is. This is to avoid infinite loops in such situations where the OS and + * Firefox have conflicting ideas about the default handler, or where our + * checks with the OS don't work (Linux and/or Snap, at time of this comment). + */ +add_task(async function test_external_asks_anyway() { + let cmdLineHandler = Cc["@mozilla.org/browser/final-clh;1"].getService( + Ci.nsICommandLineHandler + ); + let chooserDialogOpenPromise = waitForProtocolAppChooserDialog( + gBrowser, + true + ); + let fakeCmdLine = makeCmdLineHelper("local-app-test:dummy"); + cmdLineHandler.handle(fakeCmdLine); + let dialog = await chooserDialogOpenPromise; + ok(dialog, "Should have prompted."); + + let dialogClosedPromise = waitForProtocolAppChooserDialog( + gBrowser.selectedBrowser, + false + ); + let dialogEl = dialog._frame.contentDocument.querySelector("dialog"); + dialogEl.cancelDialog(); + await dialogClosedPromise; + // We will have opened a tab; close it. + BrowserTestUtils.removeTab(gBrowser.selectedTab); +}); + +/** + * Like the previous test, but avoid asking for web and extension handlers, + * as we can open those ourselves without looping. + */ +add_task(async function test_web_app_doesnt_ask() { + // Listen for a dialog open and fail the test if it does: + let dialogOpenListener = () => ok(false, "Shouldn't have opened a dialog!"); + document.documentElement.addEventListener("dialogopen", dialogOpenListener); + registerCleanupFunction(() => + document.documentElement.removeEventListener( + "dialogopen", + dialogOpenListener + ) + ); + + // Set up a promise for a tab to open with the right URL: + const kURL = "web+somesite:dummy"; + const kLoadedURL = + "https://example.com/handle_url?u=" + encodeURIComponent(kURL); + let tabPromise = BrowserTestUtils.waitForNewTab(gBrowser, kLoadedURL); + + // Load the URL: + let cmdLineHandler = Cc["@mozilla.org/browser/final-clh;1"].getService( + Ci.nsICommandLineHandler + ); + let fakeCmdLine = makeCmdLineHelper(kURL); + cmdLineHandler.handle(fakeCmdLine); + + // Check that the tab loaded. If instead the dialog opened, the dialogopen handler + // will fail the test. + let tab = await tabPromise; + is( + tab.linkedBrowser.currentURI.spec, + kLoadedURL, + "Should have opened the right URL." + ); + BrowserTestUtils.removeTab(tab); + + // We do this both here and in cleanup so it's easy to add tasks to this test, + // and so we clean up correctly if the test aborts before we get here. + document.documentElement.removeEventListener( + "dialogopen", + dialogOpenListener + ); +}); + +add_task(async function external_https_redirect_doesnt_ask() { + Services.perms.addFromPrincipal( + Services.scriptSecurityManager.createContentPrincipalFromOrigin( + "https://example.com" + ), + "open-protocol-handler^local-app-test", + Services.perms.ALLOW_ACTION + ); + // Listen for a dialog open and fail the test if it does: + let dialogOpenListener = () => ok(false, "Shouldn't have opened a dialog!"); + document.documentElement.addEventListener("dialogopen", dialogOpenListener); + registerCleanupFunction(() => { + document.documentElement.removeEventListener( + "dialogopen", + dialogOpenListener + ); + Services.perms.removeAll(); + }); + + let initialTab = gBrowser.selectedTab; + + gHandlerService.wrappedJSObject.mockProtocolHandler("local-app-test"); + registerCleanupFunction(() => + gHandlerService.wrappedJSObject.mockProtocolHandler() + ); + + // Set up a promise for an app to have launched with the right URI: + let loadPromise = TestUtils.topicObserved("mocked-protocol-handler"); + + // Load the URL: + const kURL = "local-app-test:redirect"; + let cmdLineHandler = Cc["@mozilla.org/browser/final-clh;1"].getService( + Ci.nsICommandLineHandler + ); + let fakeCmdLine = makeCmdLineHelper( + TEST_PATH + "redirect_helper.sjs?uri=" + encodeURIComponent(kURL) + ); + cmdLineHandler.handle(fakeCmdLine); + + // Check that the mock app was launched. If the dialog showed instead, + // the test will fail. + let [uri] = await loadPromise; + is(uri.spec, "local-app-test:redirect", "Should have seen correct URI."); + // We might have opened a blank tab, see bug 1718104 and friends. + if (gBrowser.selectedTab != initialTab) { + BrowserTestUtils.removeTab(gBrowser.selectedTab); + } + + // We do this both here and in cleanup so it's easy to add tasks to this test, + // and so we clean up correctly if the test aborts before we get here. + document.documentElement.removeEventListener( + "dialogopen", + dialogOpenListener + ); + gHandlerService.wrappedJSObject.mockProtocolHandler(); +}); diff --git a/uriloader/exthandler/tests/mochitest/browser_protocol_ask_dialog_permission.js b/uriloader/exthandler/tests/mochitest/browser_protocol_ask_dialog_permission.js new file mode 100644 index 0000000000..7d51a9c59a --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/browser_protocol_ask_dialog_permission.js @@ -0,0 +1,1348 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +var { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); + +const { ExtensionPermissions } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionPermissions.sys.mjs" +); + +let gHandlerService = Cc["@mozilla.org/uriloader/handler-service;1"].getService( + Ci.nsIHandlerService +); + +const ROOT_PATH = getRootDirectory(gTestPath).replace( + "chrome://mochitests/content/", + "" +); + +// Testing multiple protocol / origin combinations takes long on debug. +requestLongerTimeout(7); + +const DIALOG_URL_APP_CHOOSER = + "chrome://mozapps/content/handling/appChooser.xhtml"; +const DIALOG_URL_PERMISSION = + "chrome://mozapps/content/handling/permissionDialog.xhtml"; + +const PROTOCOL_HANDLER_OPEN_PERM_KEY = "open-protocol-handler"; +const PERMISSION_KEY_DELIMITER = "^"; + +const TEST_PROTOS = ["foo", "bar"]; + +let testDir = getChromeDir(getResolvedURI(gTestPath)); + +const ORIGIN1 = "https://example.com"; +const ORIGIN2 = "https://example.org"; +const ORIGIN3 = Services.io.newFileURI(testDir).spec; +const PRINCIPAL1 = + Services.scriptSecurityManager.createContentPrincipalFromOrigin(ORIGIN1); +const PRINCIPAL2 = + Services.scriptSecurityManager.createContentPrincipalFromOrigin(ORIGIN2); +const PRINCIPAL3 = + Services.scriptSecurityManager.createContentPrincipalFromOrigin(ORIGIN3); + +const NULL_PRINCIPAL_SCHEME = Services.scriptSecurityManager + .createNullPrincipal({}) + .scheme.toLowerCase(); + +/** + * Get the open protocol handler permission key for a given protocol scheme. + * @param {string} aProtocolScheme - Scheme of protocol to construct permission + * key with. + */ +function getSkipProtoDialogPermissionKey(aProtocolScheme) { + return ( + PROTOCOL_HANDLER_OPEN_PERM_KEY + PERMISSION_KEY_DELIMITER + aProtocolScheme + ); +} + +/** + * Creates dummy web protocol handlers used for testing. + */ +function initTestHandlers() { + TEST_PROTOS.forEach(scheme => { + let webHandler = Cc[ + "@mozilla.org/uriloader/web-handler-app;1" + ].createInstance(Ci.nsIWebHandlerApp); + webHandler.name = scheme + "Handler"; + webHandler.uriTemplate = ORIGIN1 + "/?url=%s"; + + let handlerInfo = HandlerServiceTestUtils.getBlankHandlerInfo(scheme); + handlerInfo.possibleApplicationHandlers.appendElement(webHandler); + handlerInfo.preferredApplicationHandler = webHandler; + gHandlerService.store(handlerInfo); + }); +} + +/** + * Update whether the protocol handler dialog is shown for our test protocol + + * handler. + * @param {string} scheme - Scheme of the protocol to change the ask state for. + * @param {boolean} ask - true => show dialog, false => skip dialog. + */ +function updateAlwaysAsk(scheme, ask) { + let handlerInfo = HandlerServiceTestUtils.getHandlerInfo(scheme); + handlerInfo.alwaysAskBeforeHandling = ask; + gHandlerService.store(handlerInfo); +} + +/** + * Test whether the protocol handler dialog is set to show for our + * test protocol + handler. + * @param {string} scheme - Scheme of the protocol to test the ask state for. + * @param {boolean} ask - true => show dialog, false => skip dialog. + */ +function testAlwaysAsk(scheme, ask) { + is( + HandlerServiceTestUtils.getHandlerInfo(scheme).alwaysAskBeforeHandling, + ask, + "Should have correct alwaysAsk state." + ); +} + +/** + * Triggers the load via a server redirect. + * @param {string} serverRedirect - The redirect type. + */ +function useServerRedirect(serverRedirect) { + return async (browser, scheme) => { + let uri = `${scheme}://test`; + + let innerParams = new URLSearchParams(); + innerParams.set("uri", uri); + innerParams.set("redirectType", serverRedirect); + let params = new URLSearchParams(); + params.set( + "uri", + "https://example.com/" + + ROOT_PATH + + "redirect_helper.sjs?" + + innerParams.toString() + ); + uri = + "https://example.org/" + + ROOT_PATH + + "redirect_helper.sjs?" + + params.toString(); + BrowserTestUtils.loadURIString(browser, uri); + }; +} + +/** + * Triggers the load with a specific principal or the browser's current + * principal. + * @param {nsIPrincipal} [principal] - Principal to use to trigger the load. + */ +function useTriggeringPrincipal(principal = undefined) { + return async (browser, scheme) => { + let uri = `${scheme}://test`; + let triggeringPrincipal = principal ?? browser.contentPrincipal; + + info("Loading uri: " + uri); + browser.loadURI(Services.io.newURI(uri), { triggeringPrincipal }); + }; +} + +/** + * Navigates to a test URL with the given protocol scheme and waits for the + * result. + * @param {MozBrowser} browser - Browser to navigate. + * @param {string} scheme - Scheme of the test url. e.g. irc + * @param {Object} [options] - Test options. + * @param {Object} [options.permDialogOptions] - Test options for the permission + * dialog. If defined, we expect this dialog to be shown. + * @param {Object} [options.chooserDialogOptions] - Test options for the chooser + * dialog. If defined, we expect this dialog to be shown. + * @param {Function} [options.triggerLoad] - An async callback function to + * trigger the load. Will be passed the browser and scheme to use. + * @param {nsIPrincipal} [options.triggeringPrincipal] - Principal to trigger + * the load with. Defaults to the browsers content principal. + * @returns {Promise} - A promise which resolves once the test is complete. + */ +async function testOpenProto( + browser, + scheme, + { + permDialogOptions, + chooserDialogOptions, + triggerLoad = useTriggeringPrincipal(), + } = {} +) { + let permDialogOpenPromise; + let chooserDialogOpenPromise; + + if (permDialogOptions) { + info("Should see permission dialog"); + permDialogOpenPromise = waitForProtocolPermissionDialog(browser, true); + } + + if (chooserDialogOptions) { + info("Should see chooser dialog"); + chooserDialogOpenPromise = waitForProtocolAppChooserDialog(browser, true); + } + await triggerLoad(browser, scheme); + let webHandlerLoadedPromise; + + let webHandlerShouldOpen = + (!permDialogOptions && !chooserDialogOptions) || + ((permDialogOptions?.actionConfirm || permDialogOptions?.actionChangeApp) && + chooserDialogOptions?.actionConfirm); + + // Register web handler load listener if we expect to trigger it. + if (webHandlerShouldOpen) { + webHandlerLoadedPromise = waitForHandlerURL(browser, scheme); + } + + if (permDialogOpenPromise) { + let dialog = await permDialogOpenPromise; + let dialogEl = getDialogElementFromSubDialog(dialog); + let dialogType = getDialogType(dialog); + + let { + hasCheckbox, + checkboxOrigin, + hasChangeApp, + chooserIsNext, + actionCheckbox, + actionConfirm, + actionChangeApp, + checkContents, + } = permDialogOptions; + + if (actionChangeApp) { + actionConfirm = false; + } + + let descriptionEl = dialogEl.querySelector("#description"); + ok( + descriptionEl && BrowserTestUtils.is_visible(descriptionEl), + "Has a visible description element." + ); + + ok( + !descriptionEl.innerHTML.toLowerCase().includes(NULL_PRINCIPAL_SCHEME), + "Description does not include NullPrincipal scheme." + ); + + await testCheckbox(dialogEl, dialogType, { + hasCheckbox, + actionCheckbox, + checkboxOrigin, + }); + + // Check the button label depending on whether we would show the chooser + // dialog next or directly open the handler. + let acceptBtnLabel = dialogEl.getButton("accept")?.label; + + if (chooserIsNext) { + is( + acceptBtnLabel, + "Choose Application", + "Accept button has choose app label" + ); + } else { + is(acceptBtnLabel, "Open Link", "Accept button has open link label"); + } + + let changeAppLink = dialogEl.ownerDocument.getElementById("change-app"); + if (typeof hasChangeApp == "boolean") { + ok(changeAppLink, "Permission dialog should have changeApp link label"); + is( + !changeAppLink.hidden, + hasChangeApp, + "Permission dialog change app link label" + ); + } + + if (checkContents) { + checkContents(dialogEl); + } + + if (actionChangeApp) { + let dialogClosedPromise = waitForProtocolPermissionDialog(browser, false); + changeAppLink.click(); + await dialogClosedPromise; + } else { + await closeDialog(browser, dialog, actionConfirm, scheme); + } + } + + if (chooserDialogOpenPromise) { + let dialog = await chooserDialogOpenPromise; + let dialogEl = getDialogElementFromSubDialog(dialog); + let dialogType = getDialogType(dialog); + + let { hasCheckbox, actionCheckbox, actionConfirm } = chooserDialogOptions; + + await testCheckbox(dialogEl, dialogType, { + hasCheckbox, + actionCheckbox, + }); + + await closeDialog(browser, dialog, actionConfirm, scheme); + } + + if (webHandlerShouldOpen) { + info("Waiting for web handler to open"); + await webHandlerLoadedPromise; + } else { + info("Web handler open canceled"); + } +} + +/** + * Inspects the checkbox state and interacts with it. + * @param {dialog} dialogEl + * @param {string} dialogType - String identifier of dialog type. + * Either "permission" or "chooser". + * @param {Object} options - Test Options. + * @param {boolean} [options.hasCheckbox] - Whether the dialog is expected to + * have a visible checkbox. + * @param {boolean} [options.hasCheckboxState] - The check state of the checkbox + * to test for. true = checked, false = unchecked. + * @param {boolean} [options.actionCheckbox] - The state to set on the checkbox. + * true = checked, false = unchecked. + */ +async function testCheckbox( + dialogEl, + dialogType, + { hasCheckbox, hasCheckboxState = false, actionCheckbox, checkboxOrigin } +) { + let checkbox = dialogEl.ownerDocument.getElementById("remember"); + if (typeof hasCheckbox == "boolean") { + is( + checkbox && BrowserTestUtils.is_visible(checkbox), + hasCheckbox, + "Dialog checkbox has correct visibility." + ); + + let checkboxLabel = dialogEl.ownerDocument.getElementById("remember-label"); + is( + checkbox && BrowserTestUtils.is_visible(checkboxLabel), + hasCheckbox, + "Dialog checkbox label has correct visibility." + ); + if (hasCheckbox) { + ok( + !checkboxLabel.innerHTML.toLowerCase().includes(NULL_PRINCIPAL_SCHEME), + "Dialog checkbox label does not include NullPrincipal scheme." + ); + } + } + + if (typeof hasCheckboxState == "boolean") { + is(checkbox.checked, hasCheckboxState, "Dialog checkbox has correct state"); + } + + if (checkboxOrigin) { + let doc = dialogEl.ownerDocument; + let hostFromLabel = doc.l10n.getAttributes( + doc.getElementById("remember-label") + ).args.host; + is(hostFromLabel, checkboxOrigin, "Checkbox should be for correct domain."); + } + + if (typeof actionCheckbox == "boolean") { + checkbox.click(); + } +} + +/** + * Get the dialog element which is a child of the SubDialogs browser frame. + * @param {SubDialog} subDialog - Dialog to get the dialog element for. + */ +function getDialogElementFromSubDialog(subDialog) { + let dialogEl = subDialog._frame.contentDocument.querySelector("dialog"); + ok(dialogEl, "SubDialog should have dialog element"); + return dialogEl; +} + +/** + * Wait for the test handler to be opened. + * @param {MozBrowser} browser - The browser the load should occur in. + * @param {string} scheme - Scheme which triggered the handler to open. + */ +function waitForHandlerURL(browser, scheme) { + return BrowserTestUtils.browserLoaded( + browser, + false, + url => url == `${ORIGIN1}/?url=${scheme}%3A%2F%2Ftest` + ); +} + +/** + * Test for open-protocol-handler permission. + * @param {nsIPrincipal} principal - The principal to test the permission on. + * @param {string} scheme - Scheme to generate permission key. + * @param {boolean} hasPerm - Whether we expect the princial to set the + * permission (true), or not (false). + */ +function testPermission(principal, scheme, hasPerm) { + let permKey = getSkipProtoDialogPermissionKey(scheme); + let result = Services.perms.testPermissionFromPrincipal(principal, permKey); + let message = `${permKey} ${hasPerm ? "is" : "is not"} set for ${ + principal.origin + }.`; + is(result == Services.perms.ALLOW_ACTION, hasPerm, message); +} + +/** + * Get the checkbox element of the dialog used to remember the handler choice or + * store the permission. + * @param {SubDialog} dialog - Protocol handler dialog embedded in a SubDialog. + * @param {string} dialogType - Type of the dialog which holds the checkbox. + * @returns {HTMLInputElement} - Checkbox of the dialog. + */ +function getDialogCheckbox(dialog, dialogType) { + let id; + if (dialogType == "permission") { + id = "remember-permission"; + } else { + id = "remember"; + } + return dialog._frame.contentDocument.getElementById(id); +} + +function getDialogType(dialog) { + let url = dialog._frame.currentURI.spec; + + if (url === DIALOG_URL_PERMISSION) { + return "permission"; + } + if (url === DIALOG_URL_APP_CHOOSER) { + return "chooser"; + } + throw new Error("Dialog with unexpected url"); +} + +/** + * Exit a protocol handler SubDialog and wait for it to be fully closed. + * @param {MozBrowser} browser - Browser element of the tab where the dialog is + * shown. + * @param {SubDialog} dialog - SubDialog object which holds the protocol handler + * @param {boolean} confirm - Whether to confirm (true) or cancel (false) the + * dialog. + * @param {string} scheme - The scheme of the protocol the dialog is opened for. + * dialog. + */ +async function closeDialog(browser, dialog, confirm, scheme) { + let dialogClosedPromise = waitForSubDialog(browser, dialog._openedURL, false); + let dialogEl = getDialogElementFromSubDialog(dialog); + + if (confirm) { + if (getDialogType(dialog) == "chooser") { + // Select our test protocol handler + let listItem = dialogEl.ownerDocument.querySelector( + `richlistitem[name="${scheme}Handler"]` + ); + listItem.click(); + } + + dialogEl.setAttribute("buttondisabledaccept", false); + dialogEl.acceptDialog(); + } else { + dialogEl.cancelDialog(); + } + + return dialogClosedPromise; +} + +registerCleanupFunction(function () { + // Clean up test handlers + TEST_PROTOS.forEach(scheme => { + let handlerInfo = HandlerServiceTestUtils.getHandlerInfo(scheme); + gHandlerService.remove(handlerInfo); + }); + + // Clear permissions + Services.perms.removeAll(); +}); + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [["security.external_protocol_requires_permission", true]], + }); + initTestHandlers(); +}); + +/** + * Tests that when "remember" is unchecked, we only allow the protocol to be + * opened once and don't store any permission. + */ +add_task(async function test_permission_allow_once() { + for (let scheme of TEST_PROTOS) { + await BrowserTestUtils.withNewTab(ORIGIN1, async browser => { + await testOpenProto(browser, scheme, { + permDialogOptions: { + hasCheckbox: true, + hasChangeApp: false, + chooserIsNext: true, + actionConfirm: true, + }, + chooserDialogOptions: { hasCheckbox: true, actionConfirm: true }, + }); + }); + + // No permission should be set + testPermission(PRINCIPAL1, scheme, false); + testPermission(PRINCIPAL2, scheme, false); + + // No preferred app should be set + testAlwaysAsk(scheme, true); + + // If we open again we should see the permission dialog + await BrowserTestUtils.withNewTab(ORIGIN1, async browser => { + await testOpenProto(browser, scheme, { + permDialogOptions: { + hasCheckbox: true, + hasChangeApp: false, + chooserIsNext: true, + actionConfirm: false, + }, + }); + }); + } +}); + +/** + * Tests that when checking the "remember" checkbox, the protocol permission + * is set correctly and allows the caller to skip the permission dialog in + * subsequent calls. + */ +add_task(async function test_permission_allow_persist() { + for (let [origin, principal] of [ + [ORIGIN1, PRINCIPAL1], + [ORIGIN3, PRINCIPAL3], + ]) { + for (let scheme of TEST_PROTOS) { + info("Testing with origin " + origin); + info("testing with principal of origin " + principal.origin); + info("testing with protocol " + scheme); + + // Set a permission for an unrelated protocol. + // We should still see the permission dialog. + Services.perms.addFromPrincipal( + principal, + getSkipProtoDialogPermissionKey("foobar"), + Services.perms.ALLOW_ACTION + ); + + await BrowserTestUtils.withNewTab(origin, async browser => { + await testOpenProto(browser, scheme, { + permDialogOptions: { + hasCheckbox: true, + hasChangeApp: false, + chooserIsNext: true, + actionCheckbox: true, + actionConfirm: true, + }, + chooserDialogOptions: { hasCheckbox: true, actionConfirm: true }, + }); + }); + + // Permission should be set + testPermission(principal, scheme, true); + testPermission(PRINCIPAL2, scheme, false); + + // No preferred app should be set + testAlwaysAsk(scheme, true); + + // If we open again with the origin where we granted permission, we should + // directly get the chooser dialog. + await BrowserTestUtils.withNewTab(origin, async browser => { + await testOpenProto(browser, scheme, { + chooserDialogOptions: { + hasCheckbox: true, + actionConfirm: false, + }, + }); + }); + + // If we open with the other origin, we should see the permission dialog + await BrowserTestUtils.withNewTab(ORIGIN2, async browser => { + await testOpenProto(browser, scheme, { + permDialogOptions: { + hasCheckbox: true, + hasChangeApp: false, + chooserIsNext: true, + actionConfirm: false, + }, + }); + }); + + // Cleanup permissions + Services.perms.removeAll(); + } + } +}); + +/** + * Tests that if a preferred protocol handler is set, the permission dialog + * shows the application name and a link which leads to the app chooser. + */ +add_task(async function test_permission_application_set() { + let scheme = TEST_PROTOS[0]; + updateAlwaysAsk(scheme, false); + await BrowserTestUtils.withNewTab(ORIGIN1, async browser => { + await testOpenProto(browser, scheme, { + permDialogOptions: { + hasCheckbox: true, + hasChangeApp: true, + chooserIsNext: false, + actionChangeApp: true, + }, + chooserDialogOptions: { hasCheckbox: true, actionConfirm: true }, + }); + }); + + // Cleanup + updateAlwaysAsk(scheme, true); +}); + +/** + * Tests that we correctly handle system principals. They should always + * skip the permission dialog. + */ +add_task(async function test_permission_system_principal() { + let scheme = TEST_PROTOS[0]; + await BrowserTestUtils.withNewTab(ORIGIN1, async browser => { + await testOpenProto(browser, scheme, { + chooserDialogOptions: { hasCheckbox: true, actionConfirm: false }, + triggerLoad: useTriggeringPrincipal( + Services.scriptSecurityManager.getSystemPrincipal() + ), + }); + }); +}); + +/** + * Tests that we don't show the permission dialog if the permission is disabled + * by pref. + */ +add_task(async function test_permission_disabled() { + let scheme = TEST_PROTOS[0]; + + await SpecialPowers.pushPrefEnv({ + set: [["security.external_protocol_requires_permission", false]], + }); + + await BrowserTestUtils.withNewTab(ORIGIN1, async browser => { + await testOpenProto(browser, scheme, { + chooserDialogOptions: { hasCheckbox: true, actionConfirm: true }, + }); + }); + + await SpecialPowers.popPrefEnv(); +}); + +/** + * Tests that we directly open the handler if permission and handler are set. + */ +add_task(async function test_app_and_permission_set() { + let scheme = TEST_PROTOS[1]; + + updateAlwaysAsk(scheme, false); + Services.perms.addFromPrincipal( + PRINCIPAL2, + getSkipProtoDialogPermissionKey(scheme), + Services.perms.ALLOW_ACTION + ); + + await BrowserTestUtils.withNewTab(ORIGIN2, async browser => { + await testOpenProto(browser, scheme); + }); + + // Cleanup + Services.perms.removeAll(); + updateAlwaysAsk(scheme, true); +}); + +/** + * Tests that the alwaysAsk state is not updated if the user cancels the dialog + */ +add_task(async function test_change_app_checkbox_cancel() { + let scheme = TEST_PROTOS[0]; + + await BrowserTestUtils.withNewTab(ORIGIN1, async browser => { + await testOpenProto(browser, scheme, { + permDialogOptions: { + hasCheckbox: true, + chooserIsNext: true, + hasChangeApp: false, + actionConfirm: true, + }, + chooserDialogOptions: { + hasCheckbox: true, + actionCheckbox: true, // Activate checkbox + actionConfirm: false, // Cancel dialog + }, + }); + }); + + // Should not have applied value from checkbox + testAlwaysAsk(scheme, true); +}); + +/** + * Tests that the external protocol dialogs behave correctly when a null + * principal is passed. + */ +add_task(async function test_null_principal() { + let scheme = TEST_PROTOS[0]; + + await BrowserTestUtils.withNewTab(ORIGIN1, async browser => { + await testOpenProto(browser, scheme, { + triggerLoad: () => { + let uri = `${scheme}://test`; + ContentTask.spawn(browser, { uri }, args => { + let frame = content.document.createElement("iframe"); + frame.src = `data:text/html,<script>location.href="${args.uri}"</script>`; + content.document.body.appendChild(frame); + }); + }, + permDialogOptions: { + hasCheckbox: false, + chooserIsNext: true, + hasChangeApp: false, + actionConfirm: true, + }, + chooserDialogOptions: { + hasCheckbox: true, + actionConfirm: false, // Cancel dialog + }, + }); + }); +}); + +/** + * Tests that the external protocol dialogs behave correctly when no principal + * is passed. + */ +add_task(async function test_no_principal() { + let scheme = TEST_PROTOS[1]; + + await BrowserTestUtils.withNewTab(ORIGIN1, async browser => { + await testOpenProto(browser, scheme, { + triggerLoad: () => { + let uri = `${scheme}://test`; + + let contentDispatchChooser = Cc[ + "@mozilla.org/content-dispatch-chooser;1" + ].createInstance(Ci.nsIContentDispatchChooser); + + let handler = HandlerServiceTestUtils.getHandlerInfo(scheme); + + contentDispatchChooser.handleURI( + handler, + Services.io.newURI(uri), + null, + browser.browsingContext + ); + }, + permDialogOptions: { + hasCheckbox: false, + chooserIsNext: true, + hasChangeApp: false, + actionConfirm: true, + }, + chooserDialogOptions: { + hasCheckbox: true, + actionConfirm: false, // Cancel dialog + }, + }); + }); +}); + +/** + * Tests that if a URI scheme has a non-standard protocol, an OS default exists, + * and the user hasn't selected an alternative only the permission dialog is shown. + */ +add_task(async function test_non_standard_protocol() { + let scheme = null; + // TODO add a scheme for Windows 10 or greater once support is added (see bug 1764599). + if (AppConstants.platform == "macosx") { + scheme = "itunes"; + } else { + info( + "Skipping this test since there isn't a suitable default protocol on this platform" + ); + return; + } + + await BrowserTestUtils.withNewTab(ORIGIN1, async browser => { + await testOpenProto(browser, scheme, { + permDialogOptions: { + hasCheckbox: true, + hasChangeApp: true, + chooserIsNext: false, + actionChangeApp: false, + }, + }); + }); +}); + +/** + * Tests that we show the permission dialog for extension content scripts. + */ +add_task(async function test_extension_content_script_permission() { + let scheme = TEST_PROTOS[0]; + await BrowserTestUtils.withNewTab(ORIGIN1, async browser => { + let testExtension; + + await testOpenProto(browser, scheme, { + triggerLoad: async () => { + let uri = `${scheme}://test`; + + const EXTENSION_DATA = { + manifest: { + content_scripts: [ + { + matches: [browser.currentURI.spec], + js: ["navigate.js"], + }, + ], + browser_specific_settings: { + gecko: { id: "allowed@mochi.test" }, + }, + }, + files: { + "navigate.js": `window.location.href = "${uri}";`, + }, + useAddonManager: "permanent", + }; + + testExtension = ExtensionTestUtils.loadExtension(EXTENSION_DATA); + await testExtension.startup(); + }, + permDialogOptions: { + hasCheckbox: true, + chooserIsNext: true, + hasChangeApp: false, + actionCheckbox: true, + actionConfirm: true, + checkContents: dialogEl => { + let description = dialogEl.querySelector("#description"); + let { id, args } = + description.ownerDocument.l10n.getAttributes(description); + is( + id, + "permission-dialog-description-extension", + "Should be using the correct string." + ); + is( + args.extension, + "Generated extension", + "Should have the correct extension name." + ); + }, + }, + chooserDialogOptions: { + hasCheckbox: true, + actionConfirm: false, // Cancel dialog + }, + }); + + let extensionPrincipal = + Services.scriptSecurityManager.createContentPrincipal( + Services.io.newURI(`moz-extension://${testExtension.uuid}/`), + {} + ); + let extensionPrivatePrincipal = + Services.scriptSecurityManager.createContentPrincipal( + Services.io.newURI(`moz-extension://${testExtension.uuid}/`), + { privateBrowsingId: 1 } + ); + + let key = getSkipProtoDialogPermissionKey(scheme); + is( + Services.perms.testPermissionFromPrincipal(extensionPrincipal, key), + Services.perms.ALLOW_ACTION, + "Should have permanently allowed the extension" + ); + is( + Services.perms.testPermissionFromPrincipal( + extensionPrivatePrincipal, + key + ), + Services.perms.UNKNOWN_ACTION, + "Should not have changed the private principal permission" + ); + is( + Services.perms.testPermissionFromPrincipal(PRINCIPAL1, key), + Services.perms.UNKNOWN_ACTION, + "Should not have allowed the page" + ); + + await testExtension.unload(); + + is( + Services.perms.testPermissionFromPrincipal(extensionPrincipal, key), + Services.perms.UNKNOWN_ACTION, + "Should have cleared the extension's normal principal permission" + ); + is( + Services.perms.testPermissionFromPrincipal( + extensionPrivatePrincipal, + key + ), + Services.perms.UNKNOWN_ACTION, + "Should have cleared the private browsing principal" + ); + }); +}); + +/** + * Tests that we show the permission dialog for extension content scripts. + */ +add_task(async function test_extension_private_content_script_permission() { + let scheme = TEST_PROTOS[0]; + let win = await BrowserTestUtils.openNewBrowserWindow({ private: true }); + + await BrowserTestUtils.withNewTab( + { gBrowser: win.gBrowser, url: ORIGIN1 }, + async browser => { + let testExtension; + + await testOpenProto(browser, scheme, { + triggerLoad: async () => { + let uri = `${scheme}://test`; + + const EXTENSION_DATA = { + manifest: { + content_scripts: [ + { + matches: [browser.currentURI.spec], + js: ["navigate.js"], + }, + ], + browser_specific_settings: { + gecko: { id: "allowed@mochi.test" }, + }, + }, + files: { + "navigate.js": `window.location.href = "${uri}";`, + }, + useAddonManager: "permanent", + }; + + testExtension = ExtensionTestUtils.loadExtension(EXTENSION_DATA); + await testExtension.startup(); + let perms = { + permissions: ["internal:privateBrowsingAllowed"], + origins: [], + }; + await ExtensionPermissions.add("allowed@mochi.test", perms); + let addon = await AddonManager.getAddonByID("allowed@mochi.test"); + await addon.reload(); + }, + permDialogOptions: { + hasCheckbox: true, + chooserIsNext: true, + hasChangeApp: false, + actionCheckbox: true, + actionConfirm: true, + checkContents: dialogEl => { + let description = dialogEl.querySelector("#description"); + let { id, args } = + description.ownerDocument.l10n.getAttributes(description); + is( + id, + "permission-dialog-description-extension", + "Should be using the correct string." + ); + is( + args.extension, + "Generated extension", + "Should have the correct extension name." + ); + }, + }, + chooserDialogOptions: { + hasCheckbox: true, + actionConfirm: false, // Cancel dialog + }, + }); + + let extensionPrincipal = + Services.scriptSecurityManager.createContentPrincipal( + Services.io.newURI(`moz-extension://${testExtension.uuid}/`), + {} + ); + let extensionPrivatePrincipal = + Services.scriptSecurityManager.createContentPrincipal( + Services.io.newURI(`moz-extension://${testExtension.uuid}/`), + { privateBrowsingId: 1 } + ); + + let key = getSkipProtoDialogPermissionKey(scheme); + is( + Services.perms.testPermissionFromPrincipal(extensionPrincipal, key), + Services.perms.UNKNOWN_ACTION, + "Should not have changed the extension's normal principal permission" + ); + is( + Services.perms.testPermissionFromPrincipal( + extensionPrivatePrincipal, + key + ), + Services.perms.ALLOW_ACTION, + "Should have allowed the private browsing principal" + ); + is( + Services.perms.testPermissionFromPrincipal(PRINCIPAL1, key), + Services.perms.UNKNOWN_ACTION, + "Should not have allowed the page" + ); + + await testExtension.unload(); + + is( + Services.perms.testPermissionFromPrincipal(extensionPrincipal, key), + Services.perms.UNKNOWN_ACTION, + "Should have cleared the extension's normal principal permission" + ); + is( + Services.perms.testPermissionFromPrincipal( + extensionPrivatePrincipal, + key + ), + Services.perms.UNKNOWN_ACTION, + "Should have cleared the private browsing principal" + ); + } + ); + + await BrowserTestUtils.closeWindow(win); +}); + +/** + * Tests that we do not show the permission dialog for extension content scripts + * when the page already has permission. + */ +add_task(async function test_extension_allowed_content() { + let scheme = TEST_PROTOS[0]; + await BrowserTestUtils.withNewTab(ORIGIN1, async browser => { + let testExtension; + + let key = getSkipProtoDialogPermissionKey(scheme); + Services.perms.addFromPrincipal( + PRINCIPAL1, + key, + Services.perms.ALLOW_ACTION, + Services.perms.EXPIRE_NEVER + ); + + await testOpenProto(browser, scheme, { + triggerLoad: async () => { + let uri = `${scheme}://test`; + + const EXTENSION_DATA = { + manifest: { + content_scripts: [ + { + matches: [browser.currentURI.spec], + js: ["navigate.js"], + }, + ], + }, + files: { + "navigate.js": `window.location.href = "${uri}";`, + }, + }; + + testExtension = ExtensionTestUtils.loadExtension(EXTENSION_DATA); + await testExtension.startup(); + }, + chooserDialogOptions: { + hasCheckbox: true, + actionConfirm: false, // Cancel dialog + }, + }); + + let extensionPrincipal = + Services.scriptSecurityManager.createContentPrincipal( + Services.io.newURI(`moz-extension://${testExtension.uuid}/`), + {} + ); + + is( + Services.perms.testPermissionFromPrincipal(extensionPrincipal, key), + Services.perms.UNKNOWN_ACTION, + "Should not have permanently allowed the extension" + ); + + await testExtension.unload(); + Services.perms.removeFromPrincipal(PRINCIPAL1, key); + }); +}); + +/** + * Tests that we do not show the permission dialog for extension content scripts + * when the extension already has permission. + */ +add_task(async function test_extension_allowed_extension() { + let scheme = TEST_PROTOS[0]; + await BrowserTestUtils.withNewTab(ORIGIN1, async browser => { + let testExtension; + + let key = getSkipProtoDialogPermissionKey(scheme); + + await testOpenProto(browser, scheme, { + triggerLoad: async () => { + const EXTENSION_DATA = { + manifest: { + permissions: [`${ORIGIN1}/*`], + }, + background() { + browser.test.onMessage.addListener(async (msg, uri) => { + switch (msg) { + case "engage": + browser.tabs.executeScript({ + code: `window.location.href = "${uri}";`, + }); + break; + default: + browser.test.fail(`Unexpected message received: ${msg}`); + } + }); + }, + }; + + testExtension = ExtensionTestUtils.loadExtension(EXTENSION_DATA); + await testExtension.startup(); + + let extensionPrincipal = + Services.scriptSecurityManager.createContentPrincipal( + Services.io.newURI(`moz-extension://${testExtension.uuid}/`), + {} + ); + Services.perms.addFromPrincipal( + extensionPrincipal, + key, + Services.perms.ALLOW_ACTION, + Services.perms.EXPIRE_NEVER + ); + + testExtension.sendMessage("engage", `${scheme}://test`); + }, + chooserDialogOptions: { + hasCheckbox: true, + actionConfirm: false, // Cancel dialog + }, + }); + + await testExtension.unload(); + Services.perms.removeFromPrincipal(PRINCIPAL1, key); + }); +}); + +/** + * Tests that we show the permission dialog for extensions directly opening a + * protocol. + */ +add_task(async function test_extension_principal() { + let scheme = TEST_PROTOS[0]; + await BrowserTestUtils.withNewTab(ORIGIN1, async browser => { + let testExtension; + + await testOpenProto(browser, scheme, { + triggerLoad: async () => { + const EXTENSION_DATA = { + background() { + browser.test.onMessage.addListener(async (msg, url) => { + switch (msg) { + case "engage": + browser.tabs.update({ + url, + }); + break; + default: + browser.test.fail(`Unexpected message received: ${msg}`); + } + }); + }, + }; + + testExtension = ExtensionTestUtils.loadExtension(EXTENSION_DATA); + await testExtension.startup(); + testExtension.sendMessage("engage", `${scheme}://test`); + }, + permDialogOptions: { + hasCheckbox: true, + chooserIsNext: true, + hasChangeApp: false, + actionCheckbox: true, + actionConfirm: true, + checkContents: dialogEl => { + let description = dialogEl.querySelector("#description"); + let { id, args } = + description.ownerDocument.l10n.getAttributes(description); + is( + id, + "permission-dialog-description-extension", + "Should be using the correct string." + ); + is( + args.extension, + "Generated extension", + "Should have the correct extension name." + ); + }, + }, + chooserDialogOptions: { + hasCheckbox: true, + actionConfirm: false, // Cancel dialog + }, + }); + + let extensionPrincipal = + Services.scriptSecurityManager.createContentPrincipal( + Services.io.newURI(`moz-extension://${testExtension.uuid}/`), + {} + ); + + let key = getSkipProtoDialogPermissionKey(scheme); + is( + Services.perms.testPermissionFromPrincipal(extensionPrincipal, key), + Services.perms.ALLOW_ACTION, + "Should have permanently allowed the extension" + ); + is( + Services.perms.testPermissionFromPrincipal(PRINCIPAL1, key), + Services.perms.UNKNOWN_ACTION, + "Should not have allowed the page" + ); + + await testExtension.unload(); + }); +}); + +/** + * Test that we use the redirect principal for the dialog when applicable. + */ +add_task(async function test_redirect_principal() { + let scheme = TEST_PROTOS[0]; + await BrowserTestUtils.withNewTab("about:blank", async browser => { + await testOpenProto(browser, scheme, { + triggerLoad: useServerRedirect("location"), + permDialogOptions: { + checkboxOrigin: ORIGIN1, + chooserIsNext: true, + hasCheckbox: true, + actionConfirm: false, // Cancel dialog + }, + }); + }); +}); + +/** + * Test that we use the redirect principal for the dialog for refresh headers. + */ +add_task(async function test_redirect_principal() { + let scheme = TEST_PROTOS[0]; + await BrowserTestUtils.withNewTab("about:blank", async browser => { + await testOpenProto(browser, scheme, { + triggerLoad: useServerRedirect("refresh"), + permDialogOptions: { + checkboxOrigin: ORIGIN1, + chooserIsNext: true, + hasCheckbox: true, + actionConfirm: false, // Cancel dialog + }, + }); + }); +}); + +/** + * Test that we use the redirect principal for the dialog for meta refreshes. + */ +add_task(async function test_redirect_principal() { + let scheme = TEST_PROTOS[0]; + await BrowserTestUtils.withNewTab("about:blank", async browser => { + await testOpenProto(browser, scheme, { + triggerLoad: useServerRedirect("meta-refresh"), + permDialogOptions: { + checkboxOrigin: ORIGIN1, + chooserIsNext: true, + hasCheckbox: true, + actionConfirm: false, // Cancel dialog + }, + }); + }); +}); + +/** + * Test that we use the redirect principal for the dialog for JS redirects. + */ +add_task(async function test_redirect_principal_js() { + let scheme = TEST_PROTOS[0]; + await BrowserTestUtils.withNewTab("about:blank", async browser => { + await testOpenProto(browser, scheme, { + triggerLoad: () => { + let uri = `${scheme}://test`; + + let innerParams = new URLSearchParams(); + innerParams.set("uri", uri); + let params = new URLSearchParams(); + params.set( + "uri", + "https://example.com/" + + ROOT_PATH + + "script_redirect.html?" + + innerParams.toString() + ); + uri = + "https://example.org/" + + ROOT_PATH + + "script_redirect.html?" + + params.toString(); + BrowserTestUtils.loadURIString(browser, uri); + }, + permDialogOptions: { + checkboxOrigin: ORIGIN1, + chooserIsNext: true, + hasCheckbox: true, + actionConfirm: false, // Cancel dialog + }, + }); + }); +}); + +/** + * Test that we use the redirect principal for the dialog for link clicks. + */ +add_task(async function test_redirect_principal_links() { + let scheme = TEST_PROTOS[0]; + await BrowserTestUtils.withNewTab("about:blank", async browser => { + await testOpenProto(browser, scheme, { + triggerLoad: async () => { + let uri = `${scheme}://test`; + + let params = new URLSearchParams(); + params.set("uri", uri); + uri = + "https://example.com/" + + ROOT_PATH + + "redirect_helper.sjs?" + + params.toString(); + await ContentTask.spawn(browser, { uri }, args => { + let textLink = content.document.createElement("a"); + textLink.href = args.uri; + textLink.textContent = "click me"; + content.document.body.appendChild(textLink); + textLink.click(); + }); + }, + permDialogOptions: { + checkboxOrigin: ORIGIN1, + chooserIsNext: true, + hasCheckbox: true, + actionConfirm: false, // Cancel dialog + }, + }); + }); +}); diff --git a/uriloader/exthandler/tests/mochitest/browser_protocol_custom_sandbox.js b/uriloader/exthandler/tests/mochitest/browser_protocol_custom_sandbox.js new file mode 100644 index 0000000000..bc4b13730a --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/browser_protocol_custom_sandbox.js @@ -0,0 +1,140 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests navigation to external protocol from sandboxed iframes. + */ + +"use strict"; + +requestLongerTimeout(2); + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [["dom.block_external_protocol_navigation_from_sandbox", true]], + }); + + await setupMailHandler(); +}); + +add_task(async function test_sandbox_disabled() { + await runExtProtocolSandboxTest({ blocked: false, sandbox: null }); +}); + +add_task(async function test_sandbox_allowed() { + let flags = [ + "allow-popups", + "allow-top-navigation", + "allow-top-navigation-by-user-activation", + "allow-top-navigation-to-custom-protocols", + ]; + + for (let flag of flags) { + await runExtProtocolSandboxTest({ + blocked: false, + sandbox: `allow-scripts ${flag}`, + }); + } +}); + +add_task(async function test_sandbox_blocked() { + let flags = [ + "", + "allow-same-origin", + "allow-forms", + "allow-scripts", + "allow-pointer-lock", + "allow-orientation-lock", + "allow-modals", + "allow-popups-to-escape-sandbox", + "allow-presentation", + "allow-storage-access-by-user-activation", + "allow-downloads", + ]; + + for (let flag of flags) { + await runExtProtocolSandboxTest({ + blocked: true, + sandbox: `allow-scripts ${flag}`, + }); + } +}); + +add_task(async function test_sandbox_blocked_triggers() { + info( + "For sandboxed frames external protocol navigation is blocked, no matter how it is triggered." + ); + for (let triggerMethod of [ + "trustedClick", + "untrustedClick", + "trustedLocationAPI", + "untrustedLocationAPI", + "frameSrc", + ]) { + await runExtProtocolSandboxTest({ + blocked: true, + sandbox: "allow-scripts", + triggerMethod, + }); + } + + info( + "When allow-top-navigation-by-user-activation navigation to external protocols with transient user activations is allowed." + ); + await runExtProtocolSandboxTest({ + blocked: false, + sandbox: "allow-scripts allow-top-navigation-by-user-activation", + triggerMethod: "trustedClick", + }); + + await runExtProtocolSandboxTest({ + blocked: true, + sandbox: "allow-scripts allow-top-navigation-by-user-activation", + triggerMethod: "untrustedClick", + }); + + await runExtProtocolSandboxTest({ + blocked: true, + sandbox: "allow-scripts allow-top-navigation-by-user-activation", + triggerMethod: "untrustedLocationAPI", + }); + + await runExtProtocolSandboxTest({ + blocked: true, + sandbox: "allow-scripts allow-top-navigation-by-user-activation", + triggerMethod: "frameSrc", + }); +}); + +add_task(async function test_sandbox_combination() { + await runExtProtocolSandboxTest({ + blocked: false, + sandbox: + "allow-scripts allow-downloads allow-top-navigation-to-custom-protocols", + }); + + await runExtProtocolSandboxTest({ + blocked: false, + sandbox: + "allow-scripts allow-top-navigation allow-top-navigation-to-custom-protocols", + }); + + await runExtProtocolSandboxTest({ + blocked: true, + sandbox: "allow-scripts allow-modals", + }); +}); + +add_task(async function test_sandbox_iframe_redirect() { + await runExtProtocolSandboxTest({ + blocked: true, + sandbox: "allow-scripts", + triggerMethod: "frameSrcRedirect", + }); + + await runExtProtocolSandboxTest({ + blocked: false, + sandbox: "allow-scripts allow-top-navigation-to-custom-protocols", + triggerMethod: "frameSrcRedirect", + }); +}); diff --git a/uriloader/exthandler/tests/mochitest/browser_protocol_custom_sandbox_csp.js b/uriloader/exthandler/tests/mochitest/browser_protocol_custom_sandbox_csp.js new file mode 100644 index 0000000000..03d2fe8cf5 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/browser_protocol_custom_sandbox_csp.js @@ -0,0 +1,38 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests navigation to external protocol from csp-sandboxed iframes. + */ + +"use strict"; + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [["dom.block_external_protocol_navigation_from_sandbox", true]], + }); + + await setupMailHandler(); +}); + +add_task(async function test_sandbox_csp() { + for (let triggerMethod of [ + "trustedClick", + "untrustedClick", + "trustedLocationAPI", + "untrustedLocationAPI", + ]) { + await runExtProtocolSandboxTest({ + blocked: false, + sandbox: "allow-scripts", + useCSPSandbox: true, + triggerMethod, + }); + } + + await runExtProtocolSandboxTest({ + blocked: false, + sandbox: "allow-scripts allow-top-navigation-to-custom-protocols", + useCSPSandbox: true, + }); +}); diff --git a/uriloader/exthandler/tests/mochitest/browser_protocolhandler_loop.js b/uriloader/exthandler/tests/mochitest/browser_protocolhandler_loop.js new file mode 100644 index 0000000000..149701fb23 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/browser_protocolhandler_loop.js @@ -0,0 +1,74 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function test_helperapp() { + // Set up the test infrastructure: + const kProt = "foopydoopydoo"; + const extProtocolSvc = Cc[ + "@mozilla.org/uriloader/external-protocol-service;1" + ].getService(Ci.nsIExternalProtocolService); + const handlerSvc = Cc["@mozilla.org/uriloader/handler-service;1"].getService( + Ci.nsIHandlerService + ); + let handlerInfo = extProtocolSvc.getProtocolHandlerInfo(kProt); + if (handlerSvc.exists(handlerInfo)) { + handlerSvc.fillHandlerInfo(handlerInfo, ""); + } + // Say we want to use a specific app: + handlerInfo.preferredAction = Ci.nsIHandlerInfo.useHelperApp; + handlerInfo.alwaysAskBeforeHandling = false; + + // Say it's us: + let selfFile = Services.dirsvc.get("XREExeF", Ci.nsIFile); + // Make sure it's the .app + if (AppConstants.platform == "macosx") { + while ( + !selfFile.leafName.endsWith(".app") && + !selfFile.leafName.endsWith(".app/") + ) { + selfFile = selfFile.parent; + } + } + let selfHandlerApp = Cc[ + "@mozilla.org/uriloader/local-handler-app;1" + ].createInstance(Ci.nsILocalHandlerApp); + selfHandlerApp.executable = selfFile; + handlerInfo.possibleApplicationHandlers.appendElement(selfHandlerApp); + handlerInfo.preferredApplicationHandler = selfHandlerApp; + handlerSvc.store(handlerInfo); + + await BrowserTestUtils.withNewTab("about:blank", async browser => { + // Now, do some safety stubbing. If we do end up recursing we spawn + // infinite tabs. We definitely don't want that. Avoid it by stubbing + // our external URL handling bits: + let oldAddTab = gBrowser.addTab; + registerCleanupFunction(() => (gBrowser.addTab = oldAddTab)); + let wrongThingHappenedPromise = new Promise(resolve => { + gBrowser.addTab = function (aURI) { + ok(false, "Tried to open unexpected URL in a tab: " + aURI); + resolve(null); + // Pass a dummy object to avoid upsetting BrowserContentHandler - + // if it thinks opening the tab failed, it tries to open a window instead, + // which we can't prevent as easily, and at which point we still end up + // with runaway tabs. + return {}; + }; + }); + + let askedUserPromise = waitForProtocolAppChooserDialog(browser, true); + + BrowserTestUtils.loadURIString(browser, kProt + ":test"); + let dialog = await Promise.race([ + wrongThingHappenedPromise, + askedUserPromise, + ]); + ok(dialog, "Should have gotten a dialog"); + + let closePromise = waitForProtocolAppChooserDialog(browser, false); + dialog.close(); + await closePromise; + askedUserPromise = null; + }); +}); diff --git a/uriloader/exthandler/tests/mochitest/browser_remember_download_option.js b/uriloader/exthandler/tests/mochitest/browser_remember_download_option.js new file mode 100644 index 0000000000..f3fedab69c --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/browser_remember_download_option.js @@ -0,0 +1,61 @@ +add_task(async function () { + // create mocked objects + let launcher = createMockedObjects(true); + + // open helper app dialog with mocked launcher + let dlg = await openHelperAppDialog(launcher); + + let doc = dlg.document; + let dialogElement = doc.getElementById("unknownContentType"); + + // Set remember choice + ok( + !doc.getElementById("rememberChoice").checked, + "Remember choice checkbox should be not checked." + ); + doc.getElementById("rememberChoice").checked = true; + + // Make sure the mock handler information is not in nsIHandlerService + ok( + !gHandlerSvc.exists(launcher.MIMEInfo), + "Should not be in nsIHandlerService." + ); + + // close the dialog by pushing the ok button. + let dialogClosedPromise = BrowserTestUtils.windowClosed(dlg); + // Make sure the ok button is enabled, since the ok button might be disabled by + // EnableDelayHelper mechanism. Please refer the detailed + // https://searchfox.org/mozilla-central/source/toolkit/components/prompts/src/PromptUtils.sys.mjs#51 + dialogElement.getButton("accept").disabled = false; + dialogElement.acceptDialog(); + await dialogClosedPromise; + + // check the mocked handler information is saved in nsIHandlerService + ok(gHandlerSvc.exists(launcher.MIMEInfo), "Should be in nsIHandlerService."); + // check the extension. + var mimeType = gHandlerSvc.getTypeFromExtension("abc"); + is(mimeType, launcher.MIMEInfo.type, "Got correct mime type."); + for (let handlerInfo of gHandlerSvc.enumerate()) { + if (handlerInfo.type == launcher.MIMEInfo.type) { + // check the alwaysAskBeforeHandling + ok( + !handlerInfo.alwaysAskBeforeHandling, + "Should turn off the always ask." + ); + // check the preferredApplicationHandler + ok( + handlerInfo.preferredApplicationHandler.equals( + launcher.MIMEInfo.preferredApplicationHandler + ), + "Should be equal to the mockedHandlerApp." + ); + // check the perferredAction + is( + handlerInfo.preferredAction, + launcher.MIMEInfo.preferredAction, + "Should be equal to Ci.nsIHandlerInfo.useHelperApp." + ); + break; + } + } +}); diff --git a/uriloader/exthandler/tests/mochitest/browser_save_filenames.js b/uriloader/exthandler/tests/mochitest/browser_save_filenames.js new file mode 100644 index 0000000000..f421a7a609 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/browser_save_filenames.js @@ -0,0 +1,823 @@ +// There are at least seven different ways in a which a file can be saved or downloaded. This +// test ensures that the filename is determined correctly when saving in these ways. The seven +// ways are: +// - save the file individually from the File menu +// - save as complete web page (this uses a different codepath than the previous one) +// - dragging an image to the local file system +// - copy an image and paste it as a file to the local file system (windows only) +// - open a link with content-disposition set to attachment +// - open a link with the download attribute +// - save a link or image from the context menu + +requestLongerTimeout(8); + +let types = { + text: "text/plain", + html: "text/html", + png: "image/png", + jpeg: "image/jpeg", + webp: "image/webp", + otherimage: "image/unknown", + // Other js types (application/javascript and text/javascript) are handled by the system + // inconsistently, but application/x-javascript is handled by the external helper app service, + // so it is used here for this test. + js: "application/x-javascript", + binary: "application/octet-stream", + nonsense: "application/x-nonsense", + zip: "application/zip", + json: "application/json", + tar: "application/x-tar", +}; + +const PNG_DATA = atob( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAAA1BMVEUAA" + + "ACnej3aAAAAAXRSTlMAQObYZgAAAApJREFUCNdjYAAAAAIAAeIhvDMAAAAASUVORK5CYII=" +); + +const JPEG_DATA = atob( + "/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4z" + + "NDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCAABAAEDASIAAhEB" + + "AxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS" + + "0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKz" + + "tLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgEC" + + "BAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpj" + + "ZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6" + + "/9oADAMBAAIRAxEAPwD3+iiigD//2Q==" +); + +const WEBP_DATA = atob( + "UklGRiIAAABXRUJQVlA4TBUAAAAvY8AYAAfQ/4j+B4CE8H+/ENH/VCIA" +); + +const DEFAULT_FILENAME = + AppConstants.platform == "win" ? "Untitled.htm" : "Untitled.html"; + +const PROMISE_FILENAME_TYPE = "application/x-moz-file-promise-dest-filename"; + +let MockFilePicker = SpecialPowers.MockFilePicker; +MockFilePicker.init(window); + +let expectedItems; +let sendAsAttachment = false; +let httpServer = null; + +function handleRequest(aRequest, aResponse) { + const queryString = new URLSearchParams(aRequest.queryString); + let type = queryString.get("type"); + let filename = queryString.get("filename"); + let dispname = queryString.get("dispname"); + + aResponse.setStatusLine(aRequest.httpVersion, 200); + if (type) { + aResponse.setHeader("Content-Type", types[type]); + } + + if (dispname) { + let dispositionType = sendAsAttachment ? "attachment" : "inline"; + aResponse.setHeader( + "Content-Disposition", + dispositionType + ';name="' + dispname + '"' + ); + } else if (filename) { + let dispositionType = sendAsAttachment ? "attachment" : "inline"; + aResponse.setHeader( + "Content-Disposition", + dispositionType + ';filename="' + filename + '"' + ); + } else if (sendAsAttachment) { + aResponse.setHeader("Content-Disposition", "attachment"); + } + + if (type == "png") { + aResponse.write(PNG_DATA); + } else if (type == "jpeg") { + aResponse.write(JPEG_DATA); + } else if (type == "webp") { + aResponse.write(WEBP_DATA); + } else if (type == "html") { + aResponse.write( + "<html><head><title>file.inv</title></head><body>File</body></html>" + ); + } else { + aResponse.write("// Some Text"); + } +} + +function handleBasicImageRequest(aRequest, aResponse) { + aResponse.setHeader("Content-Type", "image/png"); + aResponse.write(PNG_DATA); +} + +function handleRedirect(aRequest, aResponse) { + const queryString = new URLSearchParams(aRequest.queryString); + let filename = queryString.get("filename"); + + aResponse.setStatusLine(aRequest.httpVersion, 302); + aResponse.setHeader("Location", "/bell" + filename[0] + "?" + queryString); +} + +function promiseDownloadFinished(list) { + return new Promise(resolve => { + list.addView({ + onDownloadChanged(download) { + if (download.stopped) { + list.removeView(this); + resolve(download); + } + }, + }); + }); +} + +// nsIFile::CreateUnique crops long filenames if the path is too long, but +// we don't know exactly how long depending on the full path length, so +// for those save methods that use CreateUnique, instead just verify that +// the filename starts with the right string and has the correct extension. +function checkShortenedFilename(actual, expected) { + if (actual.length < expected.length) { + let actualDot = actual.lastIndexOf("."); + let actualExtension = actual.substring(actualDot); + let expectedExtension = expected.substring(expected.lastIndexOf(".")); + if ( + actualExtension == expectedExtension && + expected.startsWith(actual.substring(0, actualDot)) + ) { + return true; + } + } + + return false; +} + +add_setup(async function () { + const { HttpServer } = ChromeUtils.import( + "resource://testing-common/httpd.js" + ); + httpServer = new HttpServer(); + httpServer.start(8000); + + // Need to load the page from localhost:8000 as the download attribute + // only applies to links from the same domain. + let saveFilenamesPage = FileUtils.getFile( + "CurWorkD", + "/browser/uriloader/exthandler/tests/mochitest/save_filenames.html".split( + "/" + ) + ); + httpServer.registerFile("/save_filenames.html", saveFilenamesPage); + + // A variety of different scripts are set up to better ensure uniqueness. + httpServer.registerPathHandler("/save_filename.sjs", handleRequest); + httpServer.registerPathHandler("/save_thename.sjs", handleRequest); + httpServer.registerPathHandler("/getdata.png", handleRequest); + httpServer.registerPathHandler("/base", handleRequest); + httpServer.registerPathHandler("/basedata", handleRequest); + httpServer.registerPathHandler("/basetext", handleRequest); + httpServer.registerPathHandler("/text2.txt", handleRequest); + httpServer.registerPathHandler("/text3.gonk", handleRequest); + httpServer.registerPathHandler("/basic.png", handleBasicImageRequest); + httpServer.registerPathHandler("/aquamarine.jpeg", handleBasicImageRequest); + httpServer.registerPathHandler("/lazuli.exe", handleBasicImageRequest); + httpServer.registerPathHandler("/redir", handleRedirect); + httpServer.registerPathHandler("/bellr", handleRequest); + httpServer.registerPathHandler("/bellg", handleRequest); + httpServer.registerPathHandler("/bellb", handleRequest); + httpServer.registerPathHandler("/executable.exe", handleRequest); + + await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://localhost:8000/save_filenames.html" + ); + + expectedItems = await getItems("items"); +}); + +function getItems(parentid) { + return SpecialPowers.spawn( + gBrowser.selectedBrowser, + [parentid, AppConstants.platform], + (id, platform) => { + let elements = []; + let elem = content.document.getElementById(id).firstElementChild; + while (elem) { + let filename = + elem.dataset["filenamePlatform" + platform] || elem.dataset.filename; + let url = elem.getAttribute("src"); + let draggable = + elem.localName == "img" && elem.dataset.nodrag != "true"; + let unknown = elem.dataset.unknown; + let noattach = elem.dataset.noattach; + let savepagename = elem.dataset.savepagename; + elements.push({ + draggable, + unknown, + filename, + url, + noattach, + savepagename, + }); + elem = elem.nextElementSibling; + } + return elements; + } + ); +} + +function getDirectoryEntries(dir) { + let files = []; + let entries = dir.directoryEntries; + while (true) { + let file = entries.nextFile; + if (!file) { + break; + } + files.push(file.leafName); + } + entries.close(); + return files; +} + +// This test saves the document as a complete web page and verifies +// that the resources are saved with the correct filename. +add_task(async function save_document() { + let browser = gBrowser.selectedBrowser; + + let tmp = SpecialPowers.Services.dirsvc.get("TmpD", Ci.nsIFile); + const baseFilename = "test_save_filenames_" + Date.now(); + + let tmpFile = tmp.clone(); + tmpFile.append(baseFilename + "_document.html"); + let tmpDir = tmp.clone(); + tmpDir.append(baseFilename + "_document_files"); + + MockFilePicker.displayDirectory = tmpDir; + MockFilePicker.showCallback = function (fp) { + MockFilePicker.setFiles([tmpFile]); + MockFilePicker.filterIndex = 0; // kSaveAsType_Complete + }; + + let downloadsList = await Downloads.getList(Downloads.PUBLIC); + let savePromise = new Promise((resolve, reject) => { + downloadsList.addView({ + onDownloadChanged(download) { + if (download.succeeded) { + downloadsList.removeView(this); + downloadsList.removeFinished(); + resolve(); + } + }, + }); + }); + saveBrowser(browser); + await savePromise; + + let filesSaved = getDirectoryEntries(tmpDir); + + for (let idx = 0; idx < expectedItems.length; idx++) { + let filename = expectedItems[idx].filename; + if (idx == 66 && AppConstants.platform == "win") { + // This is special-cased on Windows. The default filename will be used, since + // the filename is invalid, but since the previous test file has the same issue, + // this second file will be saved with a number suffix added to it. + filename = "Untitled_002"; + } + + let file = tmpDir.clone(); + file.append(filename); + + let fileIdx = -1; + // Use checkShortenedFilename to check long filenames. + if (filename.length > 240) { + for (let t = 0; t < filesSaved.length; t++) { + if ( + filesSaved[t].length > 60 && + checkShortenedFilename(filesSaved[t], filename) + ) { + fileIdx = t; + break; + } + } + } else { + fileIdx = filesSaved.indexOf(filename); + } + + ok( + fileIdx >= 0, + "file i" + + idx + + " " + + filename + + " was saved with the correct name using saveDocument" + ); + if (fileIdx >= 0) { + // If found, remove it from the list. At end of the test, the + // list should be empty. + filesSaved.splice(fileIdx, 1); + } + } + + is(filesSaved.length, 0, "all files accounted for"); + tmpDir.remove(true); + tmpFile.remove(false); +}); + +// This test simulates dragging the images in the document and ensuring that +// the correct filename is used for each one. +// On Mac, the data is added in the parent process instead, so we cannot +// test dragging directly. +if (AppConstants.platform != "macosx") { + add_task(async function drag_files() { + let browser = gBrowser.selectedBrowser; + + await SpecialPowers.spawn(browser, [PROMISE_FILENAME_TYPE], type => { + content.addEventListener("dragstart", event => { + content.draggedFile = event.dataTransfer.getData(type); + event.preventDefault(); + }); + }); + + for (let idx = 0; idx < expectedItems.length; idx++) { + if (!expectedItems[idx].draggable) { + // You can't drag non-images and invalid images. + continue; + } + + await BrowserTestUtils.synthesizeMouse( + "#i" + idx, + 1, + 1, + { type: "mousedown" }, + browser + ); + await BrowserTestUtils.synthesizeMouse( + "#i" + idx, + 11, + 11, + { type: "mousemove" }, + browser + ); + await BrowserTestUtils.synthesizeMouse( + "#i" + idx, + 20, + 20, + { type: "mousemove" }, + browser + ); + await BrowserTestUtils.synthesizeMouse( + "#i" + idx, + 20, + 20, + { type: "mouseup" }, + browser + ); + + let draggedFile = await SpecialPowers.spawn(browser, [], () => { + let file = content.draggedFile; + content.draggedFile = null; + return file; + }); + + is( + draggedFile, + expectedItems[idx].filename, + "i" + + idx + + " " + + expectedItems[idx].filename + + " was saved with the correct name when dragging" + ); + } + }); +} + +// This test checks that copying an image provides the right filename +// for pasting to the local file system. This is only implemented on Windows. +if (AppConstants.platform == "win") { + add_task(async function copy_image() { + for (let idx = 0; idx < expectedItems.length; idx++) { + if (!expectedItems[idx].draggable) { + // You can't context-click on non-images. + continue; + } + + let data = await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [idx, PROMISE_FILENAME_TYPE], + (imagenum, type) => { + // No need to wait for the data to be really on the clipboard, we only + // need the promise data added when the command is performed. + SpecialPowers.setCommandNode( + content, + content.document.getElementById("i" + imagenum) + ); + SpecialPowers.doCommand(content, "cmd_copyImageContents"); + + return SpecialPowers.getClipboardData(type); + } + ); + + is( + data, + expectedItems[idx].filename, + "i" + + idx + + " " + + expectedItems[idx].filename + + " was saved with the correct name when copying" + ); + } + }); +} + +// This test checks the default filename selected when selecting to save +// a file from either the context menu or what would happen when save page +// as was selected from the file menu. Note that this tests a filename assigned +// when using content-disposition: inline. +add_task(async function saveas_files() { + // Iterate over each item and try saving them from the context menu, + // and the Save Page As command on the File menu. + for (let testname of ["context menu", "save page as"]) { + for (let idx = 0; idx < expectedItems.length; idx++) { + let menu; + if (testname == "context menu") { + if (!expectedItems[idx].draggable) { + // You can't context-click on non-images. + continue; + } + + menu = document.getElementById("contentAreaContextMenu"); + let popupShown = BrowserTestUtils.waitForEvent(menu, "popupshown"); + BrowserTestUtils.synthesizeMouse( + "#i" + idx, + 5, + 5, + { type: "contextmenu", button: 2 }, + gBrowser.selectedBrowser + ); + await popupShown; + } else { + if (expectedItems[idx].unknown == "typeonly") { + // Items marked with unknown="typeonly" have unknown content types and + // will be downloaded instead of opened in a tab. + let list = await Downloads.getList(Downloads.PUBLIC); + let downloadFinishedPromise = promiseDownloadFinished(list); + + await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + opening: expectedItems[idx].url, + waitForLoad: false, + waitForStateStop: true, + }); + + let download = await downloadFinishedPromise; + + let filename = PathUtils.filename(download.target.path); + + let expectedFilename = expectedItems[idx].filename; + if (expectedFilename.length > 240) { + ok( + checkShortenedFilename(filename, expectedFilename), + "open link" + + idx + + " " + + expectedFilename + + " was downloaded with the correct name when opened as a url (with long name)" + ); + } else { + is( + filename, + expectedFilename, + "open link" + + idx + + " " + + expectedFilename + + " was downloaded with the correct name when opened as a url" + ); + } + + try { + await IOUtils.remove(download.target.path); + } catch (ex) {} + + await BrowserTestUtils.removeTab(gBrowser.selectedTab); + continue; + } + + await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + opening: expectedItems[idx].url, + waitForLoad: false, + waitForStateStop: true, + }); + } + + let filename = await new Promise(resolve => { + MockFilePicker.showCallback = function (fp) { + setTimeout(() => { + resolve(fp.defaultString); + }, 0); + return Ci.nsIFilePicker.returnCancel; + }; + + if (testname == "context menu") { + let menuitem = document.getElementById("context-saveimage"); + menu.activateItem(menuitem); + } else if (testname == "save page as") { + document.getElementById("Browser:SavePage").doCommand(); + } + }); + + // Trying to open an unknown or binary type will just open a blank + // page, so trying to save will just save the blank page with the + // filename Untitled.html. + let expectedFilename = expectedItems[idx].unknown + ? DEFAULT_FILENAME + : expectedItems[idx].savepagename || expectedItems[idx].filename; + + // When saving via contentAreaUtils.js, the content disposition name + // field is used as an alternate. + if (expectedFilename == "save_thename.png") { + expectedFilename = "withname.png"; + } + + is( + filename, + expectedFilename, + "i" + + idx + + " " + + expectedFilename + + " was saved with the correct name " + + testname + ); + + if (testname == "save page as") { + await BrowserTestUtils.removeTab(gBrowser.selectedTab); + } + } + } +}); + +// This test checks that links that result in files with +// content-disposition: attachment are saved with the right filenames. +add_task(async function save_links() { + sendAsAttachment = true; + + // Create some links based on each image and insert them into the document. + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { + let doc = content.document; + let insertPos = doc.getElementById("attachment-links"); + + let idx = 0; + let elem = doc.getElementById("items").firstElementChild; + while (elem) { + let attachmentlink = doc.createElement("a"); + attachmentlink.id = "attachmentlink" + idx; + attachmentlink.href = elem.localName == "object" ? elem.data : elem.src; + attachmentlink.textContent = elem.dataset.filename; + insertPos.appendChild(attachmentlink); + insertPos.appendChild(doc.createTextNode(" ")); + + elem = elem.nextElementSibling; + idx++; + } + }); + + let list = await Downloads.getList(Downloads.PUBLIC); + + for (let idx = 0; idx < expectedItems.length; idx++) { + // Skip the items that won't have a content-disposition. + if (expectedItems[idx].noattach) { + continue; + } + + let downloadFinishedPromise = promiseDownloadFinished(list); + + BrowserTestUtils.synthesizeMouse( + "#attachmentlink" + idx, + 5, + 5, + {}, + gBrowser.selectedBrowser + ); + + let download = await downloadFinishedPromise; + + let filename = PathUtils.filename(download.target.path); + + let expectedFilename = expectedItems[idx].filename; + // Use checkShortenedFilename to check long filenames. + if (expectedItems[idx].filename.length > 240) { + ok( + checkShortenedFilename(filename, expectedFilename), + "attachmentlink" + + idx + + " " + + expectedFilename + + " was saved with the correct name when opened as attachment (with long name)" + ); + } else { + is( + filename, + expectedFilename, + "attachmentlink" + + idx + + " " + + expectedFilename + + " was saved with the correct name when opened as attachment" + ); + } + + try { + await IOUtils.remove(download.target.path); + } catch (ex) {} + } + + sendAsAttachment = false; +}); + +// This test checks some cases where links to images are saved using Save Link As, +// and when opening them in a new tab and then using Save Page As. +add_task(async function saveas_image_links() { + let links = await getItems("links"); + + // Iterate over each link and try saving the links from the context menu, + // and then after opening a new tab for that link and then selecting + // the Save Page As command on the File menu. + for (let testname of ["save link as", "save link then save page as"]) { + for (let idx = 0; idx < links.length; idx++) { + let menu = document.getElementById("contentAreaContextMenu"); + let popupShown = BrowserTestUtils.waitForEvent(menu, "popupshown"); + BrowserTestUtils.synthesizeMouse( + "#link" + idx, + 5, + 5, + { type: "contextmenu", button: 2 }, + gBrowser.selectedBrowser + ); + await popupShown; + + let promptPromise = new Promise(resolve => { + MockFilePicker.showCallback = function (fp) { + setTimeout(() => { + resolve(fp.defaultString); + }, 0); + return Ci.nsIFilePicker.returnCancel; + }; + }); + + if (testname == "save link as") { + let menuitem = document.getElementById("context-savelink"); + menu.activateItem(menuitem); + } else { + let newTabPromise = BrowserTestUtils.waitForNewTab(gBrowser); + + let menuitem = document.getElementById("context-openlinkintab"); + menu.activateItem(menuitem); + + let tab = await newTabPromise; + await BrowserTestUtils.switchTab(gBrowser, tab); + + document.getElementById("Browser:SavePage").doCommand(); + } + + let filename = await promptPromise; + + let expectedFilename = links[idx].filename; + // Only codepaths that go through contentAreaUtils.js use the + // name from the content disposition. + if (testname == "save link as" && expectedFilename == "four.png") { + expectedFilename = "save_filename.png"; + } + + is( + filename, + expectedFilename, + "i" + + idx + + " " + + expectedFilename + + " link was saved with the correct name " + + testname + ); + + if (testname == "save link then save page as") { + await BrowserTestUtils.removeTab(gBrowser.selectedTab); + } + } + } +}); + +// This test checks that links that with a download attribute +// are saved with the right filenames. +add_task(async function save_download_links() { + let downloads = await getItems("downloads"); + + let list = await Downloads.getList(Downloads.PUBLIC); + for (let idx = 0; idx < downloads.length; idx++) { + let downloadFinishedPromise = promiseDownloadFinished(list); + + BrowserTestUtils.synthesizeMouse( + "#download" + idx, + 2, + 2, + {}, + gBrowser.selectedBrowser + ); + + let download = await downloadFinishedPromise; + + let filename = PathUtils.filename(download.target.path); + + if (downloads[idx].filename.length > 240) { + ok( + checkShortenedFilename(filename, downloads[idx].filename), + "download" + + idx + + " " + + downloads[idx].filename + + " was saved with the correct name when link has download attribute" + ); + } else { + if (idx == 66 && filename == "Untitled(1)") { + // Sometimes, the previous test's file still exists or wasn't created in time + // and a non-duplicated name is created. Allow this rather than figuring out + // how to avoid it since it doesn't affect what is being tested here. + filename = "Untitled"; + } + + is( + filename, + downloads[idx].filename, + "download" + + idx + + " " + + downloads[idx].filename + + " was saved with the correct name when link has download attribute" + ); + } + + try { + await IOUtils.remove(download.target.path); + } catch (ex) {} + } +}); + +// This test verifies that invalid extensions are not removed when they +// have been entered in the file picker. +add_task(async function save_page_with_invalid_after_filepicker() { + await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://localhost:8000/save_filename.sjs?type=html&filename=invfile.lnk" + ); + + let filename = await new Promise(resolve => { + MockFilePicker.showCallback = function (fp) { + let expectedFilename = + AppConstants.platform == "win" ? "invfile.lnk.htm" : "invfile.lnk.html"; + is(fp.defaultString, expectedFilename, "supplied filename is correct"); + setTimeout(() => { + resolve("otherfile.local"); + }, 0); + return Ci.nsIFilePicker.returnCancel; + }; + + document.getElementById("Browser:SavePage").doCommand(); + }); + + is(filename, "otherfile.local", "lnk extension has been preserved"); + + await BrowserTestUtils.removeTab(gBrowser.selectedTab); +}); + +add_task(async function save_page_with_invalid_extension() { + await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://localhost:8000/save_filename.sjs?type=html" + ); + + let filename = await new Promise(resolve => { + MockFilePicker.showCallback = function (fp) { + setTimeout(() => { + resolve(fp.defaultString); + }, 0); + return Ci.nsIFilePicker.returnCancel; + }; + + document.getElementById("Browser:SavePage").doCommand(); + }); + + is( + filename, + AppConstants.platform == "win" ? "file.inv.htm" : "file.inv.html", + "html extension has been added" + ); + + await BrowserTestUtils.removeTab(gBrowser.selectedTab); +}); + +add_task(async () => { + BrowserTestUtils.removeTab(gBrowser.selectedTab); + MockFilePicker.cleanup(); + await new Promise(resolve => httpServer.stop(resolve)); +}); diff --git a/uriloader/exthandler/tests/mochitest/browser_shows_where_to_save_dialog.js b/uriloader/exthandler/tests/mochitest/browser_shows_where_to_save_dialog.js new file mode 100644 index 0000000000..6bf375dfff --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/browser_shows_where_to_save_dialog.js @@ -0,0 +1,303 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { DownloadIntegration } = ChromeUtils.importESModule( + "resource://gre/modules/DownloadIntegration.sys.mjs" +); + +const TEST_PATH = getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "https://example.com" +); + +const { handleInternally, useHelperApp, useSystemDefault, saveToDisk } = + Ci.nsIHandlerInfo; + +let MockFilePicker = SpecialPowers.MockFilePicker; +MockFilePicker.init(window); + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.download.always_ask_before_handling_new_types", false], + ["browser.download.useDownloadDir", false], + ], + }); + + registerCleanupFunction(async () => { + let hiddenPromise = BrowserTestUtils.waitForEvent( + DownloadsPanel.panel, + "popuphidden" + ); + DownloadsPanel.hidePanel(); + await hiddenPromise; + MockFilePicker.cleanup(); + }); +}); + +// This test ensures that a "Save as..." filepicker dialog is shown for a file +// if useDownloadDir ("Always ask where to save files") is set to false and +// the filetype is set to save to disk. +add_task(async function aDownloadSavedToDiskPromptsForFolder() { + let publicList = await Downloads.getList(Downloads.PUBLIC); + ensureMIMEState( + { preferredAction: saveToDisk }, + { type: "text/plain", ext: "txt" } + ); + registerCleanupFunction(async () => { + await publicList.removeFinished(); + }); + let filePickerShownPromise = new Promise(resolve => { + MockFilePicker.showCallback = function (fp) { + setTimeout(resolve, 0); + return Ci.nsIFilePicker.returnCancel; + }; + }); + + let loadingTab = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + opening: TEST_PATH + "file_txt_attachment_test.txt", + waitForLoad: false, + waitForStateStop: true, + }); + + info("Waiting on filepicker."); + await filePickerShownPromise; + ok(true, "filepicker should have been shown"); + + BrowserTestUtils.removeTab(loadingTab); +}); + +// This test ensures that downloads configured to open internally create only +// one file destination when saved via the filepicker, and don't prompt. +add_task(async function testFilesHandledInternally() { + let dir = await setupFilePickerDirectory(); + + ensureMIMEState( + { preferredAction: handleInternally }, + { type: "image/webp", ext: "webp" } + ); + + let filePickerShown = false; + MockFilePicker.showCallback = function (fp) { + filePickerShown = true; + return Ci.nsIFilePicker.returnCancel; + }; + + let thirdTabPromise = BrowserTestUtils.waitForNewTab( + gBrowser, + url => { + info("Got load for " + url); + return url.endsWith("file_green.webp") && url.startsWith("file:"); + }, + true, + true + ); + let loadingTab = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + opening: TEST_PATH + "file_green.webp", + waitForLoad: false, + waitForStateStop: true, + }); + + let openedTab = await thirdTabPromise; + ok(!filePickerShown, "file picker should not have shown up."); + + assertCorrectFile(dir, "file_green.webp"); + + // Cleanup + BrowserTestUtils.removeTab(loadingTab); + BrowserTestUtils.removeTab(openedTab); +}); + +// This test ensures that downloads configured to open with a system default +// app create only one file destination and don't open the filepicker. +add_task(async function testFilesHandledBySystemDefaultApp() { + let dir = await setupFilePickerDirectory(); + + ensureMIMEState({ preferredAction: useSystemDefault }); + + let filePickerShown = false; + MockFilePicker.showCallback = function (fp) { + filePickerShown = true; + return Ci.nsIFilePicker.returnCancel; + }; + + let oldLaunchFile = DownloadIntegration.launchFile; + let launchFileCalled = new Promise(resolve => { + DownloadIntegration.launchFile = async (file, mimeInfo) => { + is( + useSystemDefault, + mimeInfo.preferredAction, + "The file should be launched with a system app handler." + ); + resolve(); + }; + }); + + let loadingTab = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + opening: TEST_PATH + "file_pdf_application_pdf.pdf", + waitForLoad: false, + waitForStateStop: true, + }); + + await launchFileCalled; + ok(!filePickerShown, "file picker should not have shown up."); + + assertCorrectFile(dir, "file_pdf_application_pdf.pdf"); + + // Cleanup + BrowserTestUtils.removeTab(loadingTab); + DownloadIntegration.launchFile = oldLaunchFile; +}); + +// This test ensures that downloads configured to open with a helper app create +// only one file destination when saved via the filepicker. +add_task(async function testFilesHandledByHelperApp() { + let dir = await setupFilePickerDirectory(); + + // Create a custom helper app so we can check that a launcherPath is + // configured for the serialized download. + let appHandler = Cc[ + "@mozilla.org/uriloader/local-handler-app;1" + ].createInstance(Ci.nsILocalHandlerApp); + appHandler.name = "Dummy Test Handler"; + appHandler.executable = Services.dirsvc.get("ProfD", Ci.nsIFile); + appHandler.executable.append("helper_handler_test.exe"); + + if (!appHandler.executable.exists()) { + appHandler.executable.create(Ci.nsIFile.NORMAL_FILE_TYPE, 0o777); + } + + ensureMIMEState({ + preferredAction: useHelperApp, + preferredHandlerApp: appHandler, + }); + + let filePickerShown = false; + MockFilePicker.showCallback = function (fp) { + filePickerShown = true; + return Ci.nsIFilePicker.returnCancel; + }; + + let publicDownloads = await Downloads.getList(Downloads.PUBLIC); + let downloadFinishedPromise = new Promise(resolve => { + publicDownloads.addView({ + onDownloadChanged(download) { + if (download.succeeded || download.error) { + ok( + download.launcherPath.includes("helper_handler_test.exe"), + "Launcher path is available." + ); + resolve(); + } + }, + }); + }); + + let oldLaunchFile = DownloadIntegration.launchFile; + let launchFileCalled = new Promise(resolve => { + DownloadIntegration.launchFile = async (file, mimeInfo) => { + is( + useHelperApp, + mimeInfo.preferredAction, + "The file should be launched with a helper app handler." + ); + resolve(); + }; + }); + + let loadingTab = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + opening: TEST_PATH + "file_pdf_application_pdf.pdf", + waitForLoad: false, + waitForStateStop: true, + }); + + await downloadFinishedPromise; + await launchFileCalled; + ok(!filePickerShown, "file picker should not have shown up."); + assertCorrectFile(dir, "file_pdf_application_pdf.pdf"); + + // Cleanup + BrowserTestUtils.removeTab(loadingTab); + DownloadIntegration.launchFile = oldLaunchFile; +}); + +async function setupFilePickerDirectory() { + let saveDir = createSaveDir(); + Services.prefs.setComplexValue("browser.download.dir", Ci.nsIFile, saveDir); + Services.prefs.setIntPref("browser.download.folderList", 2); + + MockFilePicker.displayDirectory = saveDir; + MockFilePicker.returnValue = MockFilePicker.returnOK; + MockFilePicker.showCallback = function (fp) { + let file = saveDir.clone(); + file.append(fp.defaultString); + MockFilePicker.setFiles([file]); + }; + + registerCleanupFunction(async () => { + Services.prefs.clearUserPref("browser.download.dir"); + Services.prefs.clearUserPref("browser.download.folderList"); + let publicList = await Downloads.getList(Downloads.PUBLIC); + let unfinishedDownloads = new Set( + (await publicList.getAll()).filter(dl => !dl.succeeded && !dl.error) + ); + if (unfinishedDownloads.size) { + info(`Have ${unfinishedDownloads.size} unfinished downloads, waiting.`); + await new Promise(resolve => { + let view = { + onChanged(dl) { + if (unfinishedDownloads.has(dl) && (dl.succeeded || dl.error)) { + unfinishedDownloads.delete(dl); + info(`Removed another download.`); + if (!unfinishedDownloads.size) { + publicList.removeView(view); + resolve(); + } + } + }, + }; + publicList.addView(view); + }); + } + try { + await IOUtils.remove(saveDir.path, { recursive: true }); + } catch (e) { + console.error(e); + } + }); + + return saveDir; +} + +function assertCorrectFile(saveDir, filename) { + info("Make sure additional files haven't been created."); + let iter = saveDir.directoryEntries; + let file = iter.nextFile; + ok(file.path.includes(filename), "Download has correct filename"); + ok(!iter.nextFile, "Only one file was created."); +} + +function createSaveDir() { + info("Creating save directory."); + let time = new Date().getTime(); + let saveDir = Services.dirsvc.get("TmpD", Ci.nsIFile); + saveDir.append(time); + return saveDir; +} + +function ensureMIMEState( + { preferredAction, preferredHandlerApp = null }, + { type = "application/pdf", ext = "pdf" } = {} +) { + const mimeInfo = gMimeSvc.getFromTypeAndExtension(type, ext); + mimeInfo.preferredAction = preferredAction; + mimeInfo.preferredApplicationHandler = preferredHandlerApp; + mimeInfo.alwaysAskBeforeHandling = false; + gHandlerSvc.store(mimeInfo); +} diff --git a/uriloader/exthandler/tests/mochitest/browser_txt_download_save_as.js b/uriloader/exthandler/tests/mochitest/browser_txt_download_save_as.js new file mode 100644 index 0000000000..7155c35fd9 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/browser_txt_download_save_as.js @@ -0,0 +1,168 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { DownloadIntegration } = ChromeUtils.importESModule( + "resource://gre/modules/DownloadIntegration.sys.mjs" +); +const HandlerService = Cc[ + "@mozilla.org/uriloader/handler-service;1" +].getService(Ci.nsIHandlerService); +const MIMEService = Cc["@mozilla.org/mime;1"].getService(Ci.nsIMIMEService); + +const TEST_PATH = getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "https://example.com" +); + +const { + saveToDisk, + alwaysAsk, + handleInternally, + useHelperApp, + useSystemDefault, +} = Ci.nsIHandlerInfo; +const testDir = createTemporarySaveDirectory(); +const MockFilePicker = SpecialPowers.MockFilePicker; +MockFilePicker.init(window); + +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/toolkit/content/tests/browser/common/mockTransfer.js", + this +); + +async function testSaveAsDownload() { + await BrowserTestUtils.withNewTab( + `data:text/html,<a id="test-link" href="${TEST_PATH}/file_txt_attachment_test.txt">Test TXT Link</a>`, + async browser => { + let menu = document.getElementById("contentAreaContextMenu"); + ok(menu, "Context menu exists on the page"); + + let popupShown = BrowserTestUtils.waitForEvent(menu, "popupshown"); + BrowserTestUtils.synthesizeMouseAtCenter( + "a#test-link", + { type: "contextmenu", button: 2 }, + browser + ); + await popupShown; + info("Context menu popup was successfully displayed"); + + let filePickerPromise = setupFilePicker(); + + info("Clicking Save As... context menu"); + let menuitem = menu.querySelector("#context-savelink"); + menu.activateItem(menuitem); + await filePickerPromise; + } + ); +} + +async function setupFilePicker() { + return new Promise(resolve => { + MockFilePicker.returnValue = MockFilePicker.returnOK; + MockFilePicker.displayDirectory = testDir; + MockFilePicker.showCallback = fp => { + ok(true, "filepicker should be visible"); + ok( + fp.defaultExtension === "txt", + "Default extension in filepicker should be txt" + ); + ok( + fp.defaultString === "file_txt_attachment_test.txt", + "Default string name in filepicker should have the correct file name" + ); + const destFile = testDir.clone(); + destFile.append(fp.defaultString); + MockFilePicker.setFiles([destFile]); + + mockTransferCallback = success => { + ok(success, "File should have been downloaded successfully"); + ok(destFile.exists(), "File should exist in test directory"); + resolve(destFile); + }; + }; + }); +} + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.download.always_ask_before_handling_new_types", false], + ["browser.download.useDownloadDir", false], + ], + }); + mockTransferRegisterer.register(); + + let oldLaunchFile = DownloadIntegration.launchFile; + DownloadIntegration.launchFile = () => { + ok(false, "Download should not have launched"); + }; + + registerCleanupFunction(async () => { + DownloadIntegration.launchFile = oldLaunchFile; + mockTransferRegisterer.unregister(); + + // We only want to run MockFilerPicker.cleanup after the entire test is run. + // Otherwise, we cannot use MockFilePicker for each preferredAction. + MockFilePicker.cleanup(); + + testDir.remove(true); + ok(!testDir.exists(), "Test directory should be removed"); + }); +}); + +/** + * Tests that selecting the context menu item `Save Link As…` on a txt file link + * opens the file picker and only downloads the file without any launches when + * browser.download.always_ask_before_handling_new_types is disabled. + */ +add_task(async function test_txt_save_as_link() { + let mimeInfo; + + for (let preferredAction of [ + saveToDisk, + alwaysAsk, + handleInternally, + useHelperApp, + useSystemDefault, + ]) { + mimeInfo = MIMEService.getFromTypeAndExtension("text/plain", "txt"); + mimeInfo.alwaysAskBeforeHandling = preferredAction === alwaysAsk; + mimeInfo.preferredAction = preferredAction; + HandlerService.store(mimeInfo); + + info( + `Setting up filepicker with preferredAction ${preferredAction} and ask = ${mimeInfo.alwaysAskBeforeHandling}` + ); + await testSaveAsDownload(mimeInfo); + } +}); + +/** + * Tests that selecting the context menu item `Save Link As…` on a txt file link + * opens the file picker and only downloads the file without any launches when + * browser.download.always_ask_before_handling_new_types is disabled. For this + * particular test, set alwaysAskBeforeHandling to true. + */ +add_task(async function test_txt_save_as_link_alwaysAskBeforeHandling() { + let mimeInfo; + + for (let preferredAction of [ + saveToDisk, + alwaysAsk, + handleInternally, + useHelperApp, + useSystemDefault, + ]) { + mimeInfo = MIMEService.getFromTypeAndExtension("text/plain", "txt"); + mimeInfo.alwaysAskBeforeHandling = true; + mimeInfo.preferredAction = preferredAction; + HandlerService.store(mimeInfo); + + info( + `Setting up filepicker with preferredAction ${preferredAction} and ask = ${mimeInfo.alwaysAskBeforeHandling}` + ); + await testSaveAsDownload(mimeInfo); + } +}); diff --git a/uriloader/exthandler/tests/mochitest/browser_web_handler_app_pinned_tab.js b/uriloader/exthandler/tests/mochitest/browser_web_handler_app_pinned_tab.js new file mode 100644 index 0000000000..2f06833665 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/browser_web_handler_app_pinned_tab.js @@ -0,0 +1,81 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +let testURL = + "http://mochi.test:8888/browser/" + + "uriloader/exthandler/tests/mochitest/mailto.html"; + +XPCOMUtils.defineLazyServiceGetter( + this, + "gExternalProtocolService", + "@mozilla.org/uriloader/external-protocol-service;1", + "nsIExternalProtocolService" +); +XPCOMUtils.defineLazyServiceGetter( + this, + "gHandlerService", + "@mozilla.org/uriloader/handler-service;1", + "nsIHandlerService" +); + +let prevAlwaysAskBeforeHandling; +let prevPreferredAction; +let prevPreferredApplicationHandler; + +add_setup(async function () { + let handler = gExternalProtocolService.getProtocolHandlerInfo("mailto", {}); + + // Create a fake mail handler + const APP_NAME = "ExMail"; + const HANDLER_URL = "https://example.com/?extsrc=mailto&url=%s"; + let app = Cc["@mozilla.org/uriloader/web-handler-app;1"].createInstance( + Ci.nsIWebHandlerApp + ); + app.uriTemplate = HANDLER_URL; + app.name = APP_NAME; + + // Store defaults + prevAlwaysAskBeforeHandling = handler.alwaysAskBeforeHandling; + prevPreferredAction = handler.preferredAction; + prevPreferredApplicationHandler = handler.preferredApplicationHandler; + + // Set the fake app as default + handler.alwaysAskBeforeHandling = false; + handler.preferredAction = Ci.nsIHandlerInfo.useHelperApp; + handler.preferredApplicationHandler = app; + gHandlerService.store(handler); +}); + +registerCleanupFunction(async function () { + let handler = gExternalProtocolService.getProtocolHandlerInfo("mailto", {}); + handler.alwaysAskBeforeHandling = prevAlwaysAskBeforeHandling; + handler.preferredAction = prevPreferredAction; + handler.preferredApplicationHandler = prevPreferredApplicationHandler; + gHandlerService.store(handler); +}); + +add_task(async function () { + const expectedURL = + "https://example.com/?extsrc=mailto&url=mailto%3Amail%40example.com"; + + // Load a page with mailto handler. + let browser = gBrowser.selectedBrowser; + BrowserTestUtils.loadURIString(browser, testURL); + await BrowserTestUtils.browserLoaded(browser, false, testURL); + + // Pin as an app tab + gBrowser.pinTab(gBrowser.selectedTab); + + // Click the link and check the new tab is correct + let promiseTabOpened = BrowserTestUtils.waitForNewTab(gBrowser, expectedURL); + await BrowserTestUtils.synthesizeMouseAtCenter("#link", {}, browser); + let tab = await promiseTabOpened; + is( + gURLBar.value, + expectedURL, + "the mailto web handler is opened in a new tab" + ); + BrowserTestUtils.removeTab(tab); +}); diff --git a/uriloader/exthandler/tests/mochitest/browser_web_protocol_handlers.js b/uriloader/exthandler/tests/mochitest/browser_web_protocol_handlers.js new file mode 100644 index 0000000000..8046629219 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/browser_web_protocol_handlers.js @@ -0,0 +1,124 @@ +let testURL = + "https://example.com/browser/" + + "uriloader/exthandler/tests/mochitest/protocolHandler.html"; + +add_task(async function () { + await SpecialPowers.pushPrefEnv({ + set: [["security.external_protocol_requires_permission", false]], + }); + + // Load a page registering a protocol handler. + let browser = gBrowser.selectedBrowser; + BrowserTestUtils.loadURIString(browser, testURL); + await BrowserTestUtils.browserLoaded(browser, false, testURL); + + // Register the protocol handler by clicking the notificationbar button. + let notificationValue = "Protocol Registration: web+testprotocol"; + let getNotification = () => + gBrowser.getNotificationBox().getNotificationWithValue(notificationValue); + await BrowserTestUtils.waitForCondition(getNotification); + let notification = getNotification(); + let button = notification.buttonContainer.querySelector("button"); + ok(button, "got registration button"); + button.click(); + + // Set the new handler as default. + const protoSvc = Cc[ + "@mozilla.org/uriloader/external-protocol-service;1" + ].getService(Ci.nsIExternalProtocolService); + let protoInfo = protoSvc.getProtocolHandlerInfo("web+testprotocol"); + is( + protoInfo.preferredAction, + protoInfo.useHelperApp, + "using a helper application is the preferred action" + ); + ok(!protoInfo.preferredApplicationHandler, "no preferred handler is set"); + let handlers = protoInfo.possibleApplicationHandlers; + is(1, handlers.length, "only one handler registered for web+testprotocol"); + let handler = handlers.queryElementAt(0, Ci.nsIHandlerApp); + ok(handler instanceof Ci.nsIWebHandlerApp, "the handler is a web handler"); + is( + handler.uriTemplate, + "https://example.com/foobar?uri=%s", + "correct url template" + ); + protoInfo.preferredApplicationHandler = handler; + protoInfo.alwaysAskBeforeHandling = false; + const handlerSvc = Cc["@mozilla.org/uriloader/handler-service;1"].getService( + Ci.nsIHandlerService + ); + handlerSvc.store(protoInfo); + + const expectedURL = + "https://example.com/foobar?uri=web%2Btestprotocol%3Atest"; + + // Create a framed link: + await SpecialPowers.spawn(browser, [], async function () { + let iframe = content.document.createElement("iframe"); + iframe.src = `data:text/html,<a href="web+testprotocol:test">Click me</a>`; + content.document.body.append(iframe); + // Can't return this promise because it resolves to the event object. + await ContentTaskUtils.waitForEvent(iframe, "load"); + iframe.contentDocument.querySelector("a").click(); + }); + let kidContext = browser.browsingContext.children[0]; + await TestUtils.waitForCondition(() => { + let spec = kidContext.currentWindowGlobal?.documentURI?.spec || ""; + return spec == expectedURL; + }); + is( + kidContext.currentWindowGlobal.documentURI.spec, + expectedURL, + "Should load in frame." + ); + + // Middle-click a testprotocol link and check the new tab is correct + let link = "#link"; + + let promiseTabOpened = BrowserTestUtils.waitForNewTab(gBrowser, expectedURL); + await BrowserTestUtils.synthesizeMouseAtCenter(link, { button: 1 }, browser); + let tab = await promiseTabOpened; + gBrowser.selectedTab = tab; + is( + gURLBar.value, + expectedURL, + "the expected URL is displayed in the location bar" + ); + BrowserTestUtils.removeTab(tab); + + // Shift-click the testprotocol link and check the new window. + let newWindowPromise = BrowserTestUtils.waitForNewWindow({ + url: expectedURL, + }); + await BrowserTestUtils.synthesizeMouseAtCenter( + link, + { shiftKey: true }, + browser + ); + let win = await newWindowPromise; + await BrowserTestUtils.waitForCondition( + () => win.gBrowser.currentURI.spec == expectedURL + ); + is( + win.gURLBar.value, + expectedURL, + "the expected URL is displayed in the location bar" + ); + await BrowserTestUtils.closeWindow(win); + + // Click the testprotocol link and check the url in the current tab. + let loadPromise = BrowserTestUtils.browserLoaded(browser); + await BrowserTestUtils.synthesizeMouseAtCenter(link, {}, browser); + await loadPromise; + await BrowserTestUtils.waitForCondition(() => gURLBar.value != testURL); + is( + gURLBar.value, + expectedURL, + "the expected URL is displayed in the location bar" + ); + + // Cleanup. + protoInfo.preferredApplicationHandler = null; + handlers.removeElementAt(0); + handlerSvc.store(protoInfo); +}); diff --git a/uriloader/exthandler/tests/mochitest/download.bin b/uriloader/exthandler/tests/mochitest/download.bin new file mode 100644 index 0000000000..0e4b0c7bae --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/download.bin @@ -0,0 +1 @@ +abc123 diff --git a/uriloader/exthandler/tests/mochitest/download.sjs b/uriloader/exthandler/tests/mochitest/download.sjs new file mode 100644 index 0000000000..d02d2b7355 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/download.sjs @@ -0,0 +1,42 @@ +"use strict"; + +function actuallyHandleRequest(req, res) { + res.setHeader("Content-Type", "application/octet-stream", false); + res.write("abc123"); + res.finish(); +} + +function handleRequest(req, res) { + if (req.queryString.includes("finish")) { + res.write("OK"); + let downloadReq = null; + getObjectState("downloadReq", o => { + downloadReq = o; + }); + // Two possibilities: either the download request has already reached us, or not. + if (downloadReq) { + downloadReq.wrappedJSObject.callback(); + } else { + // Set a variable to allow the request to complete immediately: + setState("finishReq", "true"); + } + } else if (req.queryString.includes("reset")) { + res.write("OK"); + setObjectState("downloadReq", null); + setState("finishReq", "false"); + } else { + res.processAsync(); + if (getState("finishReq") === "true") { + actuallyHandleRequest(req, res); + } else { + let o = { + callback() { + actuallyHandleRequest(req, res); + }, + }; + o.wrappedJSObject = o; + o.QueryInterface = () => o; + setObjectState("downloadReq", o); + } + } +} diff --git a/uriloader/exthandler/tests/mochitest/download_page.html b/uriloader/exthandler/tests/mochitest/download_page.html new file mode 100644 index 0000000000..5a264888fa --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/download_page.html @@ -0,0 +1,22 @@ +<!-- Any copyright is dedicated to the Public Domain. + - http://creativecommons.org/publicdomain/zero/1.0/ --> +<!DOCTYPE html> +<html> +<head> + <meta charset=UTF-8> + <title>Test page for link clicking</title> + <script type="text/javascript"> + function launch_download(extra) { + window.open("download.sjs", "_blank", "height=100,width=100" + extra); + } + </script> +</head> +<body> + <a href="download.bin" id="regular_load">regular load</a> + <a href="download.bin" id="target_blank" target="_blank" rel="opener">target blank</a> + <a href="#" onclick="launch_download(''); return false" id="new_window">new window</a> + <a href="#" onclick="window.open('download_page.html?newwin'); return false" id="open_in_new_tab">click to reopen</a> + <a href="download.bin" id="target_blank_no_opener" rel="noopener" target="_blank">target blank (noopener)</a> + <a href="#" onclick="window.open('download.bin', '_blank', 'noopener'); return false" id="open_in_new_tab_no_opener">click to reopen (noopener)</a> + <a href="#" onclick="launch_download(',noopener'); return false" id="new_window_no_opener">new window (noopener)</a> +</body> diff --git a/uriloader/exthandler/tests/mochitest/file_as.exe b/uriloader/exthandler/tests/mochitest/file_as.exe new file mode 100644 index 0000000000..f2f5ab47f3 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/file_as.exe @@ -0,0 +1 @@ +Not actually an executable... but let's pretend! diff --git a/uriloader/exthandler/tests/mochitest/file_as.exe^headers^ b/uriloader/exthandler/tests/mochitest/file_as.exe^headers^ new file mode 100644 index 0000000000..89f22e30be --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/file_as.exe^headers^ @@ -0,0 +1,2 @@ +Content-Type: binary/octet-stream +Content-Disposition: attachment diff --git a/uriloader/exthandler/tests/mochitest/file_external_protocol_iframe.html b/uriloader/exthandler/tests/mochitest/file_external_protocol_iframe.html new file mode 100644 index 0000000000..eb2fb74441 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/file_external_protocol_iframe.html @@ -0,0 +1 @@ +<iframe src="mailto:foo@bar.com"></iframe> diff --git a/uriloader/exthandler/tests/mochitest/file_green.webp b/uriloader/exthandler/tests/mochitest/file_green.webp Binary files differnew file mode 100644 index 0000000000..04b7f003b4 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/file_green.webp diff --git a/uriloader/exthandler/tests/mochitest/file_green.webp^headers^ b/uriloader/exthandler/tests/mochitest/file_green.webp^headers^ new file mode 100644 index 0000000000..3f6afd6625 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/file_green.webp^headers^ @@ -0,0 +1,3 @@ +Content-Disposition: attachment; filename=file_green.webp +Content-Type: image/webp + diff --git a/uriloader/exthandler/tests/mochitest/file_image_svgxml.svg b/uriloader/exthandler/tests/mochitest/file_image_svgxml.svg new file mode 100644 index 0000000000..b730c4c492 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/file_image_svgxml.svg @@ -0,0 +1,3 @@ +<svg version="1.1" xmlns="http://www.w3.org/2000/svg"> + <rect x="5" y="5" width="10" height="10" fill="white"/> +</svg> diff --git a/uriloader/exthandler/tests/mochitest/file_image_svgxml.svg^headers^ b/uriloader/exthandler/tests/mochitest/file_image_svgxml.svg^headers^ new file mode 100644 index 0000000000..5279ae8636 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/file_image_svgxml.svg^headers^ @@ -0,0 +1,2 @@ +content-disposition: attachment; filename=file_image_svgxml_svg; filename*=UTF-8''file_image_svgxml.svg +content-type: image/svg+xml diff --git a/uriloader/exthandler/tests/mochitest/file_nested_protocol_request.html b/uriloader/exthandler/tests/mochitest/file_nested_protocol_request.html new file mode 100644 index 0000000000..b1bb863f89 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/file_nested_protocol_request.html @@ -0,0 +1 @@ +<iframe srcdoc="<a href='mailto:help@example.com'>Mail someone</a>"></iframe> diff --git a/uriloader/exthandler/tests/mochitest/file_pdf_application_octet_stream.pdf b/uriloader/exthandler/tests/mochitest/file_pdf_application_octet_stream.pdf new file mode 100644 index 0000000000..e69de29bb2 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/file_pdf_application_octet_stream.pdf diff --git a/uriloader/exthandler/tests/mochitest/file_pdf_application_octet_stream.pdf^headers^ b/uriloader/exthandler/tests/mochitest/file_pdf_application_octet_stream.pdf^headers^ new file mode 100644 index 0000000000..9e8cb41cba --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/file_pdf_application_octet_stream.pdf^headers^ @@ -0,0 +1,2 @@ +Content-Disposition: attachment; filename="file_pdf_application_octet_stream.pdf"; filename*=UTF-8''file_pdf_application_octet_stream.pdf +Content-Type: application/octet-stream diff --git a/uriloader/exthandler/tests/mochitest/file_pdf_application_pdf.pdf b/uriloader/exthandler/tests/mochitest/file_pdf_application_pdf.pdf new file mode 100644 index 0000000000..e69de29bb2 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/file_pdf_application_pdf.pdf diff --git a/uriloader/exthandler/tests/mochitest/file_pdf_application_pdf.pdf^headers^ b/uriloader/exthandler/tests/mochitest/file_pdf_application_pdf.pdf^headers^ new file mode 100644 index 0000000000..d1d59b9754 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/file_pdf_application_pdf.pdf^headers^ @@ -0,0 +1,2 @@ +content-disposition: attachment; filename=file_pdf_application_pdf.pdf; filename*=UTF-8''file_pdf_application_pdf.pdf +content-type: application/pdf diff --git a/uriloader/exthandler/tests/mochitest/file_pdf_application_unknown.pdf b/uriloader/exthandler/tests/mochitest/file_pdf_application_unknown.pdf new file mode 100644 index 0000000000..e69de29bb2 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/file_pdf_application_unknown.pdf diff --git a/uriloader/exthandler/tests/mochitest/file_pdf_application_unknown.pdf^headers^ b/uriloader/exthandler/tests/mochitest/file_pdf_application_unknown.pdf^headers^ new file mode 100644 index 0000000000..157c0e0943 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/file_pdf_application_unknown.pdf^headers^ @@ -0,0 +1,2 @@ +content-disposition: attachment; filename=file_pdf_application_unknown.pdf; filename*=UTF-8''file_pdf_application_unknown.pdf +content-type: application/unknown diff --git a/uriloader/exthandler/tests/mochitest/file_pdf_binary_octet_stream.pdf b/uriloader/exthandler/tests/mochitest/file_pdf_binary_octet_stream.pdf new file mode 100644 index 0000000000..e69de29bb2 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/file_pdf_binary_octet_stream.pdf diff --git a/uriloader/exthandler/tests/mochitest/file_pdf_binary_octet_stream.pdf^headers^ b/uriloader/exthandler/tests/mochitest/file_pdf_binary_octet_stream.pdf^headers^ new file mode 100644 index 0000000000..6358f54f48 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/file_pdf_binary_octet_stream.pdf^headers^ @@ -0,0 +1,2 @@ +Content-Disposition: attachment; filename="file_pdf_binary_octet_stream.pdf"; filename*=UTF-8''file_pdf_binary_octet_stream.pdf +Content-Type: binary/octet-stream diff --git a/uriloader/exthandler/tests/mochitest/file_txt_attachment_test.txt b/uriloader/exthandler/tests/mochitest/file_txt_attachment_test.txt new file mode 100644 index 0000000000..e69de29bb2 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/file_txt_attachment_test.txt diff --git a/uriloader/exthandler/tests/mochitest/file_txt_attachment_test.txt^headers^ b/uriloader/exthandler/tests/mochitest/file_txt_attachment_test.txt^headers^ new file mode 100644 index 0000000000..dcfed6af23 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/file_txt_attachment_test.txt^headers^ @@ -0,0 +1,2 @@ +Content-Disposition: attachment; filename=file_txt_attachment_test.txt +Content-Type: text/plain diff --git a/uriloader/exthandler/tests/mochitest/file_with@@funny_name.png b/uriloader/exthandler/tests/mochitest/file_with@@funny_name.png Binary files differnew file mode 100644 index 0000000000..743292dc6f --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/file_with@@funny_name.png diff --git a/uriloader/exthandler/tests/mochitest/file_with@@funny_name.png^headers^ b/uriloader/exthandler/tests/mochitest/file_with@@funny_name.png^headers^ new file mode 100644 index 0000000000..06e0cd957f --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/file_with@@funny_name.png^headers^ @@ -0,0 +1,2 @@ +Content-Disposition: inline; filename=file_with%40%40funny_name.png +Content-Type: image/png diff --git a/uriloader/exthandler/tests/mochitest/file_with[funny_name.webm b/uriloader/exthandler/tests/mochitest/file_with[funny_name.webm Binary files differnew file mode 100644 index 0000000000..7bc738b8b4 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/file_with[funny_name.webm diff --git a/uriloader/exthandler/tests/mochitest/file_with[funny_name.webm^headers^ b/uriloader/exthandler/tests/mochitest/file_with[funny_name.webm^headers^ new file mode 100644 index 0000000000..b77e9d3687 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/file_with[funny_name.webm^headers^ @@ -0,0 +1,2 @@ +Content-Disposition: inline; filename=file_with%5Bfunny_name.webm +Content-Type: video/webm diff --git a/uriloader/exthandler/tests/mochitest/file_xml_attachment_binary_octet_stream.xml b/uriloader/exthandler/tests/mochitest/file_xml_attachment_binary_octet_stream.xml new file mode 100644 index 0000000000..3a5792586a --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/file_xml_attachment_binary_octet_stream.xml @@ -0,0 +1,4 @@ +<?xml version = "1.0" encoding = "utf-8"?> + +<something> +</something> diff --git a/uriloader/exthandler/tests/mochitest/file_xml_attachment_binary_octet_stream.xml^headers^ b/uriloader/exthandler/tests/mochitest/file_xml_attachment_binary_octet_stream.xml^headers^ new file mode 100644 index 0000000000..5bdc4448e8 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/file_xml_attachment_binary_octet_stream.xml^headers^ @@ -0,0 +1,2 @@ +Content-Disposition: attachment +Content-Type: binary/octet-stream diff --git a/uriloader/exthandler/tests/mochitest/file_xml_attachment_test.xml b/uriloader/exthandler/tests/mochitest/file_xml_attachment_test.xml new file mode 100644 index 0000000000..3a5792586a --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/file_xml_attachment_test.xml @@ -0,0 +1,4 @@ +<?xml version = "1.0" encoding = "utf-8"?> + +<something> +</something> diff --git a/uriloader/exthandler/tests/mochitest/file_xml_attachment_test.xml^headers^ b/uriloader/exthandler/tests/mochitest/file_xml_attachment_test.xml^headers^ new file mode 100644 index 0000000000..ac0355d976 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/file_xml_attachment_test.xml^headers^ @@ -0,0 +1,2 @@ +Content-Disposition: attachment; filename=file_xml_attachment_test.xml +Content-Type: text/xml diff --git a/uriloader/exthandler/tests/mochitest/head.js b/uriloader/exthandler/tests/mochitest/head.js new file mode 100644 index 0000000000..183aeee20e --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/head.js @@ -0,0 +1,535 @@ +var { FileUtils } = ChromeUtils.importESModule( + "resource://gre/modules/FileUtils.sys.mjs" +); +var { HandlerServiceTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/HandlerServiceTestUtils.sys.mjs" +); + +var gMimeSvc = Cc["@mozilla.org/mime;1"].getService(Ci.nsIMIMEService); +var gHandlerSvc = Cc["@mozilla.org/uriloader/handler-service;1"].getService( + Ci.nsIHandlerService +); + +function createMockedHandlerApp() { + // Mock the executable + let mockedExecutable = FileUtils.getFile("TmpD", ["mockedExecutable"]); + if (!mockedExecutable.exists()) { + mockedExecutable.create(Ci.nsIFile.NORMAL_FILE_TYPE, 0o755); + } + + // Mock the handler app + let mockedHandlerApp = Cc[ + "@mozilla.org/uriloader/local-handler-app;1" + ].createInstance(Ci.nsILocalHandlerApp); + mockedHandlerApp.executable = mockedExecutable; + mockedHandlerApp.detailedDescription = "Mocked handler app"; + + registerCleanupFunction(function () { + // remove the mocked executable from disk. + if (mockedExecutable.exists()) { + mockedExecutable.remove(true); + } + }); + + return mockedHandlerApp; +} + +function createMockedObjects(createHandlerApp) { + // Mock the mime info + let internalMockedMIME = gMimeSvc.getFromTypeAndExtension( + "text/x-test-handler", + null + ); + internalMockedMIME.alwaysAskBeforeHandling = true; + internalMockedMIME.preferredAction = Ci.nsIHandlerInfo.useHelperApp; + internalMockedMIME.appendExtension("abc"); + if (createHandlerApp) { + let mockedHandlerApp = createMockedHandlerApp(); + internalMockedMIME.description = mockedHandlerApp.detailedDescription; + internalMockedMIME.possibleApplicationHandlers.appendElement( + mockedHandlerApp + ); + internalMockedMIME.preferredApplicationHandler = mockedHandlerApp; + } + + // Proxy for the mocked MIME info for faking the read-only attributes + let mockedMIME = new Proxy(internalMockedMIME, { + get(target, property) { + switch (property) { + case "hasDefaultHandler": + return true; + case "defaultDescription": + return "Default description"; + default: + return target[property]; + } + }, + }); + + // Mock the launcher: + let mockedLauncher = { + MIMEInfo: mockedMIME, + source: Services.io.newURI("http://www.mozilla.org/"), + suggestedFileName: "test_download_dialog.abc", + targetFileIsExecutable: false, + saveToDisk() {}, + cancel() {}, + setDownloadToLaunch() {}, + setWebProgressListener() {}, + saveDestinationAvailable() {}, + contentLength: 42, + targetFile: null, // never read + // PRTime is microseconds since epoch, Date.now() returns milliseconds: + timeDownloadStarted: Date.now() * 1000, + QueryInterface: ChromeUtils.generateQI([ + "nsICancelable", + "nsIHelperAppLauncher", + ]), + }; + + registerCleanupFunction(function () { + // remove the mocked mime info from database. + let mockHandlerInfo = gMimeSvc.getFromTypeAndExtension( + "text/x-test-handler", + null + ); + if (gHandlerSvc.exists(mockHandlerInfo)) { + gHandlerSvc.remove(mockHandlerInfo); + } + }); + + return mockedLauncher; +} + +function createTemporarySaveDirectory() { + var saveDir = Services.dirsvc.get("TmpD", Ci.nsIFile); + saveDir.append("testsavedir"); + if (!saveDir.exists()) { + saveDir.create(Ci.nsIFile.DIRECTORY_TYPE, 0o755); + } + return saveDir; +} + +async function openHelperAppDialog(launcher) { + let helperAppDialog = Cc[ + "@mozilla.org/helperapplauncherdialog;1" + ].createInstance(Ci.nsIHelperAppLauncherDialog); + + let helperAppDialogShownPromise = BrowserTestUtils.domWindowOpenedAndLoaded(); + try { + helperAppDialog.show(launcher, window, "foopy"); + } catch (ex) { + ok( + false, + "Trying to show unknownContentType.xhtml failed with exception: " + ex + ); + console.error(ex); + } + let dlg = await helperAppDialogShownPromise; + + is( + dlg.location.href, + "chrome://mozapps/content/downloads/unknownContentType.xhtml", + "Got correct dialog" + ); + + return dlg; +} + +/** + * Wait for a subdialog event indicating a dialog either opened + * or was closed. + * + * First argument is the browser in which to listen. If a tabbrowser, + * we listen to subdialogs for any tab of that browser. + */ +async function waitForSubDialog(browser, url, state) { + let eventStr = state ? "dialogopen" : "dialogclose"; + + let eventTarget; + + // Tabbrowser? + if (browser.tabContainer) { + eventTarget = browser.tabContainer.ownerDocument.documentElement; + } else { + // Individual browser. Get its box: + let tabDialogBox = browser.ownerGlobal.gBrowser.getTabDialogBox(browser); + eventTarget = tabDialogBox.getTabDialogManager()._dialogStack; + } + + let checkFn; + + if (state) { + checkFn = dialogEvent => dialogEvent.detail.dialog?._openedURL == url; + } + + let event = await BrowserTestUtils.waitForEvent( + eventTarget, + eventStr, + true, + checkFn + ); + + let { dialog } = event.detail; + + // If the dialog is closing wait for it to be fully closed before resolving + if (!state) { + await dialog._closingPromise; + } + + return event.detail.dialog; +} + +/** + * Wait for protocol permission dialog open/close. + * @param {MozBrowser} browser - Browser element the dialog belongs to. + * @param {boolean} state - true: dialog open, false: dialog close + * @returns {Promise<SubDialog>} - Returns a promise which resolves with the + * SubDialog object of the dialog which closed or opened. + */ +async function waitForProtocolPermissionDialog(browser, state) { + return waitForSubDialog( + browser, + "chrome://mozapps/content/handling/permissionDialog.xhtml", + state + ); +} + +/** + * Wait for protocol app chooser dialog open/close. + * @param {MozBrowser} browser - Browser element the dialog belongs to. + * @param {boolean} state - true: dialog open, false: dialog close + * @returns {Promise<SubDialog>} - Returns a promise which resolves with the + * SubDialog object of the dialog which closed or opened. + */ +async function waitForProtocolAppChooserDialog(browser, state) { + return waitForSubDialog( + browser, + "chrome://mozapps/content/handling/appChooser.xhtml", + state + ); +} + +async function promiseDownloadFinished(list, stopFromOpening) { + return new Promise(resolve => { + list.addView({ + onDownloadChanged(download) { + if (stopFromOpening) { + download.launchWhenSucceeded = false; + } + info("Download changed!"); + if (download.succeeded || download.error) { + info("Download succeeded or errored"); + list.removeView(this); + resolve(download); + } + }, + }); + }); +} + +function setupMailHandler() { + let mailHandlerInfo = HandlerServiceTestUtils.getHandlerInfo("mailto"); + let gOldMailHandlers = []; + + // Remove extant web handlers because they have icons that + // we fetch from the web, which isn't allowed in tests. + let handlers = mailHandlerInfo.possibleApplicationHandlers; + for (let i = handlers.Count() - 1; i >= 0; i--) { + try { + let handler = handlers.queryElementAt(i, Ci.nsIWebHandlerApp); + gOldMailHandlers.push(handler); + // If we get here, this is a web handler app. Remove it: + handlers.removeElementAt(i); + } catch (ex) {} + } + + let previousHandling = mailHandlerInfo.alwaysAskBeforeHandling; + mailHandlerInfo.alwaysAskBeforeHandling = true; + + // Create a dummy web mail handler so we always know the mailto: protocol. + // Without this, the test fails on VMs without a default mailto: handler, + // because no dialog is ever shown, as we ignore subframe navigations to + // protocols that cannot be handled. + let dummy = Cc["@mozilla.org/uriloader/web-handler-app;1"].createInstance( + Ci.nsIWebHandlerApp + ); + dummy.name = "Handler 1"; + dummy.uriTemplate = "https://example.com/first/%s"; + mailHandlerInfo.possibleApplicationHandlers.appendElement(dummy); + + gHandlerSvc.store(mailHandlerInfo); + registerCleanupFunction(() => { + // Re-add the original protocol handlers: + let mailHandlers = mailHandlerInfo.possibleApplicationHandlers; + for (let i = handlers.Count() - 1; i >= 0; i--) { + try { + // See if this is a web handler. If it is, it'll throw, otherwise, + // we will remove it. + mailHandlers.queryElementAt(i, Ci.nsIWebHandlerApp); + mailHandlers.removeElementAt(i); + } catch (ex) {} + } + for (let h of gOldMailHandlers) { + mailHandlers.appendElement(h); + } + mailHandlerInfo.alwaysAskBeforeHandling = previousHandling; + gHandlerSvc.store(mailHandlerInfo); + }); +} + +let gDownloadDir; + +async function setDownloadDir() { + let tmpDir = PathUtils.join( + PathUtils.tempDir, + "testsavedir" + Math.floor(Math.random() * 2 ** 32) + ); + // Create this dir if it doesn't exist (ignores existing dirs) + await IOUtils.makeDirectory(tmpDir); + registerCleanupFunction(async function () { + Services.prefs.clearUserPref("browser.download.folderList"); + Services.prefs.clearUserPref("browser.download.dir"); + try { + await IOUtils.remove(tmpDir, { recursive: true }); + } catch (e) { + console.error(e); + } + }); + Services.prefs.setIntPref("browser.download.folderList", 2); + Services.prefs.setCharPref("browser.download.dir", tmpDir); + return tmpDir; +} + +add_setup(async function test_common_initialize() { + gDownloadDir = await setDownloadDir(); + Services.prefs.setCharPref("browser.download.loglevel", "Debug"); + registerCleanupFunction(function () { + Services.prefs.clearUserPref("browser.download.loglevel"); + }); +}); + +async function removeAllDownloads() { + let publicList = await Downloads.getList(Downloads.PUBLIC); + let downloads = await publicList.getAll(); + for (let download of downloads) { + await publicList.remove(download); + if (await IOUtils.exists(download.target.path)) { + await download.finalize(true); + } + } +} + +// Helpers for external protocol sandbox tests. +const EXT_PROTO_URI_MAILTO = "mailto:test@example.com"; + +/** + * Creates and iframe and navigate to an external protocol from the iframe. + * @param {MozBrowser} browser - Browser to spawn iframe in. + * @param {string} sandboxAttr - Sandbox attribute value for the iframe. + * @param {'trustedClick'|'untrustedClick'|'trustedLocationAPI'|'untrustedLocationAPI'|'frameSrc'|'frameSrcRedirect'} triggerMethod + * - How to trigger the navigation to the external protocol. + */ +async function navigateExternalProtoFromIframe( + browser, + sandboxAttr, + useCSPSandbox = false, + triggerMethod = "trustedClick" +) { + if ( + ![ + "trustedClick", + "untrustedClick", + "trustedLocationAPI", + "untrustedLocationAPI", + "frameSrc", + "frameSrcRedirect", + ].includes(triggerMethod) + ) { + throw new Error("Invalid trigger method " + triggerMethod); + } + + // Construct the url to use as iframe src. + let testPath = getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "https://example.com" + ); + let frameSrc = testPath + "/protocol_custom_sandbox_helper.sjs"; + + // Load the external protocol directly via the frame src field. + if (triggerMethod == "frameSrc") { + frameSrc = EXT_PROTO_URI_MAILTO; + } else if (triggerMethod == "frameSrcRedirect") { + let url = new URL(frameSrc); + url.searchParams.set("redirectCustomProtocol", "true"); + frameSrc = url.href; + } + + // If enabled set the sandbox attributes via CSP header instead. To do + // this we need to pass the sandbox flags to the test server via query + // params. + if (useCSPSandbox) { + let url = new URL(frameSrc); + url.searchParams.set("cspSandbox", sandboxAttr); + frameSrc = url.href; + + // If we use CSP sandbox attributes we shouldn't set any via iframe attribute. + sandboxAttr = null; + } + + // Create a sandboxed iframe and navigate to the external protocol. + await SpecialPowers.spawn( + browser, + [sandboxAttr, frameSrc, EXT_PROTO_URI_MAILTO, triggerMethod], + async (sandbox, src, extProtoURI, trigger) => { + let frame = content.document.createElement("iframe"); + + if (sandbox != null) { + frame.sandbox = sandbox; + } + + frame.src = src; + + let useFrameSrc = trigger == "frameSrc" || trigger == "frameSrcRedirect"; + + // Create frame load promise. + let frameLoadPromise; + // We won't get a load event if we directly put the external protocol in + // the frame src. + if (!useFrameSrc) { + frameLoadPromise = ContentTaskUtils.waitForEvent(frame, "load", false); + } + + content.document.body.appendChild(frame); + await frameLoadPromise; + + if (!useFrameSrc) { + // Trigger the external protocol navigation in the iframe. We test + // navigation by clicking links and navigation via the history API. + await SpecialPowers.spawn( + frame, + [extProtoURI, trigger], + async (uri, trigger2) => { + let link = content.document.createElement("a"); + link.innerText = "CLICK ME"; + link.id = "extProtoLink"; + content.document.body.appendChild(link); + + if (trigger2 == "trustedClick" || trigger2 == "untrustedClick") { + link.href = uri; + } else if ( + trigger2 == "trustedLocationAPI" || + trigger2 == "untrustedLocationAPI" + ) { + link.setAttribute("onclick", `location.href = '${uri}'`); + } + + if ( + trigger2 == "untrustedClick" || + trigger2 == "untrustedLocationAPI" + ) { + link.click(); + } else if ( + trigger2 == "trustedClick" || + trigger2 == "trustedLocationAPI" + ) { + await ContentTaskUtils.waitForCondition( + () => link, + "wait for link to be present" + ); + await EventUtils.synthesizeMouseAtCenter(link, {}, content); + } + } + ); + } + } + ); +} + +/** + * Wait for the sandbox error message which is shown in the web console when an + * external protocol navigation from a sandboxed context is blocked. + * @returns {Promise} - Promise which resolves once message has been logged. + */ +function waitForExtProtocolSandboxError() { + return new Promise(resolve => { + Services.console.registerListener(function onMessage(msg) { + let { message, logLevel } = msg; + if (logLevel != Ci.nsIConsoleMessage.error) { + return; + } + if ( + !message.includes( + `Blocked navigation to custom protocol “${EXT_PROTO_URI_MAILTO}” from a sandboxed context.` + ) + ) { + return; + } + Services.console.unregisterListener(onMessage); + resolve(); + }); + }); +} + +/** + * Run the external protocol sandbox test using iframes. + * @param {Object} options + * @param {boolean} options.blocked - Whether the navigation should be blocked. + * @param {string} options.sandbox - See {@link navigateExternalProtoFromIframe}. + * @param {string} options.useCSPSandbox - See {@link navigateExternalProtoFromIframe}. + * @param {string} options.triggerMethod - See {@link navigateExternalProtoFromIframe}. + * @returns {Promise} - Promise which resolves once the test has finished. + */ +function runExtProtocolSandboxTest(options) { + let { blocked, sandbox, useCSPSandbox = false, triggerMethod } = options; + + let testPath = getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "https://example.com" + ); + + info("runSandboxTest options: " + JSON.stringify(options)); + return BrowserTestUtils.withNewTab( + testPath + "/protocol_custom_sandbox_helper.sjs", + async browser => { + if (blocked) { + let errorPromise = waitForExtProtocolSandboxError(); + await navigateExternalProtoFromIframe( + browser, + sandbox, + useCSPSandbox, + triggerMethod + ); + await errorPromise; + + ok( + errorPromise, + "Should not show the dialog for iframe with sandbox " + sandbox + ); + } else { + let dialogWindowOpenPromise = waitForProtocolAppChooserDialog( + browser, + true + ); + await navigateExternalProtoFromIframe( + browser, + sandbox, + useCSPSandbox, + triggerMethod + ); + let dialog = await dialogWindowOpenPromise; + + ok(dialog, "Should show the dialog for sandbox " + sandbox); + + // Close dialog before closing the tab to avoid intermittent failures. + let dialogWindowClosePromise = waitForProtocolAppChooserDialog( + browser, + false + ); + + dialog.close(); + await dialogWindowClosePromise; + } + } + ); +} diff --git a/uriloader/exthandler/tests/mochitest/invalidCharFileExtension.sjs b/uriloader/exthandler/tests/mochitest/invalidCharFileExtension.sjs new file mode 100644 index 0000000000..8afa04cfe0 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/invalidCharFileExtension.sjs @@ -0,0 +1,18 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +function handleRequest(request, response) { + response.setStatusLine(request.httpVersion, 200, "OK"); + + if (!request.queryString.match(/^name=/)) { + return; + } + var name = decodeURIComponent(request.queryString.substring(5)); + + response.setHeader("Content-Type", 'image/png; name="' + name + '"'); + response.setHeader( + "Content-Disposition", + 'attachment; filename="' + name + '"' + ); +} diff --git a/uriloader/exthandler/tests/mochitest/mailto.html b/uriloader/exthandler/tests/mochitest/mailto.html new file mode 100644 index 0000000000..d507697443 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/mailto.html @@ -0,0 +1,11 @@ +<!DOCTYPE html> +<html> + <head> + <title>Mailto handler</title> + <meta content="text/html;charset=utf-8" http-equiv="Content-Type"> + <meta content="utf-8" http-equiv="encoding"> + </head> + <body> + <a id="link" href="mailto:mail@example.com">mailto link</a> + </body> +</html> diff --git a/uriloader/exthandler/tests/mochitest/mime_type_download.sjs b/uriloader/exthandler/tests/mochitest/mime_type_download.sjs new file mode 100644 index 0000000000..a33331d0cf --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/mime_type_download.sjs @@ -0,0 +1,21 @@ +function handleRequest(request, response) { + "use strict"; + Cu.importGlobalProperties(["URLSearchParams"]); + let content = ""; + let params = new URLSearchParams(request.queryString); + let extension = params.get("extension"); + let contentType = params.get("contentType"); + if (params.has("withHeader")) { + response.setHeader( + "Content-Disposition", + `attachment; filename="mime_type_download${ + extension ? "." + extension : "" + }";`, + false + ); + } + response.setHeader("Content-Type", contentType, false); + response.setHeader("Content-Length", "" + content.length, false); + response.setStatusLine(request.httpVersion, 200); + response.write(content); +} diff --git a/uriloader/exthandler/tests/mochitest/mochitest.ini b/uriloader/exthandler/tests/mochitest/mochitest.ini new file mode 100644 index 0000000000..c2ab1f5099 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/mochitest.ini @@ -0,0 +1,17 @@ +[test_invalidCharFileExtension.xhtml] +skip-if = toolkit == 'android' # Bug 1525959 +support-files = + HelperAppLauncherDialog_chromeScript.js + invalidCharFileExtension.sjs +[test_nullCharFile.xhtml] +skip-if = toolkit == 'android' # Bug 1525959 +support-files = + HelperAppLauncherDialog_chromeScript.js +[test_unknown_ext_protocol_handlers.html] +[test_unsafeBidiChars.xhtml] +skip-if = + toolkit == 'android' # Bug 1525959 + http3 +support-files = + HelperAppLauncherDialog_chromeScript.js + unsafeBidiFileName.sjs diff --git a/uriloader/exthandler/tests/mochitest/protocolHandler.html b/uriloader/exthandler/tests/mochitest/protocolHandler.html new file mode 100644 index 0000000000..eff8a53aab --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/protocolHandler.html @@ -0,0 +1,16 @@ +<!DOCTYPE html> +<html> + <head> + <title>Protocol handler</title> + <meta content="text/html;charset=utf-8" http-equiv="Content-Type"> + <meta content="utf-8" http-equiv="encoding"> + </head> + <body> + <script type="text/javascript"> + navigator.registerProtocolHandler("web+testprotocol", + "https://example.com/foobar?uri=%s", + "Test Protocol"); + </script> + <a id="link" href="web+testprotocol:test">testprotocol link</a> + </body> +</html> diff --git a/uriloader/exthandler/tests/mochitest/protocol_custom_sandbox_helper.sjs b/uriloader/exthandler/tests/mochitest/protocol_custom_sandbox_helper.sjs new file mode 100644 index 0000000000..faf2937a08 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/protocol_custom_sandbox_helper.sjs @@ -0,0 +1,30 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +function handleRequest(request, response) { + Cu.importGlobalProperties(["URLSearchParams"]); + + let query = new URLSearchParams(request.queryString); + + // Set CSP sandbox attributes if caller requests any. + let cspSandbox = query.get("cspSandbox"); + if (cspSandbox) { + response.setHeader( + "Content-Security-Policy", + "sandbox " + cspSandbox, + false + ); + } + + // Redirect to custom protocol via HTTP 302. + if (query.get("redirectCustomProtocol")) { + response.setStatusLine(request.httpVersion, 302, "Found"); + + response.setHeader("Location", "mailto:test@example.com", false); + response.write("Redirect!"); + return; + } + + response.setStatusLine(request.httpVersion, 200); + response.write("OK"); +} diff --git a/uriloader/exthandler/tests/mochitest/redirect_helper.sjs b/uriloader/exthandler/tests/mochitest/redirect_helper.sjs new file mode 100644 index 0000000000..5c1068bebb --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/redirect_helper.sjs @@ -0,0 +1,30 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +Cu.importGlobalProperties(["URLSearchParams"]); + +function handleRequest(request, response) { + let params = new URLSearchParams(request.queryString); + let uri = params.get("uri"); + let redirectType = params.get("redirectType") || "location"; + switch (redirectType) { + case "refresh": + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Refresh", "0; url=" + uri); + break; + + case "meta-refresh": + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html"); + response.write(`<meta http-equiv="refresh" content="0; url=${uri}">`); + break; + + case "location": + // fall through + default: + response.setStatusLine(request.httpVersion, 302, "Moved Temporarily"); + response.setHeader("Location", uri); + } +} diff --git a/uriloader/exthandler/tests/mochitest/save_filenames.html b/uriloader/exthandler/tests/mochitest/save_filenames.html new file mode 100644 index 0000000000..1535a0f657 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/save_filenames.html @@ -0,0 +1,360 @@ +<html> +<head> +<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/> +</head> +<body> +<style> + img { padding: 10px; border: 1px solid red; } + a { padding-left: 10px; } +</style> + +<span id="items"> + +<!-- simple filename --> +<img id="i0" src="http://localhost:8000/basic.png" + data-noattach="true" data-filename="basic.png"> + +<!-- simple filename with content disposition --> +<img id="i1" src="http://localhost:8000/save_filename.sjs?type=png&filename=simple.png" data-filename="simple.png"> + +<!-- invalid characters in the filename --> +<img id="i2" src="http://localhost:8000/save_filename.sjs?type=png&filename=invalidfilename/a:b*c%63d.png" data-filename="invalidfilename_a b ccd.png"> + +<!-- invalid extension for a png image --> +<img id="i3" src="http://localhost:8000/save_filename.sjs?type=png&filename=invalidextension.pang" data-filename="invalidextension.png"> + +<!-- jpeg extension for a png image --> +<img id="i4" src="http://localhost:8000/save_filename.sjs?type=png&filename=reallyapng.jpeg" data-filename="reallyapng.png"> + +<!-- txt extension for a png image --> +<img id="i5" src="http://localhost:8000/save_filename.sjs?type=png&filename=nottext.txt" data-filename="nottext.png"> + +<!-- no extension for a png image --> +<img id="i6" src="http://localhost:8000/save_filename.sjs?type=png&filename=noext" data-filename="noext.png"> + +<!-- empty extension for a png image --> +<img id="i7" src="http://localhost:8000/save_filename.sjs?type=png&filename=noextdot." data-filename="noextdot.png"> + +<!-- long filename --> +<img id="i8" src="http://localhost:8000/save_filename.sjs?type=png&filename=averylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilename.png" + data-filename="averylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongf.png"> + +<!-- long filename with invalid extension --> +<img id="i9" src="http://localhost:8000/save_filename.sjs?type=png&filename=bverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilename.exe" + data-filename="bverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongf.png"> + +<!-- long filename with invalid extension --> +<img id="i10" src="http://localhost:8000/save_filename.sjs?type=png&filename=cverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilename.exe.jpg" + data-filename="cverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongfilenameverylongf.png"> + +<!-- jpeg with jpg extension --> +<img id="i11" src="http://localhost:8000/save_filename.sjs?type=jpeg&filename=thejpg.jpg" data-filename="thejpg.jpg"> + +<!-- jpeg with jpeg extension --> +<img id="i12" src="http://localhost:8000/save_filename.sjs?type=jpeg&filename=thejpg.jpeg" data-filename="thejpg.jpeg"> + +<!-- jpeg with invalid extension --> +<img id="i13" src="http://localhost:8000/save_filename.sjs?type=jpeg&filename=morejpg.exe" data-filename="morejpg.jpg" data-filename-platformlinux="morejpg.jpeg"> + +<!-- jpeg with multiple extensions --> +<img id="i14" src="http://localhost:8000/save_filename.sjs?type=jpeg&filename=anotherjpg.jpg.exe" data-filename="anotherjpg.jpg.jpg" data-filename-platformlinux="anotherjpg.jpg.jpeg"> + +<!-- jpeg with no filename portion --> +<img id="i15" src="http://localhost:8000/save_filename.sjs?type=jpeg&filename=.jpg" + data-filename="jpg.jpg" data-filename-platformlinux="jpg.jpeg"> + +<!-- png with no filename portion and invalid extension --> +<img id="i16" src="http://localhost:8000/save_filename.sjs?type=png&filename=.exe" data-filename="exe.png"> + +<!-- png with escaped characters --> +<img id="i17" src="http://localhost:8000/save_filename.sjs?type=png&filename=first%20file.png" data-filename="first file.png"> + +<!-- png with more escaped characters --> +<img id="i18" src="http://localhost:8000/save_filename.sjs?type=png&filename=second%32file%2Eexe" data-filename="second2file.png"> + +<!-- unknown type with png extension --> +<img id="i19" src="http://localhost:8000/save_filename.sjs?type=nonsense&filename=nonsense1.png" + data-nodrag="true" data-unknown="typeonly" data-filename="nonsense1.png"> + +<!-- unknown type with exe extension --> +<img id="i20" src="http://localhost:8000/save_filename.sjs?type=nonsense&filename=nonsense2.exe" + data-nodrag="true" data-unknown="typeonly" data-filename="nonsense2.exe"> + +<!-- unknown type with no extension --> +<img id="i21" src="http://localhost:8000/save_filename.sjs?type=nonsense&filename=nonsense3" + data-nodrag="true" data-unknown="typeonly" data-filename="nonsense3"> + +<!-- simple script --> +<script id="i22" src="http://localhost:8000/save_filename.sjs?type=js&filename=script1.js" data-filename="script1.js"></script> + +<!-- script with invalid extension. --> +<script id="i23" src="http://localhost:8000/save_filename.sjs?type=js&filename=script2.exe" + data-filename="script2.exe" data-savepagename="script2.exe.js"></script> + +<!-- script with escaped characters --> +<script id="i24" src="http://localhost:8000/save_filename.sjs?type=js&filename=script%20%33.exe" + data-filename="script 3.exe" data-savepagename="script 3.exe.js"></script> + +<!-- script with long filename --> +<script id="i25" src="http://localhost:8000/save_filename.sjs?type=js&filename=script123456789script123456789script123456789script123456789script123456789script123456789script123456789script123456789script123456789script123456789script123456789script123456789script123456789script123456789script123456789script123456789script123456789script123456789.js" + data-filename="script123456789script123456789script123456789script123456789script123456789script123456789script123456789script123456789script123456789script123456789script123456789script123456789script123456789script123456789script123456789script123456789script12345.js"></script> + +<!-- binary with exe extension --> +<object id="i26" data="http://localhost:8000/save_filename.sjs?type=binary&filename=download1.exe" + data-unknown="true" data-filename="download1.exe"></object> + +<!-- binary with invalid extension --> +<object id="i27" data="http://localhost:8000/save_filename.sjs?type=binary&filename=download2.png" + data-unknown="true" data-filename="download2.png"></object> + +<!-- binary with no extension --> +<object id="i28" data="http://localhost:8000/save_filename.sjs?type=binary&filename=downloadnoext" + data-unknown="true" data-filename="downloadnoext"></object> + +<!-- binary with no other invalid characters --> +<object id="i29" data="http://localhost:8000/save_filename.sjs?type=binary&filename=binary^%31%20exe.exe" + data-unknown="true" data-filename="binary^1 exe.exe"></object> + +<!-- unknown image type with no extension, but ending in png --> +<img id="i30" src="http://localhost:8000/save_filename.sjs?type=otherimage&filename=specialpng" + data-unknown="typeonly" data-nodrag="true" data-filename="specialpng"> + +<!-- unknown image type with no extension, but ending in many dots --> +<img id="i31" src="http://localhost:8000/save_filename.sjs?type=otherimage&filename=extrapng..." + data-unknown="typeonly" data-nodrag="true" data-filename="extrapng"> + +<!-- image type with no content-disposition filename specified --> +<img id="i32" src="http://localhost:8000/save_filename.sjs?type=png" data-filename="save_filename.png"> + +<!-- binary with no content-disposition filename specified --> +<object id="i33" data="http://localhost:8000/save_filename.sjs?type=binary" + data-unknown="true" data-filename="save_filename.sjs"></object> + +<!-- image where url has png extension --> +<img id="i34" src="http://localhost:8000/getdata.png?type=png&filename=override.png" data-filename="override.png"> + +<!-- image where url has png extension but content disposition has incorrect extension --> +<img id="i35" src="http://localhost:8000/getdata.png?type=png&filename=flower.jpeg" data-filename="flower.png"> + +<!-- image where url has png extension but content disposition does not --> +<img id="i36" src="http://localhost:8000/getdata.png?type=png&filename=ruby" data-filename="ruby.png"> + +<!-- image where url has png extension but content disposition has invalid characters --> +<img id="i37" src="http://localhost:8000/getdata.png?type=png&filename=sapphire/data" data-filename="sapphire_data.png"> + +<!-- image where neither content disposition or url have an extension --> +<img id="i38" src="http://localhost:8000/base?type=png&filename=emerald" data-filename="emerald.png"> + +<!-- image where filename is not specified --> +<img id="i39" src="http://localhost:8000/base?type=png" data-filename="base.png"> + +<!-- simple script where url filename has no extension --> +<script id="i40" src="http://localhost:8000/base?type=js&filename=script4.js" data-filename="script4.js"></script> + +<!-- script where url filename has no extension and invalid extension in content disposition filename --> +<script id="i41" src="http://localhost:8000/base?type=js&filename=script5.exe" + data-filename="script5.exe" data-savepagename="script5.exe.js"></script> + +<!-- script where url filename has no extension and escaped characters in content disposition filename--> +<script id="i42" src="http://localhost:8000/base?type=js&filename=script%20%36.exe" + data-filename="script 6.exe" data-savepagename="script 6.exe.js"></script> + +<!-- text where filename is present --> +<img id="i43" src="http://localhost:8000/getdata.png?type=text&filename=readme.txt" + data-nodrag="true" data-filename="readme.txt"> + +<!-- text where filename is present with a different extension --> +<img id="i44" src="http://localhost:8000/getdata.png?type=text&filename=main.cpp" + data-nodrag="true" data-filename="main.cpp"> + +<!-- text where extension is not present --> +<img id="i45" src="http://localhost:8000/getdata.png?type=text&filename=readme" + data-nodrag="true" data-filename="readme"> + +<!-- text where extension is not present and url does not have extension --> +<img id="i46" src="http://localhost:8000/base?type=text&filename=info" + data-nodrag="true" data-filename="info"> + +<!-- text where filename is not present --> +<img id="i47" src="http://localhost:8000/basetext?type=text" + data-nodrag="true" data-filename="basetext"> + +<!-- text where url has extension --> +<img id="i48" src="http://localhost:8000/text2.txt?type=text" + data-nodrag="true" data-filename="text2.txt"> + +<!-- text where url has extension --> +<img id="i49" src="http://localhost:8000/text3.gonk?type=text" + data-nodrag="true" data-filename="text3.gonk"> + +<!-- text with long filename --> +<img id="i50" src="http://localhost:8000/text3.gonk?type=text&filename=text0123456789zztext0123456789zztext0123456789zztext0123456789zztext0123456789zztext0123456789zztext0123456789zztext0123456789zztext0123456789zztext0123456789zztext0123456789zztext0123456789zztext0123456789zztext0123456789zztext0123456789zztext0123456789text0123456789zztext0123456789zztext0123456789zztext01234567.exe.txt" data-nodrag="true" data-filename="text0123456789zztext0123456789zztext0123456789zztext0123456789zztext0123456789zztext0123456789zztext0123456789zztext0123456789zztext0123456789zztext0123456789zztext0123456789zztext0123456789zztext0123456789zztext0123456789zztext0123456789zztext012345.txt"> + +<!-- webp image --> +<img id="i51" src="http://localhost:8000/save_filename.sjs?type=webp&filename=webpimage.webp" + data-filename="webpimage.webp"> + +<!-- webp image with jpg extension --> +<img id="i52" src="http://localhost:8000/save_filename.sjs?type=webp&filename=realwebpimage.jpg" + data-filename="realwebpimage.webp"> + +<!-- no content type specified --> +<img id="i53" src="http://localhost:8000/save_filename.sjs?&filename=notype.png" + data-nodrag="true" data-filename="notype.png"> + +<!-- no content type specified. --> +<img id="i54" src="http://localhost:8000/save_filename.sjs?&filename=notypebin.exe" + data-nodrag="true" data-filename="notypebin.exe"> + +<!-- extension contains invalid characters --> +<img id="i55" src="http://localhost:8000/save_filename.sjs?type=png&filename=extinvalid.a?*" + data-filename="extinvalid.png"> + +<!-- filename with redirect and content disposition --> +<img id="i56" src="http://localhost:8000/redir?type=png&filename=red.png" data-filename="red.png"> + +<!-- filename with redirect and different type --> +<img id="i57" src="http://localhost:8000/redir?type=jpeg&filename=green.png" + data-filename="green.jpg" data-filename-platformlinux="green.jpeg"> + +<!-- filename with redirect and binary type --> +<object id="i58" data="http://localhost:8000/redir?type=binary&filename=blue.png" + data-unknown="true" data-filename="blue.png"></object> + +<!-- filename in url with incorrect extension --> +<img id="i59" src="http://localhost:8000/aquamarine.jpeg" + data-noattach="true" data-filename="aquamarine.png"> + +<!-- filename in url with exe extension, but returns a png image --> +<img id="i60" src="http://localhost:8000/lazuli.exe" + data-noattach="true" data-filename="lazuli.png"> + +<!-- filename with leading, trailing and duplicate spaces --> +<img id="i61" src="http://localhost:8000/save_filename.sjs?type=png&filename= with spaces.png " + data-filename="with spaces.png"> + +<!-- filename with leading and trailing periods --> +<img id="i62" src="http://localhost:8000/save_filename.sjs?type=png&filename=..with..dots..png.." + data-filename="with..dots..png"> + +<!-- filename with non-ascii character --> +<img id="i63" src="http://localhost:8000/base?type=png&filename=s%C3%B6meescapes.%C3%B7ng" data-filename="sömeescapes.png"> + +<!-- filename with content disposition name assigned. The name is only used + when selecting to manually save, otherwise it is ignored. --> +<img id="i64" src="http://localhost:8000/save_thename.sjs?type=png&dispname=withname" + data-filename="save_thename.png"> + +<!-- reserved filename on Windows --> +<img id="i65" src="http://localhost:8000/save_filename.sjs?type=text&filename=com1" + data-nodrag="true" data-filename="com1" data-filename-platformwin="Untitled"> + +<!-- reserved filename with extension on Windows --> +<img id="i66" src="http://localhost:8000/save_filename.sjs?type=text&filename=com2.any" + data-nodrag="true" data-filename="com2.any" data-filename-platformwin="Untitled"> + +<!-- simple zip file --> +<object id="i67" data="http://localhost:8000/save_filename.sjs?type=zip&filename=simple.zip" data-filename="simple.zip" + data-unknown="true"></object> + +<!-- simple zip file with differing extension --> +<object id="i68" data="http://localhost:8000/save_filename.sjs?type=zip&filename=simple.jar" data-filename="simple.jar" + data-unknown="true"></object> + +<!-- simple zip file with no extension --> +<object id="i69" data="http://localhost:8000/save_filename.sjs?type=zip&filename=simplepack" data-filename="simplepack.zip" + data-unknown="true"></object> + +<!-- simple json file --> +<object id="i70" data="http://localhost:8000/save_filename.sjs?type=json&filename=simple.json" data-filename="simple.json" + data-unknown="true"></object> + +<!-- simple json file with differing extension --> +<object id="i71" data="http://localhost:8000/save_filename.sjs?type=json&filename=simple.dat" data-filename="simple.dat" + data-unknown="true"></object> + +<!-- compressed file with .gz extension --> +<img id="i72" src="http://localhost:8000/save_filename.sjs?type=png&filename=compressed.png.gz" data-filename="compressed.png.png"> + +<!-- compressed file with .tar.gz extension --> +<object id="i73" data="http://localhost:8000/save_filename.sjs?type=tar&filename=compressed2.tar.gz" data-filename="compressed2.tar.gz" + data-unknown="true"></object> + +<!-- compressed file with bittar.gz extension. There is no tar mime info on Windows so the filename is not changed. --> +<object id="i74" data="http://localhost:8000/save_filename.sjs?type=tar&filename=compressed3.bittar.gz" + data-filename="compressed3.bittar.gz" + data-unknown="true"></object> + +<!-- compressed file with .tar.bz2 extension --> +<object id="i75" data="http://localhost:8000/save_filename.sjs?type=tar&filename=buzz.tar.bz2" data-filename="buzz.tar.bz2" + data-unknown="true"></object> + +<!-- executable with no filename specified and an extension specified within the url --> +<img id="i76" src="http://localhost:8000/executable.exe?type=nonsense" + data-nodrag="true" data-unknown="typeonly" data-filename="executable.exe"> + +<!-- embedded child html --> +<object id="i77" data="http://localhost:8000/save_filename.sjs?type=html&filename=child.par" + data-filename="child.par" data-unknown="true"></object> + +<!-- file starting with a dot with and unknown extension --> +<img id="i78" src="http://localhost:8000/save_filename.sjs?type=png&filename=.extension" data-filename="extension.png"> + +<!-- html file starting with a dot --> +<object id="i79" data="http://localhost:8000/save_filename.sjs?type=html&filename=.alternate" + data-filename="alternate.html" data-filename-platformwin="alternate.htm" data-unknown="true"></object> + +<!-- unrecognized file type starting with a dot --> +<object id="i80" data="http://localhost:8000/save_filename.sjs?type=otherimage&filename=.alternate" data-filename="alternate" + data-unknown="true"></object> + +<!-- filename with lnk extension --> +<img id="i81" src="http://localhost:8000/save_filename.sjs?type=nonsense&filename=shortcut.lnk" + data-nodrag="true" data-unknown="typeonly" + data-filename="shortcut.lnk.download"> + +<!-- long filename with lnk extension --> +<img id="i82" src="http://localhost:8000/save_filename.sjs?type=nonsense&filename=longshortcutnamelongshortcutnamelongshortcutnamelongshortcutnamelongshortcutnamelongshortcutnamelongshortcutnamelongshortcutnamelongshortcutnamelongshortcutnamelongshortcutnamelongshortcutnamelongshortcutnamelongshortcutnamelongshortcutnamelongshort1234567890.lnk" + data-nodrag="true" data-unknown="typeonly" + data-filename="longshortcutnamelongshortcutnamelongshortcutnamelongshortcutnamelongshortcutnamelongshortcutnamelongshortcutnamelongshortcutnamelongshortcutnamelongshortcutnamelongshortcutnamelongshortcutnamelongshortcutnamelongshortcutnamelongshortcutnamelongshort1234567890.lnk.download"> + +</span> + +<!-- This set is used to test the filename specified by the download attribute is validated correctly. --> +<span id="downloads"> + <a id="download0" href="http://localhost:8000/base" download="pearl.png" data-filename="pearl.png">Link</a> + <a id="download1" href="http://localhost:8000/save_filename.sjs?type=png" download="opal.jpeg" data-filename="opal.png">Link</a> + <a id="download2" href="http://localhost:8000/save_filename.sjs?type=jpeg" + download="amethyst.png" data-filename="amethyst.jpg" + data-filename-platformlinux="amethyst.jpeg">Link</a> + <a id="download3" href="http://localhost:8000/save_filename.sjs?type=text" + download="onyx.png" data-filename="onyx.png">Link</a> + <!-- The content-disposition overrides the download attribute. --> + <a id="download4" href="http://localhost:8000/save_filename.sjs?type=png&filename=fakename.jpeg" download="topaz.jpeg" data-filename="fakename.png">Link</a> + <a id="download5" href="http://localhost:8000/save_filename.sjs?type=png" + download="amber?.png" data-filename="amber .png">Link</a> + <a id="download6" href="http://localhost:8000/save_filename.sjs?type=jpeg" + download="jade.:*jpeg" data-filename="jade.jpg" + data-filename-platformlinux="jade.jpeg">Link</a>> + <a id="download7" href="http://localhost:8000/save_filename.sjs?type=png" + download="thisisaverylongfilename-thisisaverylongfilename-thisisaverylongfilename-thisisaverylongfilename-thisisaverylongfilename-thisisaverylongfilename-thisisaverylongfilename-thisisaverylongfilename-thisisaverylongfilename-thisisaverylongfilename-thisisaverylongfilename-thisisaverylongfilename.png" + data-filename="thisisaverylongfilename-thisisaverylongfilename-thisisaverylongfilename-thisisaverylongfilename-thisisaverylongfilename-thisisaverylongfilename-thisisaverylongfilename-thisisaverylongfilename-thisisaverylongfilename-thisisaverylongfilename-thisisavery.png">Link</a> + <a id="download8" href="http://localhost:8000/base" + download="	
 ᠎᠎ spa ced.png 	
 ᠎᠎ " + data-filename="spa ced.png">Link</a> +</span> + +<span id="links"> + <a id="link0" href="http://localhost:8000/save_filename.sjs?type=png&filename=one.png" data-filename="one.png">One</a> + <a id="link1" href="http://localhost:8000/save_filename.sjs?type=png&filename=two.jpeg" data-filename="two.png">Two</a> + <a id="link2" href="http://localhost:8000/save_filename.sjs?type=png&filename=three.con" data-filename="three.png">Three</a> + <a id="link3" href="http://localhost:8000/save_filename.sjs?type=png&dispname=four" data-filename="four.png">Four</a> + <a id="link4" href="http://localhost:8000/save_filename.sjs?type=png&filename=five.local" data-filename="five.png">Five</a> +</span> + +<!-- The content-disposition attachment generates links from the images/objects/scripts above + and inserts them here. --> +<p id="attachment-links"> +</p> + +</body></html> diff --git a/uriloader/exthandler/tests/mochitest/script_redirect.html b/uriloader/exthandler/tests/mochitest/script_redirect.html new file mode 100644 index 0000000000..31e0dc6a7e --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/script_redirect.html @@ -0,0 +1,5 @@ +<script> + let params = new URL(document.location).searchParams; + let uri = params.get("uri"); + document.location = uri; +</script> diff --git a/uriloader/exthandler/tests/mochitest/test_invalidCharFileExtension.xhtml b/uriloader/exthandler/tests/mochitest/test_invalidCharFileExtension.xhtml new file mode 100644 index 0000000000..177af3757f --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/test_invalidCharFileExtension.xhtml @@ -0,0 +1,65 @@ +<html xmlns="http://www.w3.org/1999/xhtml"> +<head> + <title>Test for Handling of unsafe bidi chars</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> +</head> +<body> +<p id="display"></p> +<iframe id="test"></iframe> +<script type="text/javascript"> +var tests = [ + ["test.png:large", "test.png"], + ["test.png/large", "test.png"], + [":test.png::large:", "test.png"], +]; + +add_task(async function() { + function promiseMessage(messageName) { + return new Promise(resolve => { + chromeScript.addMessageListener(messageName, function listener(data) { + chromeScript.removeMessageListener(messageName, listener); + resolve(data); + }); + }); + } + + let url = SimpleTest.getTestFileURL("HelperAppLauncherDialog_chromeScript.js"); + let chromeScript = SpecialPowers.loadChromeScript(url); + + function wrongAPICallListener(msg) { + ok( + false, + `Called ${msg} when always ask pref was set to ${ + Services.prefs.getBoolPref( + "browser.download.always_ask_before_handling_new_types" + ) + }, which shouldn't happen.` + ); + } + chromeScript.addMessageListener("wrongAPICall", wrongAPICallListener); + + for (let prefVal of [false, true]) { + await SpecialPowers.pushPrefEnv({ + set: [["browser.download.always_ask_before_handling_new_types", prefVal]] + }); + // Run all the tests. + for (let [name, expected] of tests) { + let promiseName = promiseMessage("suggestedFileName"); + document.getElementById("test").src = + "invalidCharFileExtension.sjs?name=" + encodeURIComponent(name); + is((await promiseName), expected, "got the expected sanitized name"); + } + } + + // Clean up. + let promise = promiseMessage("unregistered"); + chromeScript.sendAsyncMessage("unregister"); + await promise; + + chromeScript.removeMessageListener("wrongAPICall", wrongAPICallListener); + chromeScript.destroy(); +}); +</script> +</body> +</html> diff --git a/uriloader/exthandler/tests/mochitest/test_nullCharFile.xhtml b/uriloader/exthandler/tests/mochitest/test_nullCharFile.xhtml new file mode 100644 index 0000000000..b153395e81 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/test_nullCharFile.xhtml @@ -0,0 +1,67 @@ +<html xmlns="http://www.w3.org/1999/xhtml"> +<head> + <title>Test for Handling of null char</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> +</head> +<body> +<p id="display"></p> +<iframe id="test"></iframe> +<script type="text/javascript"> +var tests = [ + ["test.html\u0000.png", "test.html_.png"], + ["test.html.\u0000png", "test.html._png"], +]; + +add_task(async function() { + function promiseMessage(messageName) { + return new Promise(resolve => { + chromeScript.addMessageListener(messageName, function listener(data) { + chromeScript.removeMessageListener(messageName, listener); + resolve(data); + }); + }); + } + + let url = SimpleTest.getTestFileURL("HelperAppLauncherDialog_chromeScript.js"); + let chromeScript = SpecialPowers.loadChromeScript(url); + + function wrongAPICallListener(msg) { + ok( + false, + `Called ${msg} when always ask pref was set to ${ + Services.prefs.getBoolPref( + "browser.download.always_ask_before_handling_new_types" + ) + }, which shouldn't happen.` + ); + } + chromeScript.addMessageListener("wrongAPICall", wrongAPICallListener); + + for (let prefVal of [false, true]) { + info("Pushing pref"); + await SpecialPowers.pushPrefEnv({ + set: [["browser.download.always_ask_before_handling_new_types", prefVal]] + }); + for (let [name, expected] of tests) { + let promiseName = promiseMessage("suggestedFileName"); + const a = document.createElement('a'); + // Pass an unknown mimetype so we don't "correct" the extension: + a.href = "data:application/baconizer;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAAAAAA6fptVAAAACklEQVQI12NgAAAAAgAB4iG8MwAAAABJRU5ErkJggg=="; + a.download = name; + a.dispatchEvent(new MouseEvent('click')); + is((await promiseName), expected, "got the expected sanitized name"); + } + } + + // Clean up. + let promise = promiseMessage("unregistered"); + chromeScript.sendAsyncMessage("unregister"); + await promise; + + chromeScript.removeMessageListener("wrongAPICall", wrongAPICallListener); + chromeScript.destroy(); +}); +</script> +</body> +</html> diff --git a/uriloader/exthandler/tests/mochitest/test_spammy_page.html b/uriloader/exthandler/tests/mochitest/test_spammy_page.html new file mode 100644 index 0000000000..b1e60a1e8e --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/test_spammy_page.html @@ -0,0 +1,27 @@ +<!-- Any copyright is dedicated to the Public Domain. + - http://creativecommons.org/publicdomain/zero/1.0/ --> +<!DOCTYPE html> +<html> +<head> + <meta charset="utf-8" /> + <title>...</title> +</head> +<body> + <p> Hello, it's the spammy page! </p> +<script type="text/javascript"> + let count = 0; +window.onload = window.onclick = function() { + if (count < 100) { + count++; + let l = document.createElement('a'); + l.href = 'data:text/plain,some text'; + l.download = 'sometext.pdf'; + + document.body.appendChild(l); + l.click(); + } +} +</script> +<a id="image" href="${TEST_ROOT}/file_with@@funny_name.png">Image</a> +</body> +</html> diff --git a/uriloader/exthandler/tests/mochitest/test_unknown_ext_protocol_handlers.html b/uriloader/exthandler/tests/mochitest/test_unknown_ext_protocol_handlers.html new file mode 100644 index 0000000000..f8727db605 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/test_unknown_ext_protocol_handlers.html @@ -0,0 +1,28 @@ +<!DOCTYPE html> +<html> +<head> + <title>Test for no error reporting for unknown external protocols</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> +</head> +<body> +<iframe id="testFrame"></iframe> +<script type="text/javascript"> + +SimpleTest.waitForExplicitFinish(); + +window.onload = () => { + let testFrame = document.getElementById("testFrame"); + + try { + testFrame.contentWindow.location.href = "unknownextproto:"; + ok(true, "There is no error reporting for unknown external protocol navigation."); + } catch (e) { + ok(false, "There should be no error reporting for unknown external protocol navigation."); + } + + SimpleTest.finish(); +}; +</script> +</body> +</html> diff --git a/uriloader/exthandler/tests/mochitest/test_unsafeBidiChars.xhtml b/uriloader/exthandler/tests/mochitest/test_unsafeBidiChars.xhtml new file mode 100644 index 0000000000..34c6c956fd --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/test_unsafeBidiChars.xhtml @@ -0,0 +1,89 @@ +<html xmlns="http://www.w3.org/1999/xhtml"> +<head> + <title>Test for Handling of unsafe bidi chars</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> +</head> +<body> +<p id="display"></p> +<iframe id="test"></iframe> +<script type="text/javascript"> +var unsafeBidiChars = [ + "\xe2\x80\xaa", // LRE + "\xe2\x80\xab", // RLE + "\xe2\x80\xac", // PDF + "\xe2\x80\xad", // LRO + "\xe2\x80\xae", // RLO +]; + +var tests = [ + "{1}.test", + "{1}File.test", + "Fi{1}le.test", + "File{1}.test", + "File.{1}test", + "File.te{1}st", + "File.test{1}", + "File.{1}", +]; + +function replace(name, x) { + return name.replace(/\{1\}/, x); +} + +function sanitize(name) { + return replace(name, "_"); +} + +add_task(async function() { + function promiseMessage(messageName) { + return new Promise(resolve => { + chromeScript.addMessageListener(messageName, function listener(data) { + chromeScript.removeMessageListener(messageName, listener); + resolve(data); + }); + }); + } + + let url = SimpleTest.getTestFileURL("HelperAppLauncherDialog_chromeScript.js"); + let chromeScript = SpecialPowers.loadChromeScript(url); + + function wrongAPICallListener(msg) { + ok( + false, + `Called ${msg} when always ask pref was set to ${ + Services.prefs.getBoolPref( + "browser.download.always_ask_before_handling_new_types" + ) + }, which shouldn't happen.` + ); + } + chromeScript.addMessageListener("wrongAPICall", wrongAPICallListener); + + for (let prefVal of [false, true]) { + await SpecialPowers.pushPrefEnv({ + set: [["browser.download.always_ask_before_handling_new_types", prefVal]] + }); + for (let test of tests) { + for (let char of unsafeBidiChars) { + let promiseName = promiseMessage("suggestedFileName"); + let name = replace(test, char); + let expected = sanitize(test); + document.getElementById("test").src = + "unsafeBidiFileName.sjs?name=" + encodeURIComponent(name); + is((await promiseName), expected, "got the expected sanitized name"); + } + } + } + + // Clean up. + let promise = promiseMessage("unregistered"); + chromeScript.sendAsyncMessage("unregister"); + await promise; + + chromeScript.removeMessageListener("wrongAPICall", wrongAPICallListener); + chromeScript.destroy(); +}); +</script> +</body> +</html> diff --git a/uriloader/exthandler/tests/mochitest/unsafeBidiFileName.sjs b/uriloader/exthandler/tests/mochitest/unsafeBidiFileName.sjs new file mode 100644 index 0000000000..4f88ff6de2 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/unsafeBidiFileName.sjs @@ -0,0 +1,18 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +function handleRequest(request, response) { + response.setStatusLine(request.httpVersion, 200, "OK"); + + if (!request.queryString.match(/^name=/)) { + return; + } + var name = decodeURIComponent(request.queryString.substring(5)); + + response.setHeader( + "Content-Type", + 'application/octet-stream; name="' + name + '"' + ); + response.setHeader("Content-Disposition", 'inline; filename="' + name + '"'); +} |