diff options
Diffstat (limited to 'uriloader/exthandler/tests')
102 files changed, 12170 insertions, 0 deletions
diff --git a/uriloader/exthandler/tests/HandlerServiceTestUtils.sys.mjs b/uriloader/exthandler/tests/HandlerServiceTestUtils.sys.mjs new file mode 100644 index 0000000000..0e52cc2568 --- /dev/null +++ b/uriloader/exthandler/tests/HandlerServiceTestUtils.sys.mjs @@ -0,0 +1,253 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* + * Shared functions for tests related to invoking external handler applications. + */ + +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +import { Assert } from "resource://testing-common/Assert.sys.mjs"; + +const lazy = {}; + +XPCOMUtils.defineLazyServiceGetter( + lazy, + "gExternalProtocolService", + "@mozilla.org/uriloader/external-protocol-service;1", + "nsIExternalProtocolService" +); +XPCOMUtils.defineLazyServiceGetter( + lazy, + "gMIMEService", + "@mozilla.org/mime;1", + "nsIMIMEService" +); +XPCOMUtils.defineLazyServiceGetter( + lazy, + "gHandlerService", + "@mozilla.org/uriloader/handler-service;1", + "nsIHandlerService" +); + +export var HandlerServiceTestUtils = { + /** + * Retrieves the names of all the MIME types and protocols configured in the + * handler service instance currently under testing. + * + * @return Array of strings like "example/type" or "example-scheme", sorted + * alphabetically regardless of category. + */ + getAllHandlerInfoTypes() { + return Array.from( + lazy.gHandlerService.enumerate(), + info => info.type + ).sort(); + }, + + /** + * Retrieves all the configured handlers for MIME types and protocols. + * + * @note The nsIHandlerInfo instances returned by the "enumerate" method + * cannot be used for testing because they incorporate information from + * the operating system and also from the default nsIHandlerService + * instance, independently from what instance is under testing. + * + * @return Array of nsIHandlerInfo instances sorted by their "type" property. + */ + getAllHandlerInfos() { + return this.getAllHandlerInfoTypes().map(type => this.getHandlerInfo(type)); + }, + + /** + * Retrieves an nsIHandlerInfo for the given MIME type or protocol, which + * incorporates information from the operating system and also from the + * handler service instance currently under testing. + * + * @note If the handler service instance currently under testing is not the + * default one and the requested type is a MIME type, the returned + * nsIHandlerInfo will include information from the default + * nsIHandlerService instance. This cannot be avoided easily because the + * getMIMEInfoFromOS method is not exposed to JavaScript. + * + * @param type + * MIME type or scheme name of the nsIHandlerInfo to retrieve. + * + * @return The populated nsIHandlerInfo instance. + */ + getHandlerInfo(type) { + if (type.includes("/")) { + // We have to use the getFromTypeAndExtension method because we don't have + // access to getMIMEInfoFromOS. This means that we have to reset the data + // that may have been imported from the default nsIHandlerService instance + // and is not overwritten by fillHandlerInfo later. + let handlerInfo = lazy.gMIMEService.getFromTypeAndExtension(type, null); + if (AppConstants.platform == "android") { + // On Android, the first handler application is always the internal one. + while (handlerInfo.possibleApplicationHandlers.length > 1) { + handlerInfo.possibleApplicationHandlers.removeElementAt(1); + } + } else { + handlerInfo.possibleApplicationHandlers.clear(); + } + handlerInfo.setFileExtensions(""); + // Populate the object from the handler service instance under testing. + if (lazy.gHandlerService.exists(handlerInfo)) { + lazy.gHandlerService.fillHandlerInfo(handlerInfo, ""); + } + return handlerInfo; + } + + // Populate the protocol information from the handler service instance under + // testing, like the nsIExternalProtocolService::GetProtocolHandlerInfo + // method does on the default nsIHandlerService instance. + let osDefaultHandlerFound = {}; + let handlerInfo = + lazy.gExternalProtocolService.getProtocolHandlerInfoFromOS( + type, + osDefaultHandlerFound + ); + if (lazy.gHandlerService.exists(handlerInfo)) { + lazy.gHandlerService.fillHandlerInfo(handlerInfo, ""); + } else { + lazy.gExternalProtocolService.setProtocolHandlerDefaults( + handlerInfo, + osDefaultHandlerFound.value + ); + } + return handlerInfo; + }, + + /** + * Creates an nsIHandlerInfo for the given MIME type or protocol, initialized + * to the default values for the current platform. + * + * @note For this method to work, the specified MIME type or protocol must not + * be configured in the default handler service instance or the one + * under testing, and must not be registered in the operating system. + * + * @param type + * MIME type or scheme name of the nsIHandlerInfo to create. + * + * @return The blank nsIHandlerInfo instance. + */ + getBlankHandlerInfo(type) { + let handlerInfo = this.getHandlerInfo(type); + + let preferredAction, preferredApplicationHandler; + let alwaysAskBeforeHandling = true; + + if (AppConstants.platform == "android") { + // On Android, the default preferredAction for MIME types is useHelperApp. + // For protocols, we always behave as if an operating system provided + // handler existed, and as such we initialize them to useSystemDefault. + // This is because the AndroidBridge is not available in xpcshell tests. + preferredAction = type.includes("/") + ? Ci.nsIHandlerInfo.useHelperApp + : Ci.nsIHandlerInfo.useSystemDefault; + // On Android, the default handler application is always the internal one. + preferredApplicationHandler = { + name: "Android chooser", + }; + } else { + // On Desktop, the default preferredAction for MIME types is saveToDisk, + // while for protocols it is alwaysAsk. Since Bug 1735843, for new MIME + // types we default to not asking before handling unless a pref is set. + alwaysAskBeforeHandling = Services.prefs.getBoolPref( + "browser.download.always_ask_before_handling_new_types", + false + ); + + if (type.includes("/")) { + preferredAction = Ci.nsIHandlerInfo.saveToDisk; + } else { + preferredAction = Ci.nsIHandlerInfo.alwaysAsk; + // we'll always ask before handling protocols + alwaysAskBeforeHandling = true; + } + preferredApplicationHandler = null; + } + + this.assertHandlerInfoMatches(handlerInfo, { + type, + preferredAction, + alwaysAskBeforeHandling, + preferredApplicationHandler, + }); + return handlerInfo; + }, + + /** + * Checks whether an nsIHandlerInfo instance matches the provided object. + */ + assertHandlerInfoMatches(handlerInfo, expected) { + let expectedInterface = expected.type.includes("/") + ? Ci.nsIMIMEInfo + : Ci.nsIHandlerInfo; + Assert.ok(handlerInfo instanceof expectedInterface); + Assert.equal(handlerInfo.type, expected.type); + + if (!expected.preferredActionOSDependent) { + Assert.equal(handlerInfo.preferredAction, expected.preferredAction); + Assert.equal( + handlerInfo.alwaysAskBeforeHandling, + expected.alwaysAskBeforeHandling + ); + } + + if (expectedInterface == Ci.nsIMIMEInfo) { + let fileExtensionsEnumerator = handlerInfo.getFileExtensions(); + for (let expectedFileExtension of expected.fileExtensions || []) { + Assert.equal(fileExtensionsEnumerator.getNext(), expectedFileExtension); + } + Assert.ok(!fileExtensionsEnumerator.hasMore()); + } + + if (expected.preferredApplicationHandler) { + this.assertHandlerAppMatches( + handlerInfo.preferredApplicationHandler, + expected.preferredApplicationHandler + ); + } else { + Assert.equal(handlerInfo.preferredApplicationHandler, null); + } + + let handlerAppsArrayEnumerator = + handlerInfo.possibleApplicationHandlers.enumerate(); + if (AppConstants.platform == "android") { + // On Android, the first handler application is always the internal one. + this.assertHandlerAppMatches(handlerAppsArrayEnumerator.getNext(), { + name: "Android chooser", + }); + } + for (let expectedHandlerApp of expected.possibleApplicationHandlers || []) { + this.assertHandlerAppMatches( + handlerAppsArrayEnumerator.getNext(), + expectedHandlerApp + ); + } + Assert.ok(!handlerAppsArrayEnumerator.hasMoreElements()); + }, + + /** + * Checks whether an nsIHandlerApp instance matches the provided object. + */ + assertHandlerAppMatches(handlerApp, expected) { + Assert.ok(handlerApp instanceof Ci.nsIHandlerApp); + Assert.equal(handlerApp.name, expected.name); + if (expected.executable) { + Assert.ok(handlerApp instanceof Ci.nsILocalHandlerApp); + Assert.ok(expected.executable.equals(handlerApp.executable)); + } else if (expected.uriTemplate) { + Assert.ok(handlerApp instanceof Ci.nsIWebHandlerApp); + Assert.equal(handlerApp.uriTemplate, expected.uriTemplate); + } else if (expected.service) { + Assert.ok(handlerApp instanceof Ci.nsIDBusHandlerApp); + Assert.equal(handlerApp.service, expected.service); + Assert.equal(handlerApp.method, expected.method); + Assert.equal(handlerApp.dBusInterface, expected.dBusInterface); + Assert.equal(handlerApp.objectPath, expected.objectPath); + } + }, +}; diff --git a/uriloader/exthandler/tests/WriteArgument.cpp b/uriloader/exthandler/tests/WriteArgument.cpp new file mode 100644 index 0000000000..603965150e --- /dev/null +++ b/uriloader/exthandler/tests/WriteArgument.cpp @@ -0,0 +1,20 @@ +#include <stdio.h> +#include "prenv.h" + +int main(int argc, char* argv[]) { + if (argc != 2) return 1; + + const char* value = PR_GetEnv("WRITE_ARGUMENT_FILE"); + + if (!value) return 2; + + FILE* outfile = fopen(value, "w"); + if (!outfile) return 3; + + // We only need to write out the first argument (no newline). + fputs(argv[argc - 1], outfile); + + fclose(outfile); + + return 0; +} diff --git a/uriloader/exthandler/tests/gtest/ExternalHelperAppServiceTest.cpp b/uriloader/exthandler/tests/gtest/ExternalHelperAppServiceTest.cpp new file mode 100644 index 0000000000..e63922182c --- /dev/null +++ b/uriloader/exthandler/tests/gtest/ExternalHelperAppServiceTest.cpp @@ -0,0 +1,46 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* 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/. */ + +#include "nsExternalHelperAppService.h" +#include "gtest/gtest.h" +#include "nsNetUtil.h" +#include "mozilla/StaticPrefs_network.h" + +using namespace mozilla; + +TEST(ExternalHelperAppService, EscapeURI) +{ + if (StaticPrefs::network_url_useDefaultURI()) { + return; + } + + nsCString input("myproto://hello world"); + nsCString expected("myproto://hello%20world"); + + nsCOMPtr<nsIIOService> ios(do_GetIOService()); + nsCOMPtr<nsIURI> uri; + nsresult rv = ios->NewURI(input, nullptr, nullptr, getter_AddRefs(uri)); + EXPECT_EQ(rv, NS_OK); + + nsAutoCString spec; + rv = uri->GetSpec(spec); + EXPECT_EQ(rv, NS_OK); + + // Constructing the URI does not escape the space character. + ASSERT_TRUE(spec.Equals(input)); + + // Created an escaped version of the URI. + nsCOMPtr<nsIURI> escapedURI; + rv = nsExternalHelperAppService::EscapeURI(uri, getter_AddRefs(escapedURI)); + EXPECT_EQ(rv, NS_OK); + + nsAutoCString escapedSpec; + rv = escapedURI->GetSpec(escapedSpec); + EXPECT_EQ(rv, NS_OK); + + // Escaped URI should have an escaped spec. + ASSERT_TRUE(escapedSpec.Equals(expected)); +} diff --git a/uriloader/exthandler/tests/gtest/moz.build b/uriloader/exthandler/tests/gtest/moz.build new file mode 100644 index 0000000000..163afe927d --- /dev/null +++ b/uriloader/exthandler/tests/gtest/moz.build @@ -0,0 +1,11 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/. + +UNIFIED_SOURCES += [ + "ExternalHelperAppServiceTest.cpp", +] + +FINAL_LIBRARY = "xul-gtest" 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.toml b/uriloader/exthandler/tests/mochitest/browser.toml new file mode 100644 index 0000000000..2eaf6632ac --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/browser.toml @@ -0,0 +1,164 @@ +[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"] + +["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_first_prompt_not_blocked_without_user_interaction.js"] +support-files = ["file_external_protocol_iframe.html"] + +["browser_ftp_protocol_handlers.js"] +support-files = [ + "FTPprotocolHandler.html", + "blank.html", +] + +["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_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_ask_dialog_external.js"] +support-files = ["redirect_helper.sjs"] + +["browser_protocol_ask_dialog_permission.js"] +support-files = [ + "redirect_helper.sjs", + "script_redirect.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_protocolhandler_loop.js"] + +["browser_remember_download_option.js"] + +["browser_save_filenames.js"] +support-files = ["save_filenames.html"] + +["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_standalone_application_chooser_window_fallback.js"] +support-files = [ + "FTPprotocolHandler.html", + "blank.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"] 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..c73538fe19 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/browser_download_open_with_internal_handler.js @@ -0,0 +1,814 @@ +/* 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) { + // Runs tests after all "load" event handlers have fired off + let loadPromise = BrowserTestUtils.waitForContentEvent( + browser, + "documentloaded", + false, + null, + true + ); + BrowserTestUtils.startLoadingURIString(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.startLoadingURIString( + 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() { + 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..b66266ec9e --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/browser_download_preferred_action.js @@ -0,0 +1,299 @@ +/* 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 = Promise.withResolvers(); + 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.startLoadingURIString(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.startLoadingURIString( + 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..0b8c816d2e --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/browser_download_skips_dialog.js @@ -0,0 +1,59 @@ +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], + ], + }); + + 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..ababcc095c --- /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 = Promise.withResolvers(); + // First try to save the browser + saveBrowser(browser); + await pendingPromise.promise; + + // Next, try the context menu: + pendingTest = "save from context menu"; + pendingPromise = Promise.withResolvers(); + 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..df446f4c52 --- /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.startLoadingURIString( + 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..6b7c70cc01 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/browser_ftp_protocol_handlers.js @@ -0,0 +1,111 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + UrlbarTestUtils: "resource://testing-common/UrlbarTestUtils.sys.mjs", +}); + +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.startLoadingURIString(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, + UrlbarTestUtils.trimURL(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, + UrlbarTestUtils.trimURL(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 != UrlbarTestUtils.trimURL(testURL) + ); + is( + gURLBar.value, + UrlbarTestUtils.trimURL(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..d978410263 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/browser_launched_app_save_directory.js @@ -0,0 +1,111 @@ +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]], + }); + 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..424fd424f9 --- /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 = Promise.withResolvers(); + 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..7244aec503 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/browser_protocol_ask_dialog.js @@ -0,0 +1,467 @@ +/* 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.startLoadingURIString(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.startLoadingURIString( + 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..c567722b6d --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/browser_protocol_ask_dialog_permission.js @@ -0,0 +1,1383 @@ +/* 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 + ); +} + +function getSystemProtocol() { + // TODO add a scheme for Windows 10 or greater once support is added (see bug 1764599). + if (AppConstants.platform == "macosx") { + return "itunes"; + } + + info( + "Skipping this test since there isn't a suitable default protocol on this platform" + ); + return null; +} + +/** + * 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.startLoadingURIString(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.isVisible(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.isVisible(checkbox), + hasCheckbox, + "Dialog checkbox has correct visibility." + ); + + let checkboxLabel = dialogEl.ownerDocument.getElementById("remember-label"); + is( + checkbox && BrowserTestUtils.isVisible(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 + * show the permission dialog, but give the option to choose another + * app if there isn't a default handler. + */ +add_task(async function test_permission_system_principal() { + let scheme = TEST_PROTOS[0]; + await BrowserTestUtils.withNewTab(ORIGIN1, async browser => { + await testOpenProto(browser, scheme, { + permDialogOptions: { + hasCheckbox: false, + hasChangeApp: false, + chooserIsNext: true, + actionChangeApp: false, + }, + triggerLoad: useTriggeringPrincipal( + Services.scriptSecurityManager.getSystemPrincipal() + ), + }); + }); +}); + +/** + * Tests that we correctly handle system principals and show + * a simplified permission dialog if there is a default handler. + */ +add_task(async function test_permission_system_principal() { + let scheme = getSystemProtocol(); + if (!scheme) { + return; + } + await BrowserTestUtils.withNewTab(ORIGIN1, async browser => { + await testOpenProto(browser, scheme, { + permDialogOptions: { + hasCheckbox: false, + hasChangeApp: false, + chooserIsNext: false, + actionChangeApp: 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 = getSystemProtocol(); + if (!scheme) { + 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.startLoadingURIString(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..d906d9c55b --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/browser_protocolhandler_loop.js @@ -0,0 +1,77 @@ +/* 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); + + gBrowser.fixupAndLoadURIString(kProt + ":test", { + triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(), + loadFlags: Ci.nsIWebNavigation.LOAD_FLAGS_FROM_EXTERNAL, + }); + 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..46750d5c87 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/browser_save_filenames.js @@ -0,0 +1,860 @@ +// 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); +} + +// 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.importESModule( + "resource://testing-common/httpd.sys.mjs" + ); + 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 = await IOUtils.getFile( + Services.dirsvc.get("CurWorkD", Ci.nsIFile).path, + ..."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") || elem.getAttribute("href"); + let draggable = + elem.localName == "img" && elem.dataset.nodrag != "true"; + let unknown = elem.dataset.unknown; + let noattach = elem.dataset.noattach; + let savepagename = elem.dataset.savepagename; + let pickedfilename = elem.dataset.pickedfilename; + elements.push({ + draggable, + unknown, + filename, + url, + noattach, + savepagename, + pickedfilename, + }); + 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 = promiseDownloadFinished(downloadsList); + 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); + downloadsList.removeFinished(); +}); + +// 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. +const imageAsFileEnabled = SpecialPowers.getBoolPref( + "clipboard.imageAsFile.enabled", + false +); +if (AppConstants.platform == "win" && imageAsFileEnabled) { + 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 the filename is saved correctly when it +// has been modified within the file picker. +add_task(async function saveas_files_modified_in_filepicker() { + let items = await getItems("modifieditems"); + for (let idx = 0; idx < items.length; idx++) { + await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + opening: items[idx].url, + waitForLoad: false, + waitForStateStop: true, + }); + + let savedFile = SpecialPowers.Services.dirsvc.get("TmpD", Ci.nsIFile); + + let downloadsList = await Downloads.getList(Downloads.PUBLIC); + let savePromise = promiseDownloadFinished(downloadsList); + + await new Promise(resolve => { + MockFilePicker.displayDirectory = savedFile; + + MockFilePicker.showCallback = function (fp) { + MockFilePicker.filterIndex = 0; // kSaveAsType_Complete + savedFile.append(items[idx].pickedfilename); + MockFilePicker.setFiles([savedFile]); + setTimeout(() => { + resolve(items[idx].pickedfilename); + }, 0); + + return Ci.nsIFilePicker.returnOK; + }; + + document.getElementById("Browser:SavePage").doCommand(); + }); + + await savePromise; + + savedFile.leafName = items[idx].filename; + ok( + savedFile.exists(), + "i" + + idx + + " '" + + savedFile.leafName + + "' was saved when modified with the correct name " + ); + if (savedFile.exists()) { + savedFile.remove(false); + } + + 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_standalone_application_chooser_window_fallback.js b/uriloader/exthandler/tests/mochitest/browser_standalone_application_chooser_window_fallback.js new file mode 100644 index 0000000000..40496fb3b2 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/browser_standalone_application_chooser_window_fallback.js @@ -0,0 +1,67 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the fallback fixed in bug 1875460, if the modern tab dialog box is not +// supported. + +const TEST_URL = + "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 with an FTP link. + let browser = gBrowser.selectedBrowser; + BrowserTestUtils.startLoadingURIString(browser, TEST_URL); + await BrowserTestUtils.browserLoaded(browser, false, TEST_URL); + + // Make sure no handler is set, forcing the dialog to show. + let 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(0, handlers.length, "no handler registered for ftp"); + protoInfo.alwaysAskBeforeHandling = true; + let handlerSvc = Cc["@mozilla.org/uriloader/handler-service;1"].getService( + Ci.nsIHandlerService + ); + handlerSvc.store(protoInfo); + + // Delete getTabDialogBox from gBrowser, to test the fallback to the standalone + // application chooser window. + let _getTabDialogBox = gBrowser.getTabDialogBox; + delete gBrowser.getTabDialogBox; + + let appChooserDialogOpenPromise = BrowserTestUtils.domWindowOpened( + null, + async win => { + await BrowserTestUtils.waitForEvent(win, "load"); + Assert.ok( + win.document.documentURI == + "chrome://mozapps/content/handling/appChooser.xhtml", + "application chooser dialog opened" + ); + return true; + } + ); + let link = "#link"; + await BrowserTestUtils.synthesizeMouseAtCenter(link, {}, browser); + let appChooserDialog = await appChooserDialogOpenPromise; + + let appChooserDialogClosePromise = + BrowserTestUtils.domWindowClosed(appChooserDialog); + let dialog = appChooserDialog.document.getElementsByTagName("dialog")[0]; + let cancelButton = dialog.getButton("cancel"); + cancelButton.click(); + await appChooserDialogClosePromise; + + // Restore the original getTabDialogBox(), to not affect other tests. + gBrowser.getTabDialogBox = _getTabDialogBox; +}); 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..a9d4803fca --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/browser_web_handler_app_pinned_tab.js @@ -0,0 +1,85 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + UrlbarTestUtils: "resource://testing-common/UrlbarTestUtils.sys.mjs", +}); + +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.startLoadingURIString(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, + UrlbarTestUtils.trimURL(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..828d02d24b --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/browser_web_protocol_handlers.js @@ -0,0 +1,130 @@ +ChromeUtils.defineESModuleGetters(this, { + UrlbarTestUtils: "resource://testing-common/UrlbarTestUtils.sys.mjs", +}); + +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.startLoadingURIString(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, + UrlbarTestUtils.trimURL(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, + UrlbarTestUtils.trimURL(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 != UrlbarTestUtils.trimURL(testURL) + ); + is( + gURLBar.value, + UrlbarTestUtils.trimURL(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..4b973da06d --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/head.js @@ -0,0 +1,537 @@ +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 = new FileUtils.File( + PathUtils.join(PathUtils.tempDir, "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..a12bfe9735 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/mime_type_download.sjs @@ -0,0 +1,21 @@ +function handleRequest(request, response) { + "use strict"; + + 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.toml b/uriloader/exthandler/tests/mochitest/mochitest.toml new file mode 100644 index 0000000000..1e0afbcdde --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/mochitest.toml @@ -0,0 +1,25 @@ +[DEFAULT] + +["test_invalidCharFileExtension.xhtml"] +skip-if = ["os == 'android'"] # Bug 1525959 +support-files = [ + "HelperAppLauncherDialog_chromeScript.js", + "invalidCharFileExtension.sjs", +] + +["test_nullCharFile.xhtml"] +skip-if = ["os == 'android'"] # Bug 1525959 +support-files = ["HelperAppLauncherDialog_chromeScript.js"] + +["test_unknown_ext_protocol_handlers.html"] + +["test_unsafeBidiChars.xhtml"] +skip-if = [ + "os == 'android'", # Bug 1525959 + "http3", + "http2", +] +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..d12a953b57 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/protocol_custom_sandbox_helper.sjs @@ -0,0 +1,28 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +function handleRequest(request, response) { + 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..cdf6415c54 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/redirect_helper.sjs @@ -0,0 +1,28 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +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..8755186206 --- /dev/null +++ b/uriloader/exthandler/tests/mochitest/save_filenames.html @@ -0,0 +1,377 @@ +<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 filenames that are changed within the file picker. --> +<span id="modifieditems"> + +<!-- filename which is changed within the file picker --> +<a id="mod0" href="http://localhost:8000/save_filename.sjs?type=png&filename=teal.png" + data-pickedfilename='cyan.png' data-filename='cyan.png'> + +<!-- filename which is changed to an invalid filename within the file picker --> +<a id="mod1" href="http://localhost:8000/save_filename.sjs?type=png&filename=orange.png" + data-pickedfilename='"peach".png' data-filename='peach .png'> + +<!-- an invalid filename which is changed to another invalid filename within the file picker --> +<a id="mod2" href="http://localhost:8000/save_filename.sjs?type=png&filename=%maroon%34.png" + data-pickedfilename='"violet".png' data-filename="violet .png"> + +</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 + '"'); +} diff --git a/uriloader/exthandler/tests/moz.build b/uriloader/exthandler/tests/moz.build new file mode 100644 index 0000000000..95d7f5fe59 --- /dev/null +++ b/uriloader/exthandler/tests/moz.build @@ -0,0 +1,35 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/. + +MOCHITEST_MANIFESTS += ["mochitest/mochitest.toml"] + +XPCSHELL_TESTS_MANIFESTS += ["unit/xpcshell.toml"] + +BROWSER_CHROME_MANIFESTS += ["mochitest/browser.toml"] + +TEST_DIRS += [ + "gtest", +] + +TESTING_JS_MODULES += [ + "HandlerServiceTestUtils.sys.mjs", +] + +GeckoSimplePrograms( + [ + "WriteArgument", + ], + linkage=None, +) + +if CONFIG["COMPILE_ENVIRONMENT"]: + TEST_HARNESS_FILES.xpcshell.uriloader.exthandler.tests.unit += [ + "!WriteArgument%s" % CONFIG["BIN_SUFFIX"] + ] + +USE_LIBS += [ + "nspr", +] diff --git a/uriloader/exthandler/tests/unit/handlers.json b/uriloader/exthandler/tests/unit/handlers.json new file mode 100644 index 0000000000..51ce581d83 --- /dev/null +++ b/uriloader/exthandler/tests/unit/handlers.json @@ -0,0 +1,83 @@ +{ + "defaultHandlersVersion": { + "en-US": 999 + }, + "mimeTypes": { + "example/type.handleinternally": { + "unknownProperty": "preserved", + "action": 3, + "extensions": ["example_one"] + }, + "example/type.savetodisk": { + "action": 0, + "ask": true, + "handlers": [ + { + "name": "Example Default Handler", + "uriTemplate": "https://www.example.com/?url=%s" + } + ], + "extensions": ["example_two", "example_three"] + }, + "example/type.usehelperapp": { + "action": 2, + "ask": true, + "handlers": [ + { + "name": "Example Default Handler", + "uriTemplate": "https://www.example.com/?url=%s" + }, + { + "name": "Example Possible Handler One", + "uriTemplate": "http://www.example.com/?id=1&url=%s" + }, + { + "name": "Example Possible Handler Two", + "uriTemplate": "http://www.example.com/?id=2&url=%s" + } + ], + "extensions": ["example_two", "example_three"] + }, + "example/type.usesystemdefault": { + "action": 4, + "handlers": [ + null, + { + "name": "Example Possible Handler", + "uriTemplate": "http://www.example.com/?url=%s" + } + ] + } + }, + "schemes": { + "examplescheme.usehelperapp": { + "action": 2, + "ask": true, + "handlers": [ + { + "name": "Example Default Handler", + "uriTemplate": "https://www.example.com/?url=%s" + }, + { + "name": "Example Possible Handler One", + "uriTemplate": "http://www.example.com/?id=1&url=%s" + }, + { + "name": "Example Possible Handler Two", + "uriTemplate": "http://www.example.com/?id=2&url=%s" + } + ] + }, + "examplescheme.usesystemdefault": { + "action": 4, + "handlers": [ + null, + { + "name": "Example Possible Handler", + "uriTemplate": "http://www.example.com/?url=%s" + } + ] + } + }, + "isDownloadsImprovementsAlreadyMigrated": true +} diff --git a/uriloader/exthandler/tests/unit/head.js b/uriloader/exthandler/tests/unit/head.js new file mode 100644 index 0000000000..72d1ac87c5 --- /dev/null +++ b/uriloader/exthandler/tests/unit/head.js @@ -0,0 +1,84 @@ +/* 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/. */ + +/* + * Initialization for tests related to invoking external handler applications. + */ + +"use strict"; + +var { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); +var { FileUtils } = ChromeUtils.importESModule( + "resource://gre/modules/FileUtils.sys.mjs" +); +var { NetUtil } = ChromeUtils.importESModule( + "resource://gre/modules/NetUtil.sys.mjs" +); +var { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +const { HandlerServiceTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/HandlerServiceTestUtils.sys.mjs" +); +var { TestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TestUtils.sys.mjs" +); + +XPCOMUtils.defineLazyServiceGetter( + this, + "gHandlerService", + "@mozilla.org/uriloader/handler-service;1", + "nsIHandlerService" +); + +do_get_profile(); + +let jsonPath = PathUtils.join(PathUtils.profileDir, "handlers.json"); + +/** + * Unloads the nsIHandlerService data store, so the back-end file can be + * accessed or modified, and the new data will be loaded at the next access. + */ +let unloadHandlerStore = async function () { + // If this function is called before the nsIHandlerService instance has been + // initialized for the first time, the observer below will not be registered. + // We have to force initialization to prevent the function from stalling. + gHandlerService; + + let promise = TestUtils.topicObserved("handlersvc-json-replace-complete"); + Services.obs.notifyObservers(null, "handlersvc-json-replace"); + await promise; +}; + +/** + * Unloads the data store and deletes it. + */ +let deleteHandlerStore = async function () { + await unloadHandlerStore(); + + await IOUtils.remove(jsonPath, { ignoreAbsent: true }); + + Services.prefs.clearUserPref("gecko.handlerService.defaultHandlersVersion"); +}; + +/** + * Unloads the data store and replaces it with the test data file. + */ +let copyTestDataToHandlerStore = async function () { + await unloadHandlerStore(); + + await IOUtils.copy(do_get_file("handlers.json").path, jsonPath); + + Services.prefs.setIntPref("gecko.handlerService.defaultHandlersVersion", 100); +}; + +/** + * Ensures the files are removed and the services unloaded when the tests end. + */ +registerCleanupFunction(async function test_terminate() { + await deleteHandlerStore(); +}); diff --git a/uriloader/exthandler/tests/unit/mailcap b/uriloader/exthandler/tests/unit/mailcap new file mode 100644 index 0000000000..dc93ef8042 --- /dev/null +++ b/uriloader/exthandler/tests/unit/mailcap @@ -0,0 +1,2 @@ +text/plain; cat '%s'; needsterminal +text/plain; sed '%s' diff --git a/uriloader/exthandler/tests/unit/test_badMIMEType.js b/uriloader/exthandler/tests/unit/test_badMIMEType.js new file mode 100644 index 0000000000..49c5e8d848 --- /dev/null +++ b/uriloader/exthandler/tests/unit/test_badMIMEType.js @@ -0,0 +1,29 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- + * 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 run_test() { + // "text/plain" has an 0xFF character appended to it. This means it's an + // invalid string, which is tricky to enter using a text editor (I used + // emacs' hexl-mode). It also means an ordinary text editor might drop it + // or convert it to something that *is* valid (in UTF8). So we measure + // its length to make sure this hasn't happened. + var badMimeType = "text/plainÿ"; + Assert.equal(badMimeType.length, 11); + try { + Cc["@mozilla.org/mime;1"] + .getService(Ci.nsIMIMEService) + .getFromTypeAndExtension(badMimeType, "txt"); + } catch (e) { + if ( + !(e instanceof Ci.nsIException) || + e.result != Cr.NS_ERROR_NOT_AVAILABLE + ) { + throw e; + } + // This is an expected exception, thrown if the type can't be determined + } + // Not crashing is good enough + Assert.equal(true, true); +} diff --git a/uriloader/exthandler/tests/unit/test_defaults_handlerService.js b/uriloader/exthandler/tests/unit/test_defaults_handlerService.js new file mode 100644 index 0000000000..72d3779fd8 --- /dev/null +++ b/uriloader/exthandler/tests/unit/test_defaults_handlerService.js @@ -0,0 +1,202 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +XPCOMUtils.defineLazyServiceGetter( + this, + "gExternalProtocolService", + "@mozilla.org/uriloader/external-protocol-service;1", + "nsIExternalProtocolService" +); +ChromeUtils.defineESModuleGetters(this, { + kHandlerList: "resource://gre/modules/handlers/HandlerList.sys.mjs", +}); + +add_task(async function test_check_defaults_get_added() { + let protocols = Object.keys(kHandlerList.default.schemes); + for (let protocol of protocols) { + let protocolHandlerCount = + kHandlerList.default.schemes[protocol].handlers.length; + Assert.ok( + gHandlerService.wrappedJSObject._store.data.schemes[protocol].stubEntry, + `Expect stub for ${protocol}` + ); + let info = gExternalProtocolService.getProtocolHandlerInfo(protocol, {}); + Assert.ok( + info, + `Should be able to get protocol handler info for ${protocol}` + ); + let handlers = Array.from( + info.possibleApplicationHandlers.enumerate(Ci.nsIHandlerApp) + ); + handlers = handlers.filter(h => h instanceof Ci.nsIWebHandlerApp); + Assert.equal( + handlers.length, + protocolHandlerCount, + `Default web handlers for ${protocol} should match` + ); + let { alwaysAskBeforeHandling, preferredAction } = info; + // Actually store something, pretending there was a change: + let infoToWrite = gExternalProtocolService.getProtocolHandlerInfo( + protocol, + {} + ); + gHandlerService.store(infoToWrite); + ok( + !gHandlerService.wrappedJSObject._store.data.schemes[protocol].stubEntry, + "Expect stub entry info to go away" + ); + + let newInfo = gExternalProtocolService.getProtocolHandlerInfo(protocol, {}); + Assert.equal( + alwaysAskBeforeHandling, + newInfo.alwaysAskBeforeHandling, + protocol + " - always ask shouldn't change" + ); + Assert.equal( + preferredAction, + newInfo.preferredAction, + protocol + " - preferred action shouldn't change" + ); + await deleteHandlerStore(); + } +}); + +add_task(async function test_check_default_modification() { + Assert.ok( + true, + JSON.stringify(gHandlerService.wrappedJSObject._store.data.schemes.mailto) + ); + Assert.ok( + gHandlerService.wrappedJSObject._store.data.schemes.mailto.stubEntry, + "Expect stub for mailto" + ); + let mailInfo = gExternalProtocolService.getProtocolHandlerInfo("mailto", {}); + mailInfo.alwaysAskBeforeHandling = false; + mailInfo.preferredAction = Ci.nsIHandlerInfo.useSystemDefault; + gHandlerService.store(mailInfo); + Assert.ok( + !gHandlerService.wrappedJSObject._store.data.schemes.mailto.stubEntry, + "Stub entry should be removed immediately." + ); + let newMail = gExternalProtocolService.getProtocolHandlerInfo("mailto", {}); + Assert.equal(newMail.preferredAction, Ci.nsIHandlerInfo.useSystemDefault); + Assert.equal(newMail.alwaysAskBeforeHandling, false); + await deleteHandlerStore(); +}); + +add_task(async function test_migrations() { + const kTestData = [ + ["A", "http://compose.mail.yahoo.co.jp/ym/Compose?To=%s"], + ["B", "http://www.inbox.lv/rfc2368/?value=%s"], + ["C", "http://poczta.interia.pl/mh/?mailto=%s"], + ["D", "http://win.mail.ru/cgi-bin/sentmsg?mailto=%s"], + ]; + // Set up the test handlers. This doesn't use prefs like the previous test, + // because we now refuse to import insecure handler prefs. They can only + // exist if they were added into the handler store before this restriction + // was created (bug 1526890). + gHandlerService.wrappedJSObject._injectDefaultProtocolHandlers(); + let handler = gExternalProtocolService.getProtocolHandlerInfo("mailto"); + while (handler.possibleApplicationHandlers.length) { + handler.possibleApplicationHandlers.removeElementAt(0); + } + for (let [name, uriTemplate] of kTestData) { + let app = Cc["@mozilla.org/uriloader/web-handler-app;1"].createInstance( + Ci.nsIWebHandlerApp + ); + app.uriTemplate = uriTemplate; + app.name = name; + handler.possibleApplicationHandlers.appendElement(app); + } + gHandlerService.store(handler); + + // Now migrate them: + Services.prefs.setCharPref("browser.handlers.migrations", "blah,secure-mail"); + gHandlerService.wrappedJSObject._migrateProtocolHandlersIfNeeded(); + + // Now check the result: + handler = gExternalProtocolService.getProtocolHandlerInfo("mailto"); + + let expectedURIs = new Set([ + "https://mail.yahoo.co.jp/compose/?To=%s", + "https://mail.inbox.lv/compose?to=%s", + "https://poczta.interia.pl/mh/?mailto=%s", + "https://e.mail.ru/cgi-bin/sentmsg?mailto=%s", + ]); + + let possibleApplicationHandlers = Array.from( + handler.possibleApplicationHandlers.enumerate(Ci.nsIWebHandlerApp) + ); + // Set iterators are stable to deletion, so this works: + for (let expected of expectedURIs) { + for (let app of possibleApplicationHandlers) { + if (app instanceof Ci.nsIWebHandlerApp && app.uriTemplate == expected) { + Assert.ok(true, "Found handler with URI " + expected); + // ... even when we remove items. + expectedURIs.delete(expected); + break; + } + } + } + Assert.equal(expectedURIs.size, 0, "Should have seen all the expected URIs."); + + for (let app of possibleApplicationHandlers) { + if (app instanceof Ci.nsIWebHandlerApp) { + Assert.ok( + !kTestData.some(n => n[1] == app.uriTemplate), + "Should not be any of the original handlers" + ); + } + } + + Assert.ok( + !handler.preferredApplicationHandler, + "Shouldn't have preferred handler initially." + ); + await deleteHandlerStore(); +}); + +/** + * Check that non-https templates or ones without a '%s' are ignored. + */ +add_task(async function invalid_handlers_are_rejected() { + let schemes = kHandlerList.default.schemes; + schemes.myfancyinvalidstuff = { + handlers: [ + { + name: "No template at all", + }, + { + name: "Not secure", + uriTemplate: "http://example.com/%s", + }, + { + name: "No replacement percent-s bit", + uriTemplate: "https://example.com/", + }, + { + name: "Actually valid", + uriTemplate: "https://example.com/%s", + }, + ], + }; + gHandlerService.wrappedJSObject._injectDefaultProtocolHandlers(); + // Now check the result: + let handler = gExternalProtocolService.getProtocolHandlerInfo( + "myfancyinvalidstuff" + ); + + let expectedURIs = ["https://example.com/%s"]; + + Assert.deepEqual( + Array.from( + handler.possibleApplicationHandlers.enumerate(Ci.nsIWebHandlerApp), + e => e.uriTemplate + ), + expectedURIs, + "Should have seen only 1 handler added." + ); + await deleteHandlerStore(); +}); diff --git a/uriloader/exthandler/tests/unit/test_filename_sanitize.js b/uriloader/exthandler/tests/unit/test_filename_sanitize.js new file mode 100644 index 0000000000..d8ab12c266 --- /dev/null +++ b/uriloader/exthandler/tests/unit/test_filename_sanitize.js @@ -0,0 +1,398 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +// This test verifies that +// nsIMIMEService.validateFileNameForSaving sanitizes filenames +// properly with different flags. + +"use strict"; + +add_task(async function validate_filename_method() { + let mimeService = Cc["@mozilla.org/mime;1"].getService(Ci.nsIMIMEService); + + function checkFilename(filename, flags, mime = "image/png") { + return mimeService.validateFileNameForSaving(filename, mime, flags); + } + + Assert.equal(checkFilename("basicfile.png", 0), "basicfile.png"); + Assert.equal(checkFilename(" whitespace.png ", 0), "whitespace.png"); + Assert.equal( + checkFilename(" .whitespaceanddots.png...", 0), + "whitespaceanddots.png" + ); + Assert.equal( + checkFilename(" \u00a0 \u00a0 extrawhitespace.png \u00a0 \u00a0 ", 0), + "extrawhitespace.png" + ); + Assert.equal( + checkFilename(" filename with whitespace.png ", 0), + "filename with whitespace.png" + ); + Assert.equal(checkFilename("\\path.png", 0), "_path.png"); + Assert.equal( + checkFilename("\\path*and/$?~file.png", 0), + "_path and_$ ~file.png" + ); + Assert.equal( + checkFilename(" \u180e whit\u180ee.png \u180e", 0), + "whit\u180ee.png" + ); + Assert.equal(checkFilename("簡単簡単簡単", 0), "簡単簡単簡単.png"); + Assert.equal(checkFilename(" happy\u061c\u2069.png", 0), "happy__.png"); + Assert.equal( + checkFilename("12345678".repeat(31) + "abcdefgh.png", 0), + "12345678".repeat(31) + "ab.png" + ); + Assert.equal( + checkFilename("簡単".repeat(41) + ".png", 0), + "簡単".repeat(41) + ".png" + ); + Assert.equal( + checkFilename("a" + "簡単".repeat(42) + ".png", 0), + "a" + "簡単".repeat(40) + "簡.png" + ); + Assert.equal( + checkFilename("a" + "簡単".repeat(56) + ".png", 0), + "a" + "簡単".repeat(40) + ".png" + ); + Assert.equal(checkFilename("café.png", 0), "café.png"); + Assert.equal( + checkFilename("café".repeat(50) + ".png", 0), + "café".repeat(50) + ".png" + ); + Assert.equal( + checkFilename("café".repeat(51) + ".png", 0), + "café".repeat(49) + "caf.png" + ); + + Assert.equal( + checkFilename("\u{100001}\u{100002}.png", 0), + "\u{100001}\u{100002}.png" + ); + Assert.equal( + checkFilename("\u{100001}\u{100002}".repeat(31) + ".png", 0), + "\u{100001}\u{100002}".repeat(31) + ".png" + ); + Assert.equal( + checkFilename("\u{100001}\u{100002}".repeat(32) + ".png", 0), + "\u{100001}\u{100002}".repeat(30) + "\u{100001}.png" + ); + + Assert.equal( + checkFilename("noextensionfile".repeat(16), 0), + "noextensionfile".repeat(16) + ".png" + ); + Assert.equal( + checkFilename("noextensionfile".repeat(17), 0), + "noextensionfile".repeat(16) + "noextensio.png" + ); + Assert.equal( + checkFilename("noextensionfile".repeat(16) + "noextensionfil.", 0), + "noextensionfile".repeat(16) + "noextensio.png" + ); + + Assert.equal(checkFilename(" first .png ", 0), "first .png"); + Assert.equal( + checkFilename( + " second .png ", + mimeService.VALIDATE_DONT_COLLAPSE_WHITESPACE + ), + "second .png" + ); + + // For whatever reason, the Android mime handler accepts the .jpeg + // extension for image/png, so skip this test there. + if (AppConstants.platform != "android") { + Assert.equal(checkFilename("thi/*rd.jpeg", 0), "thi_ rd.png"); + } + + Assert.equal( + checkFilename("f*\\ourth file.jpg", mimeService.VALIDATE_SANITIZE_ONLY), + "f _ourth file.jpg" + ); + Assert.equal( + checkFilename( + "f*\\ift h.jpe*\\g", + mimeService.VALIDATE_SANITIZE_ONLY | + mimeService.VALIDATE_DONT_COLLAPSE_WHITESPACE + ), + "f _ift h.jpe _g" + ); + Assert.equal(checkFilename("sixth.j pe/*g", 0), "sixth.png"); + + let repeatStr = "12345678".repeat(31); + Assert.equal( + checkFilename( + repeatStr + "seventh.png", + mimeService.VALIDATE_DONT_TRUNCATE + ), + repeatStr + "seventh.png" + ); + Assert.equal( + checkFilename(repeatStr + "seventh.png", 0), + repeatStr + "se.png" + ); + + // no filename, so index is used by default. + Assert.equal(checkFilename(".png", 0), "png.png"); + + // sanitization only, so Untitled is not added, but initial period is stripped. + Assert.equal( + checkFilename(".png", mimeService.VALIDATE_SANITIZE_ONLY), + "png" + ); + + // correct .png extension is applied. + Assert.equal(checkFilename(".butterpecan.icecream", 0), "butterpecan.png"); + + // sanitization only, so extension is not modified, but initial period is stripped. + Assert.equal( + checkFilename(".butterpecan.icecream", mimeService.VALIDATE_SANITIZE_ONLY), + "butterpecan.icecream" + ); + + let ext = ".fairlyLongExtension"; + Assert.equal( + checkFilename(repeatStr + ext, mimeService.VALIDATE_SANITIZE_ONLY), + repeatStr.substring(0, 254 - ext.length) + ext + ); + + ext = "lo%?n/ginvalid? ch\\ars"; + Assert.equal( + checkFilename(repeatStr + ext, mimeService.VALIDATE_SANITIZE_ONLY), + repeatStr + "lo% n_" + ); + + ext = ".long/invalid%? ch\\ars"; + Assert.equal( + checkFilename(repeatStr + ext, mimeService.VALIDATE_SANITIZE_ONLY), + repeatStr.substring(0, 233) + ".long_invalid% ch_ars" + ); + + Assert.equal( + checkFilename("test_テスト_T\x83E\\S\x83T.png", 0), + "test_テスト_T E_S T.png" + ); + Assert.equal( + checkFilename("test_テスト_T\x83E\\S\x83T.pテ\x83ng", 0), + "test_テスト_T E_S T.png" + ); + + // Check we don't invalidate surrogate pairs when trimming. + Assert.equal(checkFilename("test😀", 0, ""), "test😀"); + Assert.equal(checkFilename("test😀😀", 0, ""), "test😀😀"); + + // Now check some media types + Assert.equal( + mimeService.validateFileNameForSaving("video.ogg", "video/ogg", 0), + "video.ogg", + "video.ogg" + ); + Assert.equal( + mimeService.validateFileNameForSaving("video.ogv", "video/ogg", 0), + "video.ogv", + "video.ogv" + ); + Assert.equal( + mimeService.validateFileNameForSaving("video.ogt", "video/ogg", 0), + "video.ogv", + "video.ogt" + ); + + Assert.equal( + mimeService.validateFileNameForSaving("audio.mp3", "audio/mpeg", 0), + "audio.mp3", + "audio.mp3" + ); + Assert.equal( + mimeService.validateFileNameForSaving("audio.mpega", "audio/mpeg", 0), + "audio.mpega", + "audio.mpega" + ); + Assert.equal( + mimeService.validateFileNameForSaving("audio.mp2", "audio/mpeg", 0), + "audio.mp2", + "audio.mp2" + ); + + let expected = "audio.mp3"; + if (AppConstants.platform == "linux") { + expected = "audio.mpga"; + } else if (AppConstants.platform == "android") { + expected = "audio.mp4"; + } + + Assert.equal( + mimeService.validateFileNameForSaving("audio.mp4", "audio/mpeg", 0), + expected, + "audio.mp4" + ); + + Assert.equal( + mimeService.validateFileNameForSaving("sound.m4a", "audio/mp4", 0), + "sound.m4a", + "sound.m4a" + ); + Assert.equal( + mimeService.validateFileNameForSaving("sound.m4b", "audio/mp4", 0), + AppConstants.platform == "android" ? "sound.m4a" : "sound.m4b", + "sound.m4b" + ); + Assert.equal( + mimeService.validateFileNameForSaving("sound.m4c", "audio/mp4", 0), + AppConstants.platform == "macosx" ? "sound.mp4" : "sound.m4a", + "sound.mpc" + ); + + // This has a long filename with a 13 character extension. The end of the filename should be + // cropped to fit into 255 bytes. + Assert.equal( + mimeService.validateFileNameForSaving( + "라이브9.9만 시청컬처렐 다이제스티브 3박스 - 3박스 더 (뚱랑이 굿즈 증정) - 선물용 쇼핑백 2장컬처렐 다이제스티브 3박스 - 3박스 더 (뚱랑이 굿즈 증정) - 선물용 쇼핑백 2장24%102 000원 브랜드데이 앵콜 🎁 1.등 유산균 컬처렐 특가!", + "text/unknown", + mimeService.VALIDATE_SANITIZE_ONLY + ), + "라이브9.9만 시청컬처렐 다이제스티브 3박스 - 3박스 더 (뚱랑이 굿즈 증정) - 선물용 쇼핑백 2장컬처렐 다이제스티브 3박스 - 3박스 더 (뚱랑이 굿즈 .등 유산균 컬처렐 특가!", + "very long filename with extension" + ); + + // This filename has a very long extension, almost the entire filename. + Assert.equal( + mimeService.validateFileNameForSaving( + "라이브9.9만 시청컬처렐 다이제스티브 3박스 - 3박스 더 (뚱랑이 굿즈 증정) - 선물용 쇼핑백 2장컬처렐 다이제스티브 3박스 - 3박스 더 (뚱랑이 굿즈 증정) - 선물용 쇼핑백 2장24%102 000원 브랜드데이 앵콜 🎁 1등 유산균 컬처렐 특가!", + "text/unknown", + mimeService.VALIDATE_SANITIZE_ONLY + ), + "라이브9", + "another very long filename with long extension" + ); + + // This filename is cropped at 254 bytes. + Assert.equal( + mimeService.validateFileNameForSaving( + ".라이브99만 시청컬처렐 다이제스티브 3박스 - 3박스 더 (뚱랑이 굿즈 증정) - 선물용 쇼핑백 2장컬처렐 다이제스티브 3박스 - 3박스 더 (뚱랑이 굿즈 증정) - 선물용 쇼핑백 2장24%102 000원 브랜드데이 앵콜 🎁 1등 유산균 컬처렐 특가!", + "text/unknown", + mimeService.VALIDATE_SANITIZE_ONLY + ), + "라이브99만 시청컬처렐 다이제스티브 3박스 - 3박스 더 (뚱랑이 굿즈 증정) - 선물용 쇼핑백 2장컬처렐 다이제스티브 3박스 - 3박스 더 (뚱랑이 굿즈 증정) - 선물용 쇼핑백 2장24%102 000원 브랜드데", + "very filename with extension only" + ); + + Assert.equal( + mimeService.validateFileNameForSaving("filename.LNK", "text/unknown", 0), + "filename.LNK.download", + "filename.LNK" + ); + Assert.equal( + mimeService.validateFileNameForSaving("filename.local", "text/unknown", 0), + "filename.local.download", + "filename.local" + ); + Assert.equal( + mimeService.validateFileNameForSaving("filename.url", "text/unknown", 0), + "filename.url.download", + "filename.url" + ); + Assert.equal( + mimeService.validateFileNameForSaving("filename.URl", "text/unknown", 0), + "filename.URl.download", + "filename.URl" + ); + Assert.equal( + mimeService.validateFileNameForSaving("filename.scf", "text/unknown", 0), + "filename.scf.download", + "filename.scf" + ); + Assert.equal( + mimeService.validateFileNameForSaving("filename.sCF", "text/unknown", 0), + "filename.sCF.download", + "filename.sCF" + ); + + Assert.equal( + mimeService.validateFileNameForSaving("filename.lnk\n", "text/unknown", 0), + "filename.lnk.download", + "filename.lnk with newline" + ); + + Assert.equal( + mimeService.validateFileNameForSaving( + "filename.lnk\n ", + "text/unknown", + 0 + ), + "filename.lnk.download", + "filename.lnk with newline" + ); + + Assert.equal( + mimeService.validateFileNameForSaving( + "filename.\n\t lnk", + "text/unknown", + 0 + ), + "filename. lnk", + "filename.lnk with space and newline" + ); + + Assert.equal( + mimeService.validateFileNameForSaving( + "filename.local\u180e\u180e\u180e", + "text/unknown", + 0 + ), + "filename.local.download", + "filename.lnk with vowel separators" + ); + + Assert.equal( + mimeService.validateFileNameForSaving( + "filename.LNK", + "text/unknown", + mimeService.VALIDATE_SANITIZE_ONLY + ), + "filename.LNK.download", + "filename.LNK sanitize only" + ); + + Assert.equal( + mimeService.validateFileNameForSaving( + "filename.LNK\n", + "text/unknown", + mimeService.VALIDATE_ALLOW_INVALID_FILENAMES + ), + "filename.LNK", + "filename.LNK allow invalid" + ); + + Assert.equal( + mimeService.validateFileNameForSaving( + "filename.URL\n", + "text/unknown", + mimeService.VALIDATE_SANITIZE_ONLY | + mimeService.VALIDATE_ALLOW_INVALID_FILENAMES + ), + "filename.URL", + "filename.URL allow invalid, sanitize only" + ); + + Assert.equal( + mimeService.validateFileNameForSaving( + "filename.desktop", + "text/unknown", + mimeService.VALIDATE_SANITIZE_ONLY + ), + "filename.desktop.download", + "filename.desktop sanitize only" + ); + Assert.equal( + mimeService.validateFileNameForSaving( + "filename.DESKTOP\n", + "text/unknown", + mimeService.VALIDATE_SANITIZE_ONLY | + mimeService.VALIDATE_ALLOW_INVALID_FILENAMES + ), + "filename.DESKTOP", + "filename.DESKTOP allow invalid, sanitize only" + ); +}); diff --git a/uriloader/exthandler/tests/unit/test_getFromTypeAndExtension.js b/uriloader/exthandler/tests/unit/test_getFromTypeAndExtension.js new file mode 100644 index 0000000000..6f4fe52a49 --- /dev/null +++ b/uriloader/exthandler/tests/unit/test_getFromTypeAndExtension.js @@ -0,0 +1,17 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function test_utf8_extension() { + const mimeService = Cc["@mozilla.org/mime;1"].getService(Ci.nsIMIMEService); + let someMIME = mimeService.getFromTypeAndExtension( + "application/x-nonsense", + ".тест" + ); + Assert.stringContains(someMIME.description, "тест"); + // primary extension isn't set on macOS or android, see bug 1721181 + if (AppConstants.platform != "macosx" && AppConstants.platform != "android") { + Assert.equal(someMIME.primaryExtension, ".тест"); + } +}); diff --git a/uriloader/exthandler/tests/unit/test_getMIMEInfo_pdf.js b/uriloader/exthandler/tests/unit/test_getMIMEInfo_pdf.js new file mode 100644 index 0000000000..03b0cea25e --- /dev/null +++ b/uriloader/exthandler/tests/unit/test_getMIMEInfo_pdf.js @@ -0,0 +1,30 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +XPCOMUtils.defineLazyServiceGetter( + this, + "gMIMEService", + "@mozilla.org/mime;1", + "nsIMIMEService" +); + +// PDF files should always have a generic description instead +// of relying on what is registered with the Operating System. +add_task(async function test_check_unknown_mime_type() { + const mimeService = Cc["@mozilla.org/mime;1"].getService(Ci.nsIMIMEService); + let pdfType = mimeService.getTypeFromExtension("pdf"); + Assert.equal(pdfType, "application/pdf"); + let extension = mimeService.getPrimaryExtension("application/pdf", ""); + Assert.equal(extension, "pdf", "Expect pdf extension when given mime"); + let mimeInfo = gMIMEService.getFromTypeAndExtension("", "pdf"); + let stringBundle = Services.strings.createBundle( + "chrome://mozapps/locale/downloads/unknownContentType.properties" + ); + Assert.equal( + mimeInfo.description, + stringBundle.GetStringFromName("pdfExtHandlerDescription"), + "PDF has generic description" + ); +}); diff --git a/uriloader/exthandler/tests/unit/test_getMIMEInfo_unknown_mime_type.js b/uriloader/exthandler/tests/unit/test_getMIMEInfo_unknown_mime_type.js new file mode 100644 index 0000000000..9beef9d9c5 --- /dev/null +++ b/uriloader/exthandler/tests/unit/test_getMIMEInfo_unknown_mime_type.js @@ -0,0 +1,32 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Zip files can be opened by Windows explorer, so we should always be able to +// determine a description and default handler for them. However, things can +// get messy if they are sent to us with a mime type other than what Windows +// considers the "right" mimetype (application/x-zip-compressed), like +// application/zip, which is what most places (IANA, macOS, probably all linux +// distros, Apache, etc.) think is the "right" mimetype. +add_task(async function test_check_unknown_mime_type() { + const mimeService = Cc["@mozilla.org/mime;1"].getService(Ci.nsIMIMEService); + let zipType = mimeService.getTypeFromExtension("zip"); + Assert.equal(zipType, "application/x-zip-compressed"); + try { + let extension = mimeService.getPrimaryExtension("application/zip", ""); + Assert.equal( + extension, + "zip", + "Expect our own info to provide an extension for zip files." + ); + } catch (ex) { + Assert.ok(false, "We shouldn't throw when getting zip info."); + } + let found = {}; + let mimeInfo = mimeService.getMIMEInfoFromOS("application/zip", "zip", found); + Assert.ok( + mimeInfo.hasDefaultHandler, + "Should have a default app for zip files" + ); +}); diff --git a/uriloader/exthandler/tests/unit/test_getTypeFromExtension_ext_to_type_mapping.js b/uriloader/exthandler/tests/unit/test_getTypeFromExtension_ext_to_type_mapping.js new file mode 100644 index 0000000000..7202db58de --- /dev/null +++ b/uriloader/exthandler/tests/unit/test_getTypeFromExtension_ext_to_type_mapping.js @@ -0,0 +1,65 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- + * 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/. */ + +/** + * Test for bug 508030 <https://bugzilla.mozilla.org/show_bug.cgi?id=508030>: + * nsIMIMEService.getTypeFromExtension fails to find a match in the + * "ext-to-type-mapping" category if the provided extension is not lowercase. + */ +function run_test() { + // --- Common services --- + + const mimeService = Cc["@mozilla.org/mime;1"].getService(Ci.nsIMIMEService); + + const categoryManager = Services.catMan; + + // --- Test procedure --- + + const kTestExtension = "testextension"; + const kTestExtensionMixedCase = "testExtensIon"; + const kTestMimeType = "application/x-testextension"; + + // Ensure that the test extension is not initially recognized by the operating + // system or the "ext-to-type-mapping" category. + try { + // Try and get the MIME type associated with the extension. + mimeService.getTypeFromExtension(kTestExtension); + // The line above should have thrown an exception. + do_throw("nsIMIMEService.getTypeFromExtension succeeded unexpectedly"); + } catch (e) { + if ( + !(e instanceof Ci.nsIException) || + e.result != Cr.NS_ERROR_NOT_AVAILABLE + ) { + throw e; + } + // This is an expected exception, thrown if the type can't be determined. + // Any other exception would cause the test to fail. + } + + // Add a temporary category entry mapping the extension to the MIME type. + categoryManager.addCategoryEntry( + "ext-to-type-mapping", + kTestExtension, + kTestMimeType, + false, + true + ); + + // Check that the mapping is recognized in the simple case. + var type = mimeService.getTypeFromExtension(kTestExtension); + Assert.equal(type, kTestMimeType); + + // Check that the mapping is recognized even if the extension has mixed case. + type = mimeService.getTypeFromExtension(kTestExtensionMixedCase); + Assert.equal(type, kTestMimeType); + + // Clean up after ourselves. + categoryManager.deleteCategoryEntry( + "ext-to-type-mapping", + kTestExtension, + false + ); +} diff --git a/uriloader/exthandler/tests/unit/test_getTypeFromExtension_with_empty_Content_Type.js b/uriloader/exthandler/tests/unit/test_getTypeFromExtension_with_empty_Content_Type.js new file mode 100644 index 0000000000..5076a57738 --- /dev/null +++ b/uriloader/exthandler/tests/unit/test_getTypeFromExtension_with_empty_Content_Type.js @@ -0,0 +1,218 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- + * 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/. */ + +/** + * Test for bug 484579 <https://bugzilla.mozilla.org/show_bug.cgi?id=484579>: + * nsIMIMEService.getTypeFromExtension may fail unexpectedly on Windows when + * "Content Type" is empty in the registry. + */ + +// We must use a file extension that isn't listed in nsExternalHelperAppService's +// defaultMimeEntries, otherwise the code takes a shortcut skipping the registry. +const FILE_EXTENSION = ".nfo"; +// This is used to ensure the test properly used the mock, so that if we change +// the underlying code, it won't be skipped. +let gTestUsedOurMock = false; + +function run_test() { + // Activate the override of the file association data in the registry. + registerMockWindowsRegKeyFactory(); + + // Check the mock has been properly activated. + let regKey = Cc["@mozilla.org/windows-registry-key;1"].createInstance( + Ci.nsIWindowsRegKey + ); + regKey.open( + Ci.nsIWindowsRegKey.ROOT_KEY_CLASSES_ROOT, + FILE_EXTENSION, + Ci.nsIWindowsRegKey.ACCESS_QUERY_VALUE + ); + Assert.equal( + regKey.readStringValue("content type"), + "", + "Check the mock replied as expected." + ); + Assert.ok(gTestUsedOurMock, "The test properly used the mock registry"); + // Reset gTestUsedOurMock, because we just used it. + gTestUsedOurMock = false; + // Try and get the MIME type associated with the extension. If this + // operation does not throw an unexpected exception, the test succeeds. + Assert.throws( + () => { + Cc["@mozilla.org/mime;1"] + .getService(Ci.nsIMIMEService) + .getTypeFromExtension(FILE_EXTENSION); + }, + /NS_ERROR_NOT_AVAILABLE/, + "Should throw a NOT_AVAILABLE exception" + ); + + Assert.ok(gTestUsedOurMock, "The test properly used the mock registry"); +} + +/** + * Constructs a new mock registry key by wrapping the provided object. + * + * This mock implementation is tailored for this test, and forces consumers + * of the readStringValue method to believe that the "Content Type" value of + * the FILE_EXTENSION key under HKEY_CLASSES_ROOT is an empty string. + * + * The same value read from "HKEY_LOCAL_MACHINE\SOFTWARE\Classes" is not + * affected. + * + * @param aWrappedObject An actual nsIWindowsRegKey implementation. + */ +function MockWindowsRegKey(aWrappedObject) { + this._wrappedObject = aWrappedObject; + + // This function creates a forwarding function for wrappedObject + function makeForwardingFunction(functionName) { + return function () { + return aWrappedObject[functionName].apply(aWrappedObject, arguments); + }; + } + + // Forward all the functions that are not explicitly overridden + for (var propertyName in aWrappedObject) { + if (!(propertyName in this)) { + if (typeof aWrappedObject[propertyName] == "function") { + this[propertyName] = makeForwardingFunction(propertyName); + } else { + this[propertyName] = aWrappedObject[propertyName]; + } + } + } +} + +MockWindowsRegKey.prototype = { + // --- Overridden nsISupports interface functions --- + + QueryInterface: ChromeUtils.generateQI(["nsIWindowsRegKey"]), + + // --- Overridden nsIWindowsRegKey interface functions --- + + open(aRootKey, aRelPath, aMode) { + // Remember the provided root key and path + this._rootKey = aRootKey; + this._relPath = aRelPath; + + // Create the actual registry key + return this._wrappedObject.open(aRootKey, aRelPath, aMode); + }, + + openChild(aRelPath, aMode) { + // Open the child key and wrap it + var innerKey = this._wrappedObject.openChild(aRelPath, aMode); + var key = new MockWindowsRegKey(innerKey); + + // Set the properties of the child key and return it + key._rootKey = this._rootKey; + key._relPath = this._relPath + aRelPath; + return key; + }, + + createChild(aRelPath, aMode) { + // Create the child key and wrap it + var innerKey = this._wrappedObject.createChild(aRelPath, aMode); + var key = new MockWindowsRegKey(innerKey); + + // Set the properties of the child key and return it + key._rootKey = this._rootKey; + key._relPath = this._relPath + aRelPath; + return key; + }, + + get childCount() { + return this._wrappedObject.childCount; + }, + + get valueCount() { + return this._wrappedObject.valueCount; + }, + + readStringValue(aName) { + // If this is the key under test, return a fake value + if ( + this._rootKey == Ci.nsIWindowsRegKey.ROOT_KEY_CLASSES_ROOT && + this._relPath.toLowerCase() == FILE_EXTENSION && + aName.toLowerCase() == "content type" + ) { + gTestUsedOurMock = true; + return ""; + } + // Return the real value from the registry + return this._wrappedObject.readStringValue(aName); + }, +}; + +function registerMockWindowsRegKeyFactory() { + const kMockCID = Components.ID("{9b23dfe9-296b-4740-ba1c-d39c9a16e55e}"); + const kWindowsRegKeyContractID = "@mozilla.org/windows-registry-key;1"; + // Preserve the original CID. + let originalWindowsRegKeyCID = Cc[kWindowsRegKeyContractID].number; + + // See bug 1694345 - nsNotifyAddrListener::CheckAdaptersAddresses might + // attempt to use the registry off the main thread, so we disable that + // feature while the mock registry is active. + let oldSuffixListPref = Services.prefs.getBoolPref( + "network.notify.dnsSuffixList" + ); + Services.prefs.setBoolPref("network.notify.dnsSuffixList", false); + + let oldCheckForProxiesPref = Services.prefs.getBoolPref( + "network.notify.checkForProxies" + ); + Services.prefs.setBoolPref("network.notify.checkForProxies", false); + + let oldCheckForNRPTPref = Services.prefs.getBoolPref( + "network.notify.checkForNRPT" + ); + Services.prefs.setBoolPref("network.notify.checkForNRPT", false); + + info("Create a mock RegKey factory"); + let originalRegKey = Cc["@mozilla.org/windows-registry-key;1"].createInstance( + Ci.nsIWindowsRegKey + ); + let mockWindowsRegKeyFactory = { + createInstance(iid) { + info("Create a mock wrapper around RegKey"); + var key = new MockWindowsRegKey(originalRegKey); + return key.QueryInterface(iid); + }, + }; + info("Register the mock RegKey factory"); + let registrar = Components.manager.QueryInterface(Ci.nsIComponentRegistrar); + registrar.registerFactory( + kMockCID, + "Mock Windows Registry Key Implementation", + kWindowsRegKeyContractID, + mockWindowsRegKeyFactory + ); + + registerCleanupFunction(() => { + // Free references to the mock factory + registrar.unregisterFactory(kMockCID, mockWindowsRegKeyFactory); + // Restore the original factory + registrar.registerFactory( + Components.ID(originalWindowsRegKeyCID), + "", + kWindowsRegKeyContractID, + null + ); + + Services.prefs.setBoolPref( + "network.notify.dnsSuffixList", + oldSuffixListPref + ); + Services.prefs.setBoolPref( + "network.notify.checkForProxies", + oldCheckForProxiesPref + ); + Services.prefs.setBoolPref( + "network.notify.checkForNRPT", + oldCheckForNRPTPref + ); + }); +} diff --git a/uriloader/exthandler/tests/unit/test_handlerService.js b/uriloader/exthandler/tests/unit/test_handlerService.js new file mode 100644 index 0000000000..46a4d9fdd9 --- /dev/null +++ b/uriloader/exthandler/tests/unit/test_handlerService.js @@ -0,0 +1,467 @@ +/* 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 run_test() { + //* *************************************************************************// + // Constants + + const handlerSvc = Cc["@mozilla.org/uriloader/handler-service;1"].getService( + Ci.nsIHandlerService + ); + + const mimeSvc = Cc["@mozilla.org/mime;1"].getService(Ci.nsIMIMEService); + + const protoSvc = Cc[ + "@mozilla.org/uriloader/external-protocol-service;1" + ].getService(Ci.nsIExternalProtocolService); + + const prefSvc = Services.prefs; + + let noMailto = false; + if (mozinfo.os == "win") { + // Check mailto handler from registry. + // If registry entry is nothing, no mailto handler + let regSvc = Cc["@mozilla.org/windows-registry-key;1"].createInstance( + Ci.nsIWindowsRegKey + ); + try { + regSvc.open(regSvc.ROOT_KEY_CLASSES_ROOT, "mailto", regSvc.ACCESS_READ); + noMailto = false; + } catch (ex) { + noMailto = true; + } + regSvc.close(); + } + + if (mozinfo.os == "linux") { + // Check mailto handler from GIO + // If there isn't one, then we have no mailto handler + let gIOSvc = Cc["@mozilla.org/gio-service;1"].createInstance( + Ci.nsIGIOService + ); + try { + gIOSvc.getAppForURIScheme("mailto"); + noMailto = false; + } catch (ex) { + noMailto = true; + } + } + + //* *************************************************************************// + // Sample Data + + // It doesn't matter whether or not this nsIFile is actually executable, + // only that it has a path and exists. Since we don't know any executable + // that exists on all platforms (except possibly the application being + // tested, but there doesn't seem to be a way to get a reference to that + // from the directory service), we use the temporary directory itself. + var executable = Services.dirsvc.get("TmpD", Ci.nsIFile); + // XXX We could, of course, create an actual executable in the directory: + // executable.append("localhandler"); + // if (!executable.exists()) + // executable.create(Ci.nsIFile.NORMAL_FILE_TYPE, 0o755); + + var localHandler = Cc[ + "@mozilla.org/uriloader/local-handler-app;1" + ].createInstance(Ci.nsILocalHandlerApp); + localHandler.name = "Local Handler"; + localHandler.executable = executable; + + var webHandler = Cc[ + "@mozilla.org/uriloader/web-handler-app;1" + ].createInstance(Ci.nsIWebHandlerApp); + webHandler.name = "Web Handler"; + webHandler.uriTemplate = "http://www.example.com/?%s"; + + // FIXME: these tests create and manipulate enough variables that it would + // make sense to move each test into its own scope so we don't run the risk + // of one test stomping on another's data. + + //* *************************************************************************// + // Test Default Properties + + // Get a handler info for a MIME type that neither the application nor + // the OS knows about and make sure its properties are set to the proper + // default values. + + var handlerInfo = mimeSvc.getFromTypeAndExtension("nonexistent/type", null); + + // Make sure it's also an nsIHandlerInfo. + Assert.ok(handlerInfo instanceof Ci.nsIHandlerInfo); + + Assert.equal(handlerInfo.type, "nonexistent/type"); + + // Deprecated property, but we should still make sure it's set correctly. + Assert.equal(handlerInfo.MIMEType, "nonexistent/type"); + + // These properties are the ones the handler service knows how to store. + Assert.equal(handlerInfo.preferredAction, Ci.nsIHandlerInfo.saveToDisk); + Assert.equal(handlerInfo.preferredApplicationHandler, null); + Assert.equal(handlerInfo.possibleApplicationHandlers.length, 0); + Assert.equal( + handlerInfo.alwaysAskBeforeHandling, + prefSvc.getBoolPref( + "browser.download.always_ask_before_handling_new_types", + false + ) + ); + + // These properties are initialized to default values by the service, + // so we might as well make sure they're initialized to the right defaults. + Assert.equal(handlerInfo.description, ""); + Assert.equal(handlerInfo.hasDefaultHandler, false); + Assert.equal(handlerInfo.defaultDescription, ""); + + const kExternalWarningDefault = + "network.protocol-handler.warn-external-default"; + prefSvc.setBoolPref(kExternalWarningDefault, true); + + // XXX add more thorough protocol info property checking + + // no OS default handler exists + var protoInfo = protoSvc.getProtocolHandlerInfo("x-moz-rheet"); + Assert.equal(protoInfo.preferredAction, protoInfo.alwaysAsk); + Assert.ok(protoInfo.alwaysAskBeforeHandling); + + // OS default exists, injected default does not exist, + // explicit warning pref: false + const kExternalWarningPrefPrefix = "network.protocol-handler.warn-external."; + prefSvc.setBoolPref(kExternalWarningPrefPrefix + "http", false); + protoInfo = protoSvc.getProtocolHandlerInfo("http"); + Assert.equal(0, protoInfo.possibleApplicationHandlers.length); + // NOTE: this assertion will fail if the system executing the test does not + // have a handler registered for the http protocol. This is very unlikely to + // actually happen except on certain configurations of Linux, but one of + // those configurations is the default WSL Ubuntu install. So, if you are + // running this test locally and seeing a failure here, it might not be + // anything to really worry about. + Assert.ok(!protoInfo.alwaysAskBeforeHandling); + + // OS default exists, injected default does not exist, + // explicit warning pref: true + prefSvc.setBoolPref(kExternalWarningPrefPrefix + "http", true); + protoInfo = protoSvc.getProtocolHandlerInfo("http"); + // OS handler isn't included in possibleApplicationHandlers, so length is 0 + // Once they become instances of nsILocalHandlerApp, this number will need + // to change. + Assert.equal(0, protoInfo.possibleApplicationHandlers.length); + Assert.ok(protoInfo.alwaysAskBeforeHandling); + + // OS default exists, injected default exists, explicit warning pref: false + prefSvc.setBoolPref(kExternalWarningPrefPrefix + "mailto", false); + protoInfo = protoSvc.getProtocolHandlerInfo("mailto"); + if (AppConstants.MOZ_APP_NAME == "thunderbird") { + Assert.equal(0, protoInfo.possibleApplicationHandlers.length); + } else { + Assert.equal(1, protoInfo.possibleApplicationHandlers.length); + } + + // Win7+ or Linux's GIO might not have a default mailto: handler + if (noMailto) { + Assert.ok(protoInfo.alwaysAskBeforeHandling); + } else { + Assert.ok(!protoInfo.alwaysAskBeforeHandling); + } + + // OS default exists, injected default exists, explicit warning pref: true + prefSvc.setBoolPref(kExternalWarningPrefPrefix + "mailto", true); + protoInfo = protoSvc.getProtocolHandlerInfo("mailto"); + if (AppConstants.MOZ_APP_NAME == "thunderbird") { + Assert.equal(0, protoInfo.possibleApplicationHandlers.length); + } else { + Assert.equal(1, protoInfo.possibleApplicationHandlers.length); + // Win7+ or Linux's GIO may have no default mailto: handler, so we'd ask + // anyway. Otherwise, the default handlers will not have stored preferred + // actions etc., so re-requesting them after the warning pref has changed + // will use the updated pref value. So both when we have and do not have + // a default mailto: handler, we'll ask: + Assert.ok(protoInfo.alwaysAskBeforeHandling); + // As soon as anyone actually stores updated defaults into the profile + // database, that default will stop tracking the warning pref. + } + // Now set the value stored in RDF to true, and the pref to false, to make + // sure we still get the right value. (Basically, same thing as above but + // with the values reversed.) + prefSvc.setBoolPref(kExternalWarningPrefPrefix + "mailto", false); + protoInfo.alwaysAskBeforeHandling = true; + handlerSvc.store(protoInfo); + protoInfo = protoSvc.getProtocolHandlerInfo("mailto"); + if (AppConstants.MOZ_APP_NAME == "thunderbird") { + Assert.equal(0, protoInfo.possibleApplicationHandlers.length); + } else { + Assert.equal(1, protoInfo.possibleApplicationHandlers.length); + Assert.ok(protoInfo.alwaysAskBeforeHandling); + } + + //* *************************************************************************// + // Test Round-Trip Data Integrity + + // Test round-trip data integrity by setting the properties of the handler + // info object to different values, telling the handler service to store the + // object, and then retrieving a new info object for the same type and making + // sure its properties are identical. + + handlerInfo.preferredAction = Ci.nsIHandlerInfo.useHelperApp; + handlerInfo.preferredApplicationHandler = localHandler; + handlerInfo.alwaysAskBeforeHandling = false; + + handlerSvc.store(handlerInfo); + + handlerInfo = mimeSvc.getFromTypeAndExtension("nonexistent/type", null); + + Assert.equal(handlerInfo.preferredAction, Ci.nsIHandlerInfo.useHelperApp); + + Assert.notEqual(handlerInfo.preferredApplicationHandler, null); + var preferredHandler = handlerInfo.preferredApplicationHandler; + Assert.equal(typeof preferredHandler, "object"); + Assert.equal(preferredHandler.name, "Local Handler"); + Assert.ok(preferredHandler instanceof Ci.nsILocalHandlerApp); + preferredHandler.QueryInterface(Ci.nsILocalHandlerApp); + Assert.equal(preferredHandler.executable.path, localHandler.executable.path); + + Assert.ok(!handlerInfo.alwaysAskBeforeHandling); + + // Make sure the handler service's enumerate method lists all known handlers. + var handlerInfo2 = mimeSvc.getFromTypeAndExtension("nonexistent/type2", null); + handlerSvc.store(handlerInfo2); + var handlerTypes = ["nonexistent/type", "nonexistent/type2"]; + handlerTypes.push("mailto"); + for (let handler of handlerSvc.enumerate()) { + Assert.notEqual(handlerTypes.indexOf(handler.type), -1); + handlerTypes.splice(handlerTypes.indexOf(handler.type), 1); + } + Assert.equal(handlerTypes.length, 0); + // Make sure the handler service's remove method removes a handler record. + handlerSvc.remove(handlerInfo2); + let handlers = handlerSvc.enumerate(); + while (handlers.hasMoreElements()) { + Assert.notEqual( + handlers.getNext().QueryInterface(Ci.nsIHandlerInfo).type, + handlerInfo2.type + ); + } + + // Make sure we can store and retrieve a handler info object with no preferred + // handler. + var noPreferredHandlerInfo = mimeSvc.getFromTypeAndExtension( + "nonexistent/no-preferred-handler", + null + ); + handlerSvc.store(noPreferredHandlerInfo); + noPreferredHandlerInfo = mimeSvc.getFromTypeAndExtension( + "nonexistent/no-preferred-handler", + null + ); + Assert.equal(noPreferredHandlerInfo.preferredApplicationHandler, null); + + // Make sure that the handler service removes an existing handler record + // if we store a handler info object with no preferred handler. + var removePreferredHandlerInfo = mimeSvc.getFromTypeAndExtension( + "nonexistent/rem-preferred-handler", + null + ); + removePreferredHandlerInfo.preferredApplicationHandler = localHandler; + handlerSvc.store(removePreferredHandlerInfo); + removePreferredHandlerInfo = mimeSvc.getFromTypeAndExtension( + "nonexistent/rem-preferred-handler", + null + ); + removePreferredHandlerInfo.preferredApplicationHandler = null; + handlerSvc.store(removePreferredHandlerInfo); + removePreferredHandlerInfo = mimeSvc.getFromTypeAndExtension( + "nonexistent/rem-preferred-handler", + null + ); + Assert.equal(removePreferredHandlerInfo.preferredApplicationHandler, null); + + // Make sure we can store and retrieve a handler info object with possible + // handlers. We test both adding and removing handlers. + + // Get a handler info and make sure it has no possible handlers. + var possibleHandlersInfo = mimeSvc.getFromTypeAndExtension( + "nonexistent/possible-handlers", + null + ); + Assert.equal(possibleHandlersInfo.possibleApplicationHandlers.length, 0); + + // Store and re-retrieve the handler and make sure it still has no possible + // handlers. + handlerSvc.store(possibleHandlersInfo); + possibleHandlersInfo = mimeSvc.getFromTypeAndExtension( + "nonexistent/possible-handlers", + null + ); + Assert.equal(possibleHandlersInfo.possibleApplicationHandlers.length, 0); + + // Add two handlers, store the object, re-retrieve it, and make sure it has + // two handlers. + possibleHandlersInfo.possibleApplicationHandlers.appendElement(localHandler); + possibleHandlersInfo.possibleApplicationHandlers.appendElement(webHandler); + handlerSvc.store(possibleHandlersInfo); + possibleHandlersInfo = mimeSvc.getFromTypeAndExtension( + "nonexistent/possible-handlers", + null + ); + Assert.equal(possibleHandlersInfo.possibleApplicationHandlers.length, 2); + + // Figure out which is the local and which is the web handler and the index + // in the array of the local handler, which is the one we're going to remove + // to test removal of a handler. + var handler1 = + possibleHandlersInfo.possibleApplicationHandlers.queryElementAt( + 0, + Ci.nsIHandlerApp + ); + var handler2 = + possibleHandlersInfo.possibleApplicationHandlers.queryElementAt( + 1, + Ci.nsIHandlerApp + ); + var localPossibleHandler, webPossibleHandler, localIndex; + if (handler1 instanceof Ci.nsILocalHandlerApp) { + [localPossibleHandler, webPossibleHandler, localIndex] = [ + handler1, + handler2, + 0, + ]; + } else { + [localPossibleHandler, webPossibleHandler, localIndex] = [ + handler2, + handler1, + 1, + ]; + } + localPossibleHandler.QueryInterface(Ci.nsILocalHandlerApp); + webPossibleHandler.QueryInterface(Ci.nsIWebHandlerApp); + + // Make sure the two handlers are the ones we stored. + Assert.equal(localPossibleHandler.name, localHandler.name); + Assert.ok(localPossibleHandler.equals(localHandler)); + Assert.equal(webPossibleHandler.name, webHandler.name); + Assert.ok(webPossibleHandler.equals(webHandler)); + + // Remove a handler, store the object, re-retrieve it, and make sure + // it only has one handler. + possibleHandlersInfo.possibleApplicationHandlers.removeElementAt(localIndex); + handlerSvc.store(possibleHandlersInfo); + possibleHandlersInfo = mimeSvc.getFromTypeAndExtension( + "nonexistent/possible-handlers", + null + ); + Assert.equal(possibleHandlersInfo.possibleApplicationHandlers.length, 1); + + // Make sure the handler is the one we didn't remove. + webPossibleHandler = + possibleHandlersInfo.possibleApplicationHandlers.queryElementAt( + 0, + Ci.nsIWebHandlerApp + ); + Assert.equal(webPossibleHandler.name, webHandler.name); + Assert.ok(webPossibleHandler.equals(webHandler)); + + // //////////////////////////////////////////////////// + // handler info command line parameters and equality + var localApp = Cc[ + "@mozilla.org/uriloader/local-handler-app;1" + ].createInstance(Ci.nsILocalHandlerApp); + var handlerApp = localApp.QueryInterface(Ci.nsIHandlerApp); + + Assert.ok(handlerApp.equals(localApp)); + + localApp.executable = executable; + + Assert.equal(0, localApp.parameterCount); + localApp.appendParameter("-test1"); + Assert.equal(1, localApp.parameterCount); + localApp.appendParameter("-test2"); + Assert.equal(2, localApp.parameterCount); + Assert.ok(localApp.parameterExists("-test1")); + Assert.ok(localApp.parameterExists("-test2")); + Assert.ok(!localApp.parameterExists("-false")); + localApp.clearParameters(); + Assert.equal(0, localApp.parameterCount); + + var localApp2 = Cc[ + "@mozilla.org/uriloader/local-handler-app;1" + ].createInstance(Ci.nsILocalHandlerApp); + + localApp2.executable = executable; + + localApp.clearParameters(); + Assert.ok(localApp.equals(localApp2)); + + // equal: + // cut -d 1 -f 2 + // cut -d 1 -f 2 + + localApp.appendParameter("-test1"); + localApp.appendParameter("-test2"); + localApp.appendParameter("-test3"); + localApp2.appendParameter("-test1"); + localApp2.appendParameter("-test2"); + localApp2.appendParameter("-test3"); + Assert.ok(localApp.equals(localApp2)); + + // not equal: + // cut -d 1 -f 2 + // cut -f 1 -d 2 + + localApp.clearParameters(); + localApp2.clearParameters(); + + localApp.appendParameter("-test1"); + localApp.appendParameter("-test2"); + localApp.appendParameter("-test3"); + localApp2.appendParameter("-test2"); + localApp2.appendParameter("-test1"); + localApp2.appendParameter("-test3"); + Assert.ok(!localApp2.equals(localApp)); + + var str; + str = localApp.getParameter(0); + Assert.equal(str, "-test1"); + str = localApp.getParameter(1); + Assert.equal(str, "-test2"); + str = localApp.getParameter(2); + Assert.equal(str, "-test3"); + + // FIXME: test round trip integrity for a protocol. + // FIXME: test round trip integrity for a handler info with a web handler. + + //* *************************************************************************// + // getTypeFromExtension tests + + // test nonexistent extension + var lolType = handlerSvc.getTypeFromExtension("lolcat"); + Assert.equal(lolType, ""); + + // add a handler for the extension + var lolHandler = mimeSvc.getFromTypeAndExtension("application/lolcat", null); + + Assert.ok(!lolHandler.extensionExists("lolcat")); + lolHandler.preferredAction = Ci.nsIHandlerInfo.useHelperApp; + lolHandler.preferredApplicationHandler = localHandler; + lolHandler.alwaysAskBeforeHandling = false; + lolHandler.appendExtension("lolcat"); + + // store the handler + Assert.ok(!handlerSvc.exists(lolHandler)); + handlerSvc.store(lolHandler); + Assert.ok(handlerSvc.exists(lolHandler)); + + // test now-existent extension + lolType = handlerSvc.getTypeFromExtension("lolcat"); + Assert.equal(lolType, "application/lolcat"); + + // test mailcap entries with needsterminal are ignored on non-Windows non-Mac. + if (mozinfo.os != "win" && mozinfo.os != "mac") { + prefSvc.setStringPref( + "helpers.private_mailcap_file", + do_get_file("mailcap").path + ); + handlerInfo = mimeSvc.getFromTypeAndExtension("text/plain", null); + Assert.equal(handlerInfo.preferredAction, Ci.nsIHandlerInfo.saveToDisk); + Assert.equal(handlerInfo.defaultDescription, "sed"); + } +} diff --git a/uriloader/exthandler/tests/unit/test_handlerService_store.js b/uriloader/exthandler/tests/unit/test_handlerService_store.js new file mode 100644 index 0000000000..27c8dc6c9e --- /dev/null +++ b/uriloader/exthandler/tests/unit/test_handlerService_store.js @@ -0,0 +1,757 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* + * Tests the nsIHandlerService interface. + */ + +// Set up an nsIWebHandlerApp instance that can be used in multiple tests. +let webHandlerApp = Cc[ + "@mozilla.org/uriloader/web-handler-app;1" +].createInstance(Ci.nsIWebHandlerApp); +webHandlerApp.name = "Web Handler"; +webHandlerApp.uriTemplate = "https://www.example.com/?url=%s"; +let expectedWebHandlerApp = { + name: webHandlerApp.name, + uriTemplate: webHandlerApp.uriTemplate, +}; + +// Set up an nsILocalHandlerApp instance that can be used in multiple tests. The +// executable should exist, but it doesn't need to point to an actual file, so +// we simply initialize it to the path of an existing directory. +let localHandlerApp = Cc[ + "@mozilla.org/uriloader/local-handler-app;1" +].createInstance(Ci.nsILocalHandlerApp); +localHandlerApp.name = "Local Handler"; +localHandlerApp.executable = new FileUtils.File(PathUtils.tempDir); +let expectedLocalHandlerApp = { + name: localHandlerApp.name, + executable: localHandlerApp.executable, +}; + +/** + * Returns a new nsIHandlerInfo instance initialized to known values that don't + * depend on the platform and are easier to verify later. + * + * @param type + * Because the "preferredAction" is initialized to saveToDisk, this + * should represent a MIME type rather than a protocol. + */ +function getKnownHandlerInfo(type) { + let handlerInfo = HandlerServiceTestUtils.getBlankHandlerInfo(type); + handlerInfo.preferredAction = Ci.nsIHandlerInfo.saveToDisk; + handlerInfo.alwaysAskBeforeHandling = false; + return handlerInfo; +} + +/** + * Checks that the information stored in the handler service instance under + * testing matches the test data files. + */ +function assertAllHandlerInfosMatchTestData() { + let handlerInfos = HandlerServiceTestUtils.getAllHandlerInfos(); + + // It's important that the MIME types we check here do not exist at the + // operating system level, otherwise the list of handlers and file extensions + // will be merged. The current implementation avoids duplicate entries. + + HandlerServiceTestUtils.assertHandlerInfoMatches(handlerInfos.shift(), { + type: "example/type.handleinternally", + preferredAction: Ci.nsIHandlerInfo.handleInternally, + alwaysAskBeforeHandling: false, + fileExtensions: ["example_one"], + }); + + HandlerServiceTestUtils.assertHandlerInfoMatches(handlerInfos.shift(), { + type: "example/type.savetodisk", + preferredAction: Ci.nsIHandlerInfo.saveToDisk, + alwaysAskBeforeHandling: true, + preferredApplicationHandler: { + name: "Example Default Handler", + uriTemplate: "https://www.example.com/?url=%s", + }, + possibleApplicationHandlers: [ + { + name: "Example Default Handler", + uriTemplate: "https://www.example.com/?url=%s", + }, + ], + fileExtensions: ["example_two", "example_three"], + }); + + HandlerServiceTestUtils.assertHandlerInfoMatches(handlerInfos.shift(), { + type: "example/type.usehelperapp", + preferredAction: Ci.nsIHandlerInfo.useHelperApp, + alwaysAskBeforeHandling: true, + preferredApplicationHandler: { + name: "Example Default Handler", + uriTemplate: "https://www.example.com/?url=%s", + }, + possibleApplicationHandlers: [ + { + name: "Example Default Handler", + uriTemplate: "https://www.example.com/?url=%s", + }, + { + name: "Example Possible Handler One", + uriTemplate: "http://www.example.com/?id=1&url=%s", + }, + { + name: "Example Possible Handler Two", + uriTemplate: "http://www.example.com/?id=2&url=%s", + }, + ], + fileExtensions: ["example_two", "example_three"], + }); + + HandlerServiceTestUtils.assertHandlerInfoMatches(handlerInfos.shift(), { + type: "example/type.usesystemdefault", + preferredAction: Ci.nsIHandlerInfo.useSystemDefault, + alwaysAskBeforeHandling: false, + possibleApplicationHandlers: [ + { + name: "Example Possible Handler", + uriTemplate: "http://www.example.com/?url=%s", + }, + ], + }); + + HandlerServiceTestUtils.assertHandlerInfoMatches(handlerInfos.shift(), { + type: "examplescheme.usehelperapp", + preferredAction: Ci.nsIHandlerInfo.useHelperApp, + alwaysAskBeforeHandling: true, + preferredApplicationHandler: { + name: "Example Default Handler", + uriTemplate: "https://www.example.com/?url=%s", + }, + possibleApplicationHandlers: [ + { + name: "Example Default Handler", + uriTemplate: "https://www.example.com/?url=%s", + }, + { + name: "Example Possible Handler One", + uriTemplate: "http://www.example.com/?id=1&url=%s", + }, + { + name: "Example Possible Handler Two", + uriTemplate: "http://www.example.com/?id=2&url=%s", + }, + ], + }); + + HandlerServiceTestUtils.assertHandlerInfoMatches(handlerInfos.shift(), { + type: "examplescheme.usesystemdefault", + preferredAction: Ci.nsIHandlerInfo.useSystemDefault, + alwaysAskBeforeHandling: false, + possibleApplicationHandlers: [ + { + name: "Example Possible Handler", + uriTemplate: "http://www.example.com/?url=%s", + }, + ], + }); + + Assert.equal(handlerInfos.length, 0); +} + +/** + * Loads data from a file in a predefined format, verifying that the format is + * recognized and all the known properties are loaded and saved. + */ +add_task(async function test_store_fillHandlerInfo_predefined() { + // Test that the file format used in previous versions can be loaded. + await copyTestDataToHandlerStore(); + await assertAllHandlerInfosMatchTestData(); + + // Keep a copy of the nsIHandlerInfo instances, then delete the handler store + // and populate it with the known data. Since the handler store is empty, the + // default handlers for the current locale are also injected, so we have to + // delete them manually before adding the other nsIHandlerInfo instances. + let testHandlerInfos = HandlerServiceTestUtils.getAllHandlerInfos(); + await deleteHandlerStore(); + for (let handlerInfo of HandlerServiceTestUtils.getAllHandlerInfos()) { + gHandlerService.remove(handlerInfo); + } + for (let handlerInfo of testHandlerInfos) { + gHandlerService.store(handlerInfo); + } + + // Test that the known data still matches after saving it and reloading. + await unloadHandlerStore(); + await assertAllHandlerInfosMatchTestData(); +}); + +/** + * Check that "store" is able to add new instances, that "remove" and "exists" + * work, and that "fillHandlerInfo" throws when the instance does not exist. + */ +add_task(async function test_store_remove_exists() { + // Test both MIME types and protocols. + for (let type of [ + "example/type.usehelperapp", + "examplescheme.usehelperapp", + ]) { + // Create new nsIHandlerInfo instances before loading the test data. + await deleteHandlerStore(); + let handlerInfoPresent = HandlerServiceTestUtils.getHandlerInfo(type); + let handlerInfoAbsent = HandlerServiceTestUtils.getHandlerInfo(type + "2"); + + // Set up known properties that we can verify later. + handlerInfoAbsent.preferredAction = Ci.nsIHandlerInfo.saveToDisk; + handlerInfoAbsent.alwaysAskBeforeHandling = false; + + await copyTestDataToHandlerStore(); + + Assert.ok(gHandlerService.exists(handlerInfoPresent)); + Assert.ok(!gHandlerService.exists(handlerInfoAbsent)); + + gHandlerService.store(handlerInfoAbsent); + gHandlerService.remove(handlerInfoPresent); + + await unloadHandlerStore(); + + Assert.ok(!gHandlerService.exists(handlerInfoPresent)); + Assert.ok(gHandlerService.exists(handlerInfoAbsent)); + + Assert.throws( + () => gHandlerService.fillHandlerInfo(handlerInfoPresent, ""), + ex => ex.result == Cr.NS_ERROR_NOT_AVAILABLE + ); + + let actualHandlerInfo = HandlerServiceTestUtils.getHandlerInfo(type + "2"); + HandlerServiceTestUtils.assertHandlerInfoMatches(actualHandlerInfo, { + type: type + "2", + preferredAction: Ci.nsIHandlerInfo.saveToDisk, + alwaysAskBeforeHandling: false, + }); + } +}); + +/** + * Tests that it is possible to save an nsIHandlerInfo instance with a + * "preferredAction" that is either a valid or an unknown value, and the + * action always takes on an appropriate value when reloading. + */ +add_task(async function test_store_preferredAction() { + await deleteHandlerStore(); + + let handlerInfo = getKnownHandlerInfo("example/new"); + // Valid action values should all remain unchanged across a refresh, except + // for alwaysAsk which may be overridden with useHelperApp depending on prefs. + // Invalid action values should always convert to useHelperApp. + const actions = [ + { + preferred: Ci.nsIHandlerInfo.alwaysAsk, + expected: Ci.nsIHandlerInfo.alwaysAsk, + }, + { + preferred: Ci.nsIHandlerInfo.handleInternally, + expected: Ci.nsIHandlerInfo.handleInternally, + }, + { preferred: 999, expected: Ci.nsIHandlerInfo.useHelperApp }, + ]; + + for (let action of actions) { + handlerInfo.preferredAction = action.preferred; + gHandlerService.store(handlerInfo); + gHandlerService.fillHandlerInfo(handlerInfo, ""); + Assert.equal(handlerInfo.preferredAction, action.expected); + } +}); + +/** + * Tests that it is possible to save an nsIHandlerInfo instance containing an + * nsILocalHandlerApp instance pointing to an executable that doesn't exist, but + * this entry is ignored when reloading. + */ +add_task(async function test_store_localHandlerApp_missing() { + if (!("@mozilla.org/uriloader/dbus-handler-app;1" in Cc)) { + info("Skipping test because it does not apply to this platform."); + return; + } + + let missingHandlerApp = Cc[ + "@mozilla.org/uriloader/local-handler-app;1" + ].createInstance(Ci.nsILocalHandlerApp); + missingHandlerApp.name = "Non-existing Handler"; + missingHandlerApp.executable = new FileUtils.File( + PathUtils.join(PathUtils.tempDir, "nonexisting") + ); + + await deleteHandlerStore(); + + let handlerInfo = getKnownHandlerInfo("example/new"); + handlerInfo.preferredApplicationHandler = missingHandlerApp; + handlerInfo.possibleApplicationHandlers.appendElement(missingHandlerApp); + handlerInfo.possibleApplicationHandlers.appendElement(webHandlerApp); + gHandlerService.store(handlerInfo); + + await unloadHandlerStore(); + + let actualHandlerInfo = HandlerServiceTestUtils.getHandlerInfo("example/new"); + HandlerServiceTestUtils.assertHandlerInfoMatches(actualHandlerInfo, { + type: "example/new", + preferredAction: Ci.nsIHandlerInfo.saveToDisk, + alwaysAskBeforeHandling: false, + possibleApplicationHandlers: [expectedWebHandlerApp], + }); +}); + +/** + * Test saving and reloading an instance of nsIDBusHandlerApp. + */ +add_task(async function test_store_dBusHandlerApp() { + if (!("@mozilla.org/uriloader/dbus-handler-app;1" in Cc)) { + info("Skipping test because it does not apply to this platform."); + return; + } + + // Set up an nsIDBusHandlerApp instance for testing. + let dBusHandlerApp = Cc[ + "@mozilla.org/uriloader/dbus-handler-app;1" + ].createInstance(Ci.nsIDBusHandlerApp); + dBusHandlerApp.name = "DBus Handler"; + dBusHandlerApp.service = "test.method.server"; + dBusHandlerApp.method = "Method"; + dBusHandlerApp.dBusInterface = "test.method.Type"; + dBusHandlerApp.objectPath = "/test/method/Object"; + let expectedDBusHandlerApp = { + name: dBusHandlerApp.name, + service: dBusHandlerApp.service, + method: dBusHandlerApp.method, + dBusInterface: dBusHandlerApp.dBusInterface, + objectPath: dBusHandlerApp.objectPath, + }; + + await deleteHandlerStore(); + + let handlerInfo = getKnownHandlerInfo("example/new"); + handlerInfo.preferredApplicationHandler = dBusHandlerApp; + handlerInfo.possibleApplicationHandlers.appendElement(dBusHandlerApp); + gHandlerService.store(handlerInfo); + + await unloadHandlerStore(); + + let actualHandlerInfo = HandlerServiceTestUtils.getHandlerInfo("example/new"); + HandlerServiceTestUtils.assertHandlerInfoMatches(actualHandlerInfo, { + type: "example/new", + preferredAction: Ci.nsIHandlerInfo.saveToDisk, + alwaysAskBeforeHandling: false, + preferredApplicationHandler: expectedDBusHandlerApp, + possibleApplicationHandlers: [expectedDBusHandlerApp], + }); +}); + +/** + * Tests that it is possible to save an nsIHandlerInfo instance with a + * "preferredApplicationHandler" and no "possibleApplicationHandlers", but the + * former is always included in the latter list when reloading. + */ +add_task( + async function test_store_possibleApplicationHandlers_includes_preferred() { + await deleteHandlerStore(); + + let handlerInfo = getKnownHandlerInfo("example/new"); + handlerInfo.preferredApplicationHandler = localHandlerApp; + gHandlerService.store(handlerInfo); + + await unloadHandlerStore(); + + let actualHandlerInfo = + HandlerServiceTestUtils.getHandlerInfo("example/new"); + HandlerServiceTestUtils.assertHandlerInfoMatches(actualHandlerInfo, { + type: "example/new", + preferredAction: Ci.nsIHandlerInfo.saveToDisk, + alwaysAskBeforeHandling: false, + preferredApplicationHandler: expectedLocalHandlerApp, + possibleApplicationHandlers: [expectedLocalHandlerApp], + }); + } +); + +/** + * Tests that it is possible to save an nsIHandlerInfo instance with a + * "preferredApplicationHandler" that is not the first element in + * "possibleApplicationHandlers", but the former is always included as the first + * element of the latter list when reloading. + */ +add_task( + async function test_store_possibleApplicationHandlers_preferred_first() { + await deleteHandlerStore(); + + let handlerInfo = getKnownHandlerInfo("example/new"); + handlerInfo.preferredApplicationHandler = webHandlerApp; + // The preferred handler is appended after the other one. + handlerInfo.possibleApplicationHandlers.appendElement(localHandlerApp); + handlerInfo.possibleApplicationHandlers.appendElement(webHandlerApp); + gHandlerService.store(handlerInfo); + + await unloadHandlerStore(); + + let actualHandlerInfo = + HandlerServiceTestUtils.getHandlerInfo("example/new"); + HandlerServiceTestUtils.assertHandlerInfoMatches(actualHandlerInfo, { + type: "example/new", + preferredAction: Ci.nsIHandlerInfo.saveToDisk, + alwaysAskBeforeHandling: false, + preferredApplicationHandler: expectedWebHandlerApp, + possibleApplicationHandlers: [ + expectedWebHandlerApp, + expectedLocalHandlerApp, + ], + }); + } +); + +/** + * Tests that it is possible to save an nsIHandlerInfo instance with an + * uppercase file extension, but it is converted to lowercase when reloading. + */ +add_task(async function test_store_fileExtensions_lowercase() { + await deleteHandlerStore(); + + let handlerInfo = getKnownHandlerInfo("example/new"); + handlerInfo.appendExtension("extension_test1"); + handlerInfo.appendExtension("EXTENSION_test2"); + gHandlerService.store(handlerInfo); + + await unloadHandlerStore(); + + let actualHandlerInfo = HandlerServiceTestUtils.getHandlerInfo("example/new"); + HandlerServiceTestUtils.assertHandlerInfoMatches(actualHandlerInfo, { + type: "example/new", + preferredAction: Ci.nsIHandlerInfo.saveToDisk, + alwaysAskBeforeHandling: false, + fileExtensions: ["extension_test1", "extension_test2"], + }); +}); + +/** + * Tests that appendExtension doesn't add duplicates, and that anyway duplicates + * from possibleApplicationHandlers are removed when saving and reloading. + */ +add_task(async function test_store_no_duplicates() { + await deleteHandlerStore(); + + let handlerInfo = getKnownHandlerInfo("example/new"); + handlerInfo.preferredApplicationHandler = webHandlerApp; + handlerInfo.possibleApplicationHandlers.appendElement(webHandlerApp); + handlerInfo.possibleApplicationHandlers.appendElement(localHandlerApp); + handlerInfo.possibleApplicationHandlers.appendElement(localHandlerApp); + handlerInfo.possibleApplicationHandlers.appendElement(webHandlerApp); + handlerInfo.appendExtension("extension_test1"); + handlerInfo.appendExtension("extension_test2"); + handlerInfo.appendExtension("extension_test1"); + handlerInfo.appendExtension("EXTENSION_test1"); + Assert.deepEqual(Array.from(handlerInfo.getFileExtensions()), [ + "extension_test1", + "extension_test2", + ]); + gHandlerService.store(handlerInfo); + + await unloadHandlerStore(); + + let actualHandlerInfo = HandlerServiceTestUtils.getHandlerInfo("example/new"); + HandlerServiceTestUtils.assertHandlerInfoMatches(actualHandlerInfo, { + type: "example/new", + preferredAction: Ci.nsIHandlerInfo.saveToDisk, + alwaysAskBeforeHandling: false, + preferredApplicationHandler: expectedWebHandlerApp, + possibleApplicationHandlers: [ + expectedWebHandlerApp, + expectedLocalHandlerApp, + ], + fileExtensions: ["extension_test1", "extension_test2"], + }); +}); + +/** + * Tests that setFileExtensions doesn't add duplicates. + */ +add_task(async function test_setFileExtensions_no_duplicates() { + await deleteHandlerStore(); + + let handlerInfo = getKnownHandlerInfo("example/new"); + handlerInfo.setFileExtensions("a,b,A,b,c,a"); + let expected = ["a", "b", "c"]; + Assert.deepEqual(Array.from(handlerInfo.getFileExtensions()), expected); + // Test empty extensions, also at begin and end. + handlerInfo.setFileExtensions(",a,,b,A,c,"); + Assert.deepEqual(Array.from(handlerInfo.getFileExtensions()), expected); +}); + +/** + * Tests that "store" deletes properties that have their default values from + * the data store. + * + * File extensions are never deleted once they have been associated. + */ +add_task(async function test_store_deletes_properties_except_extensions() { + await deleteHandlerStore(); + + // Prepare an nsIHandlerInfo instance with all the properties set to values + // that will result in deletions. The preferredAction is also set to a defined + // value so we can more easily verify it later. + let handlerInfo = HandlerServiceTestUtils.getBlankHandlerInfo( + "example/type.savetodisk" + ); + handlerInfo.preferredAction = Ci.nsIHandlerInfo.saveToDisk; + handlerInfo.alwaysAskBeforeHandling = false; + + // All the properties for "example/type.savetodisk" are present in the test + // data, so we load the data before overwriting their values. + await copyTestDataToHandlerStore(); + gHandlerService.store(handlerInfo); + + // Now we can reload the data and verify that no extra values have been kept. + await unloadHandlerStore(); + let actualHandlerInfo = HandlerServiceTestUtils.getHandlerInfo( + "example/type.savetodisk" + ); + HandlerServiceTestUtils.assertHandlerInfoMatches(actualHandlerInfo, { + type: "example/type.savetodisk", + preferredAction: Ci.nsIHandlerInfo.saveToDisk, + alwaysAskBeforeHandling: false, + fileExtensions: ["example_two", "example_three"], + }); +}); + +/** + * Tests the "overrideType" argument of "fillHandlerInfo". + */ +add_task(async function test_fillHandlerInfo_overrideType() { + // Test both MIME types and protocols. + for (let type of [ + "example/type.usesystemdefault", + "examplescheme.usesystemdefault", + ]) { + await deleteHandlerStore(); + + // Create new nsIHandlerInfo instances before loading the test data. + let handlerInfoAbsent = HandlerServiceTestUtils.getHandlerInfo(type + "2"); + + // Fill the nsIHandlerInfo instance using the type that actually exists. + await copyTestDataToHandlerStore(); + gHandlerService.fillHandlerInfo(handlerInfoAbsent, type); + HandlerServiceTestUtils.assertHandlerInfoMatches(handlerInfoAbsent, { + // While the data is populated from another type, the type is unchanged. + type: type + "2", + preferredAction: Ci.nsIHandlerInfo.useSystemDefault, + alwaysAskBeforeHandling: false, + possibleApplicationHandlers: [ + { + name: "Example Possible Handler", + uriTemplate: "http://www.example.com/?url=%s", + }, + ], + }); + } +}); + +/** + * Tests "getTypeFromExtension" including unknown extensions. + */ +add_task(async function test_getTypeFromExtension() { + await copyTestDataToHandlerStore(); + + Assert.equal(gHandlerService.getTypeFromExtension(""), ""); + Assert.equal(gHandlerService.getTypeFromExtension("example_unknown"), ""); + Assert.equal( + gHandlerService.getTypeFromExtension("example_one"), + "example/type.handleinternally" + ); + Assert.equal( + gHandlerService.getTypeFromExtension("EXAMPLE_one"), + "example/type.handleinternally" + ); +}); + +/** + * Checks that the information stored in the handler service instance under + * testing matches the default handlers for the English locale. + */ +function assertAllHandlerInfosMatchDefaultHandlers() { + let handlerInfos = HandlerServiceTestUtils.getAllHandlerInfos(); + + HandlerServiceTestUtils.assertHandlerInfoMatches(handlerInfos.shift(), { + type: "mailto", + preferredActionOSDependent: true, + possibleApplicationHandlers: [ + { + name: "Gmail", + uriTemplate: "https://mail.google.com/mail/?extsrc=mailto&url=%s", + }, + ], + }); + + Assert.equal(handlerInfos.length, 0); +} + +/** + * Tests the default protocol handlers imported from the locale-specific data. + */ +add_task( + { skip_if: () => AppConstants.MOZ_APP_NAME == "thunderbird" }, + async function test_default_protocol_handlers() { + if ( + !Services.prefs.getPrefType("gecko.handlerService.defaultHandlersVersion") + ) { + info("This platform or locale does not have default handlers."); + return; + } + + // This will inject the default protocol handlers for the current locale. + await deleteHandlerStore(); + + await assertAllHandlerInfosMatchDefaultHandlers(); + } +); + +/** + * Tests that the default protocol handlers are not imported again from the + * locale-specific data if they already exist. + */ +add_task( + { skip_if: () => AppConstants.MOZ_APP_NAME == "thunderbird" }, + async function test_default_protocol_handlers_no_duplicates() { + if ( + !Services.prefs.getPrefType("gecko.handlerService.defaultHandlersVersion") + ) { + info("This platform or locale does not have default handlers."); + return; + } + + // This will inject the default protocol handlers for the current locale. + await deleteHandlerStore(); + + // Clear the preference to force injecting again. + Services.prefs.clearUserPref("gecko.handlerService.defaultHandlersVersion"); + + await unloadHandlerStore(); + + // There should be no duplicate handlers in the protocols. + assertAllHandlerInfosMatchDefaultHandlers(); + } +); + +/** + * Ensures forward compatibility by checking that the "store" method preserves + * unknown properties in the test data. + */ +add_task(async function test_store_keeps_unknown_properties() { + // Create a new nsIHandlerInfo instance before loading the test data. + await deleteHandlerStore(); + let handlerInfo = HandlerServiceTestUtils.getHandlerInfo( + "example/type.handleinternally" + ); + + await copyTestDataToHandlerStore(); + gHandlerService.store(handlerInfo); + + await unloadHandlerStore(); + let data = await IOUtils.readJSON(jsonPath); + Assert.equal( + data.mimeTypes["example/type.handleinternally"].unknownProperty, + "preserved" + ); +}); + +/** + * Runs the asyncInit method, ensuring that it successfully inits the store + * and calls the handlersvc-store-initialized topic. + */ +add_task(async function test_async_init() { + await deleteHandlerStore(); + await copyTestDataToHandlerStore(); + gHandlerService.asyncInit(); + await TestUtils.topicObserved("handlersvc-store-initialized"); + await assertAllHandlerInfosMatchTestData(); + + await unloadHandlerStore(); +}); + +/** + * Races the asyncInit method against the sync init (implicit in enumerate), + * to ensure that the store will be synchronously initialized without any + * ill effects. + */ +add_task(async function test_race_async_init() { + await deleteHandlerStore(); + await copyTestDataToHandlerStore(); + let storeInitialized = false; + // Pass a callback to synchronously observe the topic, as a promise would + // resolve asynchronously + TestUtils.topicObserved("handlersvc-store-initialized", () => { + storeInitialized = true; + return true; + }); + gHandlerService.asyncInit(); + Assert.ok(!storeInitialized); + gHandlerService.enumerate(); + Assert.ok(storeInitialized); + await assertAllHandlerInfosMatchTestData(); + + await unloadHandlerStore(); +}); + +/** + * Test saving and reloading an instance of nsIGIOMimeApp. + */ +add_task(async function test_store_gioHandlerApp() { + if (!("@mozilla.org/gio-service;1" in Cc)) { + info("Skipping test because it does not apply to this platform."); + return; + } + + // Create dummy exec file that following won't fail because file not found error + let dummyHandlerFile = await IOUtils.getFile( + PathUtils.tempDir, + "dummyHandler" + ); + dummyHandlerFile.createUnique( + Ci.nsIFile.NORMAL_FILE_TYPE, + parseInt("777", 8) + ); + + // Set up an nsIGIOMimeApp instance for testing. + let handlerApp = Cc["@mozilla.org/gio-service;1"] + .getService(Ci.nsIGIOService) + .createAppFromCommand(dummyHandlerFile.path, "Dummy GIO handler"); + let expectedGIOMimeHandlerApp = { + name: handlerApp.name, + command: handlerApp.command, + }; + + await deleteHandlerStore(); + + let handlerInfo = getKnownHandlerInfo("example/new"); + handlerInfo.preferredApplicationHandler = handlerApp; + handlerInfo.possibleApplicationHandlers.appendElement(handlerApp); + handlerInfo.possibleApplicationHandlers.appendElement(webHandlerApp); + gHandlerService.store(handlerInfo); + + await unloadHandlerStore(); + + let actualHandlerInfo = HandlerServiceTestUtils.getHandlerInfo("example/new"); + HandlerServiceTestUtils.assertHandlerInfoMatches(actualHandlerInfo, { + type: "example/new", + preferredAction: Ci.nsIHandlerInfo.saveToDisk, + alwaysAskBeforeHandling: false, + preferredApplicationHandler: expectedGIOMimeHandlerApp, + possibleApplicationHandlers: [expectedGIOMimeHandlerApp, webHandlerApp], + }); + + await IOUtils.remove(dummyHandlerFile.path); + + // After removing dummyHandlerFile, the handler should disappear from the + // list of possibleApplicationHandlers and preferredAppHandler should be null. + actualHandlerInfo = HandlerServiceTestUtils.getHandlerInfo("example/new"); + HandlerServiceTestUtils.assertHandlerInfoMatches(actualHandlerInfo, { + type: "example/new", + preferredAction: Ci.nsIHandlerInfo.saveToDisk, + alwaysAskBeforeHandling: false, + preferredApplicationHandler: null, + possibleApplicationHandlers: [webHandlerApp], + }); +}); diff --git a/uriloader/exthandler/tests/unit/test_punycodeURIs.js b/uriloader/exthandler/tests/unit/test_punycodeURIs.js new file mode 100644 index 0000000000..949c9914ec --- /dev/null +++ b/uriloader/exthandler/tests/unit/test_punycodeURIs.js @@ -0,0 +1,126 @@ +/* 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/. */ + +// Encoded test URI to work on all platforms/independent of file encoding +const kTestURI = "http://\u65e5\u672c\u8a93.jp/"; +const kExpectedURI = "http://xn--wgv71a309e.jp/"; +const kOutputFile = "result.txt"; + +// Try several times in case the box we're running on is slow. +const kMaxCheckExistAttempts = 30; // seconds +var gCheckExistsAttempts = 0; + +const tempDir = do_get_tempdir(); + +function checkFile() { + // This is where we expect the output + var tempFile = tempDir.clone(); + tempFile.append(kOutputFile); + + if (!tempFile.exists()) { + if (gCheckExistsAttempts >= kMaxCheckExistAttempts) { + do_throw( + "Expected File " + + tempFile.path + + " does not exist after " + + kMaxCheckExistAttempts + + " seconds" + ); + } else { + ++gCheckExistsAttempts; + // Wait a bit longer then try again + do_timeout(1000, checkFile); + return; + } + } + + // Now read it + var fstream = Cc["@mozilla.org/network/file-input-stream;1"].createInstance( + Ci.nsIFileInputStream + ); + var sstream = Cc["@mozilla.org/scriptableinputstream;1"].createInstance( + Ci.nsIScriptableInputStream + ); + fstream.init(tempFile, -1, 0, 0); + sstream.init(fstream); + + // Read the first line only as that's the one we expect WriteArguments + // to be writing the argument to. + var data = sstream.read(4096); + + sstream.close(); + fstream.close(); + + // Now remove the old file + tempFile.remove(false); + + // This currently fails on Mac with an argument like -psn_0_nnnnnn + // This seems to be to do with how the executable is called, but I couldn't + // find a way around it. + // Additionally the lack of OS detection in xpcshell tests sucks, so we'll + // have to check for the argument mac gives us. + if (data.substring(0, 7) != "-psn_0_") { + Assert.equal(data, kExpectedURI); + } + + do_test_finished(); +} + +function run_test() { + if (mozinfo.os == "mac") { + dump("INFO | test_punycodeURIs.js | Skipping test on mac, bug 599475"); + return; + } + + // set up the uri to test with + var ioService = Services.io; + + // set up the local handler object + var localHandler = Cc[ + "@mozilla.org/uriloader/local-handler-app;1" + ].createInstance(Ci.nsILocalHandlerApp); + localHandler.name = "Test Local Handler App"; + + // WriteArgument will just dump its arguments to a file for us. + var processDir = do_get_cwd(); + var exe = processDir.clone(); + exe.append("WriteArgument"); + + if (!exe.exists()) { + // Maybe we are on windows + exe.leafName = "WriteArgument.exe"; + if (!exe.exists()) { + do_throw("Could not locate the WriteArgument tests executable\n"); + } + } + + var outFile = tempDir.clone(); + outFile.append(kOutputFile); + + // Set an environment variable for WriteArgument to pick up + // The Write Argument file needs to know where its libraries are, so + // just force the path variable + // For mac + var greDir = Services.dirsvc.get("GreD", Ci.nsIFile); + + Services.env.set("DYLD_LIBRARY_PATH", greDir.path); + // For Linux + Services.env.set("LD_LIBRARY_PATH", greDir.path); + // XXX: handle windows + + // Now tell it where we want the file. + Services.env.set("WRITE_ARGUMENT_FILE", outFile.path); + + var uri = ioService.newURI(kTestURI); + + // Just check we've got these matching, if we haven't there's a problem + // with ascii spec or our test case. + Assert.equal(uri.asciiSpec, kExpectedURI); + + localHandler.executable = exe; + localHandler.launchWithURI(uri); + + do_test_pending(); + do_timeout(1000, checkFile); +} diff --git a/uriloader/exthandler/tests/unit/xpcshell.toml b/uriloader/exthandler/tests/unit/xpcshell.toml new file mode 100644 index 0000000000..216ad49555 --- /dev/null +++ b/uriloader/exthandler/tests/unit/xpcshell.toml @@ -0,0 +1,43 @@ +[DEFAULT] +head = "head.js" +run-sequentially = "Bug 912235 - Intermittent failures" +firefox-appdir = "browser" + +["test_badMIMEType.js"] +run-if = ["buildapp == 'browser'"] + +["test_defaults_handlerService.js"] +# No default stored handlers on android given lack of support. +# No default stored handlers on Thunderbird. +skip-if = [ + "os == 'android'", + "appname == 'thunderbird'", +] + +["test_filename_sanitize.js"] +skip-if = ["os == 'mac'"] # Bug 1817727 + +["test_getFromTypeAndExtension.js"] + +["test_getMIMEInfo_pdf.js"] + +["test_getMIMEInfo_unknown_mime_type.js"] +run-if = ["os == 'win'"] # Windows only test + +["test_getTypeFromExtension_ext_to_type_mapping.js"] + +["test_getTypeFromExtension_with_empty_Content_Type.js"] +run-if = ["os == 'win'"] # Windows only test + +["test_handlerService.js"] +support-files = ["mailcap"] +# Bug 676997: test consistently fails on Android +fail-if = ["os == 'android'"] + +["test_handlerService_store.js"] +# Disabled for 1563343 -- the app should determine possible handlers in GV. +fail-if = ["os == 'android'"] +support-files = ["handlers.json"] + +["test_punycodeURIs.js"] +skip-if = ["os == 'win' && msix"] # https://bugzilla.mozilla.org/show_bug.cgi?id=1809485 |