summaryrefslogtreecommitdiffstats
path: root/toolkit/components/downloads/test/unit/head.js
diff options
context:
space:
mode:
Diffstat (limited to 'toolkit/components/downloads/test/unit/head.js')
-rw-r--r--toolkit/components/downloads/test/unit/head.js1183
1 files changed, 1183 insertions, 0 deletions
diff --git a/toolkit/components/downloads/test/unit/head.js b/toolkit/components/downloads/test/unit/head.js
new file mode 100644
index 0000000000..19d85b6f7c
--- /dev/null
+++ b/toolkit/components/downloads/test/unit/head.js
@@ -0,0 +1,1183 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Provides infrastructure for automated download components tests.
+ */
+
+"use strict";
+
+var { Integration } = ChromeUtils.importESModule(
+ "resource://gre/modules/Integration.sys.mjs"
+);
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+const { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+
+ChromeUtils.defineESModuleGetters(this, {
+ DownloadPaths: "resource://gre/modules/DownloadPaths.sys.mjs",
+ Downloads: "resource://gre/modules/Downloads.sys.mjs",
+ E10SUtils: "resource://gre/modules/E10SUtils.sys.mjs",
+ FileTestUtils: "resource://testing-common/FileTestUtils.sys.mjs",
+ FileUtils: "resource://gre/modules/FileUtils.sys.mjs",
+ HttpServer: "resource://testing-common/httpd.sys.mjs",
+ MockRegistrar: "resource://testing-common/MockRegistrar.sys.mjs",
+ NetUtil: "resource://gre/modules/NetUtil.sys.mjs",
+ PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs",
+ TelemetryTestUtils: "resource://testing-common/TelemetryTestUtils.sys.mjs",
+ TestUtils: "resource://testing-common/TestUtils.sys.mjs",
+});
+
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "gExternalHelperAppService",
+ "@mozilla.org/uriloader/external-helper-app-service;1",
+ Ci.nsIExternalHelperAppService
+);
+
+/* global DownloadIntegration */
+Integration.downloads.defineESModuleGetter(
+ this,
+ "DownloadIntegration",
+ "resource://gre/modules/DownloadIntegration.sys.mjs"
+);
+
+const ServerSocket = Components.Constructor(
+ "@mozilla.org/network/server-socket;1",
+ "nsIServerSocket",
+ "init"
+);
+const BinaryOutputStream = Components.Constructor(
+ "@mozilla.org/binaryoutputstream;1",
+ "nsIBinaryOutputStream",
+ "setOutputStream"
+);
+
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "gMIMEService",
+ "@mozilla.org/mime;1",
+ "nsIMIMEService"
+);
+
+const ReferrerInfo = Components.Constructor(
+ "@mozilla.org/referrer-info;1",
+ "nsIReferrerInfo",
+ "init"
+);
+
+const TEST_TARGET_FILE_NAME = "test-download.txt";
+const TEST_STORE_FILE_NAME = "test-downloads.json";
+
+// We are testing an HTTPS referrer with HTTP downloads in order to verify that
+// the default policy will not prevent the referrer from being passed around.
+const TEST_REFERRER_URL = "https://www.example.com/referrer.html";
+
+const TEST_DATA_SHORT = "This test string is downloaded.";
+// Generate using gzipCompressString in TelemetryController.sys.mjs.
+const TEST_DATA_SHORT_GZIP_ENCODED_FIRST = [
+ 31, 139, 8, 0, 0, 0, 0, 0, 0, 3, 11, 201, 200, 44, 86, 40, 73, 45, 46, 81, 40,
+ 46, 41, 202, 204,
+];
+const TEST_DATA_SHORT_GZIP_ENCODED_SECOND = [
+ 75, 87, 0, 114, 83, 242, 203, 243, 114, 242, 19, 83, 82, 83, 244, 0, 151, 222,
+ 109, 43, 31, 0, 0, 0,
+];
+const TEST_DATA_SHORT_GZIP_ENCODED = TEST_DATA_SHORT_GZIP_ENCODED_FIRST.concat(
+ TEST_DATA_SHORT_GZIP_ENCODED_SECOND
+);
+
+/**
+ * All the tests are implemented with add_task, this starts them automatically.
+ */
+function run_test() {
+ do_get_profile();
+ run_next_test();
+}
+
+// Support functions
+
+/**
+ * HttpServer object initialized before tests start.
+ */
+var gHttpServer;
+
+/**
+ * Given a file name, returns a string containing an URI that points to the file
+ * on the currently running instance of the test HTTP server.
+ */
+function httpUrl(aFileName) {
+ return (
+ "http://www.example.com:" +
+ gHttpServer.identity.primaryPort +
+ "/" +
+ aFileName
+ );
+}
+
+/**
+ * Returns a reference to a temporary file that is guaranteed not to exist and
+ * is cleaned up later. See FileTestUtils.getTempFile for details.
+ */
+function getTempFile(leafName) {
+ return FileTestUtils.getTempFile(leafName);
+}
+
+/**
+ * Check for file existence.
+ * @param {string} path The file path.
+ */
+async function fileExists(path) {
+ try {
+ return (await IOUtils.stat(path)).type == "regular";
+ } catch (ex) {
+ return false;
+ }
+}
+
+/**
+ * Waits for pending events to be processed.
+ *
+ * @return {Promise}
+ * @resolves When pending events have been processed.
+ * @rejects Never.
+ */
+function promiseExecuteSoon() {
+ return new Promise(resolve => {
+ executeSoon(resolve);
+ });
+}
+
+/**
+ * Waits for a pending events to be processed after a timeout.
+ *
+ * @return {Promise}
+ * @resolves When pending events have been processed.
+ * @rejects Never.
+ */
+function promiseTimeout(aTime) {
+ return new Promise(resolve => {
+ do_timeout(aTime, resolve);
+ });
+}
+
+/**
+ * Waits for a new history visit to be notified for the specified URI.
+ *
+ * @param aUrl
+ * String containing the URI that will be visited.
+ *
+ * @return {Promise}
+ * @resolves Array [aTime, aTransitionType] from page-visited places event.
+ * @rejects Never.
+ */
+function promiseWaitForVisit(aUrl) {
+ return new Promise(resolve => {
+ function listener(aEvents) {
+ Assert.equal(aEvents.length, 1);
+ let event = aEvents[0];
+ Assert.equal(event.type, "page-visited");
+ if (event.url == aUrl) {
+ PlacesObservers.removeListener(["page-visited"], listener);
+ resolve([event.visitTime, event.transitionType, event.lastKnownTitle]);
+ }
+ }
+ PlacesObservers.addListener(["page-visited"], listener);
+ });
+}
+
+/**
+ * Creates a new Download object, setting a temporary file as the target.
+ *
+ * @param aSourceUrl
+ * String containing the URI for the download source, or null to use
+ * httpUrl("source.txt").
+ *
+ * @return {Promise}
+ * @resolves The newly created Download object.
+ * @rejects JavaScript exception.
+ */
+function promiseNewDownload(aSourceUrl) {
+ return Downloads.createDownload({
+ source: aSourceUrl || httpUrl("source.txt"),
+ target: getTempFile(TEST_TARGET_FILE_NAME),
+ });
+}
+
+/**
+ * Starts a new download using the nsIWebBrowserPersist interface, and controls
+ * it using the legacy nsITransfer interface.
+ *
+ * @param aSourceUrl
+ * String containing the URI for the download source, or null to use
+ * httpUrl("source.txt").
+ * @param aOptions
+ * An optional object used to control the behavior of this function.
+ * You may pass an object with a subset of the following fields:
+ * {
+ * isPrivate: Boolean indicating whether the download originated from a
+ * private window.
+ * referrerInfo: referrerInfo for the download source.
+ * cookieJarSettings: cookieJarSettings for the download source.
+ * targetFile: nsIFile for the target, or null to use a temporary file.
+ * outPersist: Receives a reference to the created nsIWebBrowserPersist
+ * instance.
+ * launchWhenSucceeded: Boolean indicating whether the target should
+ * be launched when it has completed successfully.
+ * launcherPath: String containing the path of the custom executable to
+ * use to launch the target of the download.
+ * }
+ *
+ * @return {Promise}
+ * @resolves The Download object created as a consequence of controlling the
+ * download through the legacy nsITransfer interface.
+ * @rejects Never. The current test fails in case of exceptions.
+ */
+function promiseStartLegacyDownload(aSourceUrl, aOptions) {
+ let sourceURI = NetUtil.newURI(aSourceUrl || httpUrl("source.txt"));
+ let targetFile =
+ (aOptions && aOptions.targetFile) || getTempFile(TEST_TARGET_FILE_NAME);
+
+ let persist = Cc[
+ "@mozilla.org/embedding/browser/nsWebBrowserPersist;1"
+ ].createInstance(Ci.nsIWebBrowserPersist);
+ if (aOptions) {
+ aOptions.outPersist = persist;
+ }
+
+ let fileExtension = null,
+ mimeInfo = null;
+ let match = sourceURI.pathQueryRef.match(/\.([^.\/]+)$/);
+ if (match) {
+ fileExtension = match[1];
+ }
+
+ if (fileExtension) {
+ try {
+ mimeInfo = gMIMEService.getFromTypeAndExtension(null, fileExtension);
+ mimeInfo.preferredAction = Ci.nsIMIMEInfo.saveToDisk;
+ } catch (ex) {}
+ }
+
+ if (aOptions && aOptions.launcherPath) {
+ Assert.ok(mimeInfo != null);
+
+ let localHandlerApp = Cc[
+ "@mozilla.org/uriloader/local-handler-app;1"
+ ].createInstance(Ci.nsILocalHandlerApp);
+ localHandlerApp.executable = new FileUtils.File(aOptions.launcherPath);
+
+ mimeInfo.preferredApplicationHandler = localHandlerApp;
+ mimeInfo.preferredAction = Ci.nsIMIMEInfo.useHelperApp;
+ }
+
+ if (aOptions && aOptions.launchWhenSucceeded) {
+ Assert.ok(mimeInfo != null);
+
+ mimeInfo.preferredAction = Ci.nsIMIMEInfo.useHelperApp;
+ }
+
+ // Apply decoding if required by the "Content-Encoding" header.
+ persist.persistFlags &= ~Ci.nsIWebBrowserPersist.PERSIST_FLAGS_NO_CONVERSION;
+ persist.persistFlags |=
+ Ci.nsIWebBrowserPersist.PERSIST_FLAGS_AUTODETECT_APPLY_CONVERSION;
+
+ let transfer = Cc["@mozilla.org/transfer;1"].createInstance(Ci.nsITransfer);
+
+ return new Promise(resolve => {
+ Downloads.getList(Downloads.ALL)
+ .then(function (aList) {
+ // Temporarily register a view that will get notified when the download we
+ // are controlling becomes visible in the list of downloads.
+ aList
+ .addView({
+ onDownloadAdded(aDownload) {
+ aList.removeView(this).catch(do_report_unexpected_exception);
+
+ // Remove the download to keep the list empty for the next test. This
+ // also allows the caller to register the "onchange" event directly.
+ let promise = aList.remove(aDownload);
+
+ // When the download object is ready, make it available to the caller.
+ promise.then(
+ () => resolve(aDownload),
+ do_report_unexpected_exception
+ );
+ },
+ })
+ .catch(do_report_unexpected_exception);
+
+ let isPrivate = aOptions && aOptions.isPrivate;
+ let referrerInfo = aOptions ? aOptions.referrerInfo : null;
+ let cookieJarSettings = aOptions ? aOptions.cookieJarSettings : null;
+ let classification =
+ aOptions?.downloadClassification ??
+ Ci.nsITransfer.DOWNLOAD_ACCEPTABLE;
+ // Initialize the components so they reference each other. This will cause
+ // the Download object to be created and added to the public downloads.
+ transfer.init(
+ sourceURI,
+ null,
+ NetUtil.newURI(targetFile),
+ null,
+ mimeInfo,
+ null,
+ null,
+ persist,
+ isPrivate,
+ classification,
+ null
+ );
+ persist.progressListener = transfer;
+
+ // Start the actual download process.
+ persist.saveURI(
+ sourceURI,
+ Services.scriptSecurityManager.getSystemPrincipal(),
+ 0,
+ referrerInfo,
+ cookieJarSettings,
+ null,
+ null,
+ targetFile,
+ Ci.nsIContentPolicy.TYPE_SAVEAS_DOWNLOAD,
+ isPrivate
+ );
+ })
+ .catch(do_report_unexpected_exception);
+ });
+}
+
+/**
+ * Starts a new download using the nsIHelperAppService interface, and controls
+ * it using the legacy nsITransfer interface. The source of the download will
+ * be "interruptible_resumable.txt" and partially downloaded data will be kept.
+ *
+ * @param aSourceUrl
+ * String containing the URI for the download source, or null to use
+ * httpUrl("interruptible_resumable.txt").
+ *
+ * @return {Promise}
+ * @resolves The Download object created as a consequence of controlling the
+ * download through the legacy nsITransfer interface.
+ * @rejects Never. The current test fails in case of exceptions.
+ */
+function promiseStartExternalHelperAppServiceDownload(aSourceUrl) {
+ let sourceURI = NetUtil.newURI(
+ aSourceUrl || httpUrl("interruptible_resumable.txt")
+ );
+
+ return new Promise(resolve => {
+ Downloads.getList(Downloads.PUBLIC)
+ .then(function (aList) {
+ // Temporarily register a view that will get notified when the download we
+ // are controlling becomes visible in the list of downloads.
+ aList
+ .addView({
+ onDownloadAdded(aDownload) {
+ aList.removeView(this).catch(do_report_unexpected_exception);
+
+ // Remove the download to keep the list empty for the next test. This
+ // also allows the caller to register the "onchange" event directly.
+ let promise = aList.remove(aDownload);
+
+ // When the download object is ready, make it available to the caller.
+ promise.then(
+ () => resolve(aDownload),
+ do_report_unexpected_exception
+ );
+ },
+ })
+ .catch(do_report_unexpected_exception);
+
+ let channel = NetUtil.newChannel({
+ uri: sourceURI,
+ loadUsingSystemPrincipal: true,
+ });
+
+ // Start the actual download process.
+ channel.asyncOpen({
+ contentListener: null,
+
+ onStartRequest(aRequest) {
+ let requestChannel = aRequest.QueryInterface(Ci.nsIChannel);
+ this.contentListener = gExternalHelperAppService.doContent(
+ requestChannel.contentType,
+ aRequest,
+ null,
+ true
+ );
+ this.contentListener.onStartRequest(aRequest);
+ },
+
+ onStopRequest(aRequest, aStatusCode) {
+ this.contentListener.onStopRequest(aRequest, aStatusCode);
+ },
+
+ onDataAvailable(aRequest, aInputStream, aOffset, aCount) {
+ this.contentListener.onDataAvailable(
+ aRequest,
+ aInputStream,
+ aOffset,
+ aCount
+ );
+ },
+ });
+ })
+ .catch(do_report_unexpected_exception);
+ });
+}
+
+/**
+ * Waits for a download to reach half of its progress, in case it has not
+ * reached the expected progress already.
+ *
+ * @param aDownload
+ * The Download object to wait upon.
+ *
+ * @return {Promise}
+ * @resolves When the download has reached half of its progress.
+ * @rejects Never.
+ */
+function promiseDownloadMidway(aDownload) {
+ return new Promise(resolve => {
+ // Wait for the download to reach half of its progress.
+ let onchange = function () {
+ if (
+ !aDownload.stopped &&
+ !aDownload.canceled &&
+ aDownload.progress == 50
+ ) {
+ aDownload.onchange = null;
+ resolve();
+ }
+ };
+
+ // Register for the notification, but also call the function directly in
+ // case the download already reached the expected progress.
+ aDownload.onchange = onchange;
+ onchange();
+ });
+}
+
+/**
+ * Waits for a download to make any amount of progress.
+ *
+ * @param aDownload
+ * The Download object to wait upon.
+ *
+ * @return {Promise}
+ * @resolves When the download has transfered any number of bytes.
+ * @rejects Never.
+ */
+function promiseDownloadStarted(aDownload) {
+ return new Promise(resolve => {
+ // Wait for the download to transfer some amount of bytes.
+ let onchange = function () {
+ if (
+ !aDownload.stopped &&
+ !aDownload.canceled &&
+ aDownload.currentBytes > 0
+ ) {
+ aDownload.onchange = null;
+ resolve();
+ }
+ };
+
+ // Register for the notification, but also call the function directly in
+ // case the download already reached the expected progress.
+ aDownload.onchange = onchange;
+ onchange();
+ });
+}
+
+/**
+ * Waits for a download to finish.
+ *
+ * @param aDownload
+ * The Download object to wait upon.
+ *
+ * @return {Promise}
+ * @resolves When the download succeeded or errored.
+ * @rejects Never.
+ */
+function promiseDownloadFinished(aDownload) {
+ return new Promise(resolve => {
+ // Wait for the download to finish.
+ let onchange = function () {
+ if (aDownload.succeeded || aDownload.error) {
+ aDownload.onchange = null;
+ resolve();
+ }
+ };
+
+ // Register for the notification, but also call the function directly in
+ // case the download already reached the expected progress.
+ aDownload.onchange = onchange;
+ onchange();
+ });
+}
+
+/**
+ * Waits for a download to finish, in case it has not finished already.
+ *
+ * @param aDownload
+ * The Download object to wait upon.
+ *
+ * @return {Promise}
+ * @resolves When the download has finished successfully.
+ * @rejects JavaScript exception if the download failed.
+ */
+function promiseDownloadStopped(aDownload) {
+ if (!aDownload.stopped) {
+ // The download is in progress, wait for the current attempt to finish and
+ // report any errors that may occur.
+ return aDownload.start();
+ }
+
+ if (aDownload.succeeded) {
+ return Promise.resolve();
+ }
+
+ // The download failed or was canceled.
+ return Promise.reject(aDownload.error || new Error("Download canceled."));
+}
+
+/**
+ * Returns a new public or private DownloadList object.
+ *
+ * @param aIsPrivate
+ * True for the private list, false or undefined for the public list.
+ *
+ * @return {Promise}
+ * @resolves The newly created DownloadList object.
+ * @rejects JavaScript exception.
+ */
+function promiseNewList(aIsPrivate) {
+ // We need to clear all the internal state for the list and summary objects,
+ // since all the objects are interdependent internally.
+ Downloads._promiseListsInitialized = null;
+ Downloads._lists = {};
+ Downloads._summaries = {};
+
+ return Downloads.getList(aIsPrivate ? Downloads.PRIVATE : Downloads.PUBLIC);
+}
+
+/**
+ * Ensures that the given file contents are equal to the given string.
+ *
+ * @param aPath
+ * String containing the path of the file whose contents should be
+ * verified.
+ * @param aExpectedContents
+ * String containing the octets that are expected in the file.
+ *
+ * @return {Promise}
+ * @resolves When the operation completes.
+ * @rejects Never.
+ */
+async function promiseVerifyContents(aPath, aExpectedContents) {
+ let file = new FileUtils.File(aPath);
+
+ if (!(await IOUtils.exists(aPath))) {
+ do_throw("File does not exist: " + aPath);
+ }
+
+ if ((await IOUtils.stat(aPath)).size == 0) {
+ do_throw("File is empty: " + aPath);
+ }
+
+ await new Promise(resolve => {
+ NetUtil.asyncFetch(
+ { uri: NetUtil.newURI(file), loadUsingSystemPrincipal: true },
+ function (aInputStream, aStatus) {
+ Assert.ok(Components.isSuccessCode(aStatus));
+ let contents = NetUtil.readInputStreamToString(
+ aInputStream,
+ aInputStream.available()
+ );
+ if (
+ contents.length > TEST_DATA_SHORT.length * 2 ||
+ /[^\x20-\x7E]/.test(contents)
+ ) {
+ // Do not print the entire content string to the test log.
+ Assert.equal(contents.length, aExpectedContents.length);
+ Assert.ok(contents == aExpectedContents);
+ } else {
+ // Print the string if it is short and made of printable characters.
+ Assert.equal(contents, aExpectedContents);
+ }
+ resolve();
+ }
+ );
+ });
+}
+
+/**
+ * Creates and starts a new download, configured to keep partial data, and
+ * returns only when the first part of "interruptible_resumable.txt" has been
+ * saved to disk. You must call "continueResponses" to allow the interruptible
+ * request to continue.
+ *
+ * This function uses either DownloadCopySaver or DownloadLegacySaver based on
+ * the current test run.
+ *
+ * @param aOptions
+ * An optional object used to control the behavior of this function.
+ * You may pass an object with a subset of the following fields:
+ * {
+ * useLegacySaver: Boolean indicating whether to launch a legacy download.
+ * }
+ *
+ * @return {Promise}
+ * @resolves The newly created Download object, still in progress.
+ * @rejects JavaScript exception.
+ */
+async function promiseStartDownload_tryToKeepPartialData({
+ useLegacySaver = false,
+} = {}) {
+ mustInterruptResponses();
+
+ // Start a new download and configure it to keep partially downloaded data.
+ let download;
+ if (!useLegacySaver) {
+ let targetFilePath = getTempFile(TEST_TARGET_FILE_NAME).path;
+ download = await Downloads.createDownload({
+ source: httpUrl("interruptible_resumable.txt"),
+ target: {
+ path: targetFilePath,
+ partFilePath: targetFilePath + ".part",
+ },
+ });
+ download.tryToKeepPartialData = true;
+ download.start().catch(() => {});
+ } else {
+ // Start a download using nsIExternalHelperAppService, that is configured
+ // to keep partially downloaded data by default.
+ download = await promiseStartExternalHelperAppServiceDownload();
+ }
+
+ await promiseDownloadMidway(download);
+ await promisePartFileReady(download);
+
+ return download;
+}
+
+/**
+ * This function should be called after the progress notification for a download
+ * is received, and waits for the worker thread of BackgroundFileSaver to
+ * receive the data to be written to the ".part" file on disk.
+ *
+ * @return {Promise}
+ * @resolves When the ".part" file has been written to disk.
+ * @rejects JavaScript exception.
+ */
+async function promisePartFileReady(aDownload) {
+ // We don't have control over the file output code in BackgroundFileSaver.
+ // After we receive the download progress notification, we may only check
+ // that the ".part" file has been created, while its size cannot be
+ // determined because the file is currently open.
+ try {
+ do {
+ await promiseTimeout(50);
+ } while (!(await IOUtils.exists(aDownload.target.partFilePath)));
+ } catch (ex) {
+ if (!(ex instanceof IOUtils.Error)) {
+ throw ex;
+ }
+ // This indicates that the file has been created and cannot be accessed.
+ // The specific error might vary with the platform.
+ info("Expected exception while checking existence: " + ex.toString());
+ // Wait some more time to allow the write to complete.
+ await promiseTimeout(100);
+ }
+}
+
+/**
+ * Create a download which will be reputation blocked.
+ *
+ * @param options
+ * {
+ * keepPartialData: bool,
+ * keepBlockedData: bool,
+ * useLegacySaver: bool,
+ * verdict: string indicating the detailed reason for the block,
+ * }
+ * @return {Promise}
+ * @resolves The reputation blocked download.
+ * @rejects JavaScript exception.
+ */
+async function promiseBlockedDownload({
+ keepPartialData,
+ keepBlockedData,
+ useLegacySaver,
+ verdict = Downloads.Error.BLOCK_VERDICT_UNCOMMON,
+} = {}) {
+ let blockFn = base => ({
+ shouldBlockForReputationCheck: () =>
+ Promise.resolve({
+ shouldBlock: true,
+ verdict,
+ }),
+ shouldKeepBlockedData: () => Promise.resolve(keepBlockedData),
+ });
+
+ Integration.downloads.register(blockFn);
+ function cleanup() {
+ Integration.downloads.unregister(blockFn);
+ }
+ registerCleanupFunction(cleanup);
+
+ let download;
+
+ try {
+ if (keepPartialData) {
+ download = await promiseStartDownload_tryToKeepPartialData({
+ useLegacySaver,
+ });
+ continueResponses();
+ } else if (useLegacySaver) {
+ download = await promiseStartLegacyDownload();
+ } else {
+ download = await promiseNewDownload();
+ await download.start();
+ do_throw("The download should have blocked.");
+ }
+
+ await promiseDownloadStopped(download);
+ do_throw("The download should have blocked.");
+ } catch (ex) {
+ if (!(ex instanceof Downloads.Error) || !ex.becauseBlocked) {
+ throw ex;
+ }
+ Assert.ok(ex.becauseBlockedByReputationCheck);
+ Assert.equal(ex.reputationCheckVerdict, verdict);
+ Assert.ok(download.error.becauseBlockedByReputationCheck);
+ Assert.equal(download.error.reputationCheckVerdict, verdict);
+ }
+
+ Assert.ok(download.stopped);
+ Assert.ok(!download.succeeded);
+
+ cleanup();
+ return download;
+}
+
+/**
+ * Starts a socket listener that closes each incoming connection.
+ *
+ * @returns nsIServerSocket that listens for connections. Call its "close"
+ * method to stop listening and free the server port.
+ */
+function startFakeServer() {
+ let serverSocket = new ServerSocket(-1, true, -1);
+ serverSocket.asyncListen({
+ onSocketAccepted(aServ, aTransport) {
+ aTransport.close(Cr.NS_BINDING_ABORTED);
+ },
+ onStopListening() {},
+ });
+ return serverSocket;
+}
+
+/**
+ * This is an internal reference that should not be used directly by tests.
+ */
+var _gDeferResponses = Promise.withResolvers();
+
+/**
+ * Ensures that all the interruptible requests started after this function is
+ * called won't complete until the continueResponses function is called.
+ *
+ * Normally, the internal HTTP server returns all the available data as soon as
+ * a request is received. In order for some requests to be served one part at a
+ * time, special interruptible handlers are registered on the HTTP server. This
+ * allows testing events or actions that need to happen in the middle of a
+ * download.
+ *
+ * For example, the handler accessible at the httpUri("interruptible.txt")
+ * address returns the TEST_DATA_SHORT text, then it may block until the
+ * continueResponses method is called. At this point, the handler sends the
+ * TEST_DATA_SHORT text again to complete the response.
+ *
+ * If an interruptible request is started before the function is called, it may
+ * or may not be blocked depending on the actual sequence of events.
+ */
+function mustInterruptResponses() {
+ // If there are pending blocked requests, allow them to complete. This is
+ // done to prevent requests from being blocked forever, but should not affect
+ // the test logic, since previously started requests should not be monitored
+ // on the client side anymore.
+ _gDeferResponses.resolve();
+
+ info("Interruptible responses will be blocked midway.");
+ _gDeferResponses = Promise.withResolvers();
+}
+
+/**
+ * Allows all the current and future interruptible requests to complete.
+ */
+function continueResponses() {
+ info("Interruptible responses are now allowed to continue.");
+ _gDeferResponses.resolve();
+}
+
+/**
+ * Registers an interruptible response handler.
+ *
+ * @param aPath
+ * Path passed to nsIHttpServer.registerPathHandler.
+ * @param aFirstPartFn
+ * This function is called when the response is received, with the
+ * aRequest and aResponse arguments of the server.
+ * @param aSecondPartFn
+ * This function is called with the aRequest and aResponse arguments of
+ * the server, when the continueResponses function is called.
+ */
+function registerInterruptibleHandler(aPath, aFirstPartFn, aSecondPartFn) {
+ gHttpServer.registerPathHandler(aPath, function (aRequest, aResponse) {
+ info("Interruptible request started.");
+
+ // Process the first part of the response.
+ aResponse.processAsync();
+ aFirstPartFn(aRequest, aResponse);
+
+ // Wait on the current deferred object, then finish the request.
+ _gDeferResponses.promise
+ .then(function RIH_onSuccess() {
+ aSecondPartFn(aRequest, aResponse);
+ aResponse.finish();
+ info("Interruptible request finished.");
+ })
+ .catch(console.error);
+ });
+}
+
+/**
+ * Ensure the given date object is valid.
+ *
+ * @param aDate
+ * The date object to be checked. This value can be null.
+ */
+function isValidDate(aDate) {
+ return aDate && aDate.getTime && !isNaN(aDate.getTime());
+}
+
+/**
+ * Check actual ReferrerInfo is the same as expected.
+ * Because the actual download's referrer info's computedReferrer is computed
+ * from referrerPolicy and originalReferrer and is non-null, and the expected
+ * referrer info was constructed in isolation and therefore the computedReferrer
+ * is null, it isn't possible to use equals here. */
+function checkEqualReferrerInfos(aActualInfo, aExpectedInfo) {
+ Assert.equal(
+ !!aExpectedInfo.originalReferrer,
+ !!aActualInfo.originalReferrer
+ );
+ if (aExpectedInfo.originalReferrer && aActualInfo.originalReferrer) {
+ Assert.equal(
+ aExpectedInfo.originalReferrer.spec,
+ aActualInfo.originalReferrer.spec
+ );
+ }
+
+ Assert.equal(aExpectedInfo.sendReferrer, aActualInfo.sendReferrer);
+ Assert.equal(aExpectedInfo.referrerPolicy, aActualInfo.referrerPolicy);
+}
+
+/**
+ * Waits for the download annotations to be set for the given page, required
+ * because the addDownload method will add these to the database asynchronously.
+ */
+function waitForAnnotation(sourceUriSpec, annotationName, optionalValue) {
+ return TestUtils.waitForCondition(async () => {
+ let pageInfo = await PlacesUtils.history.fetch(sourceUriSpec, {
+ includeAnnotations: true,
+ });
+ if (optionalValue) {
+ return pageInfo?.annotations.get(annotationName) == optionalValue;
+ }
+ return pageInfo?.annotations.has(annotationName);
+ }, `Should have found annotation ${annotationName} for ${sourceUriSpec}`);
+}
+
+/**
+ * Position of the first byte served by the "interruptible_resumable.txt"
+ * handler during the most recent response.
+ */
+var gMostRecentFirstBytePos;
+
+// Initialization functions common to all tests
+
+add_setup(function test_common_initialize() {
+ // Start the HTTP server.
+ gHttpServer = new HttpServer();
+ gHttpServer.registerDirectory("/", do_get_file("../data"));
+ gHttpServer.start(-1);
+ registerCleanupFunction(() => {
+ return new Promise(resolve => {
+ // Ensure all the pending HTTP requests have a chance to finish.
+ continueResponses();
+ // Stop the HTTP server, calling resolve when it's done.
+ gHttpServer.stop(resolve);
+ });
+ });
+
+ // Serve the downloads from a domain located in the Internet zone on Windows.
+ gHttpServer.identity.setPrimary(
+ "http",
+ "www.example.com",
+ gHttpServer.identity.primaryPort
+ );
+ Services.prefs.setCharPref("network.dns.localDomains", "www.example.com");
+ registerCleanupFunction(function () {
+ Services.prefs.clearUserPref("network.dns.localDomains");
+ });
+
+ // Cache locks might prevent concurrent requests to the same resource, and
+ // this may block tests that use the interruptible handlers.
+ Services.prefs.setBoolPref("browser.cache.disk.enable", false);
+ Services.prefs.setBoolPref("browser.cache.memory.enable", false);
+ registerCleanupFunction(function () {
+ Services.prefs.clearUserPref("browser.cache.disk.enable");
+ Services.prefs.clearUserPref("browser.cache.memory.enable");
+ });
+
+ // Allow relaxing default referrer.
+ Services.prefs.setBoolPref(
+ "network.http.referer.disallowCrossSiteRelaxingDefault",
+ false
+ );
+ registerCleanupFunction(function () {
+ Services.prefs.clearUserPref(
+ "network.http.referer.disallowCrossSiteRelaxingDefault"
+ );
+ });
+
+ registerInterruptibleHandler(
+ "/interruptible.txt",
+ function firstPart(aRequest, aResponse) {
+ aResponse.setHeader("Content-Type", "text/plain", false);
+ aResponse.setHeader(
+ "Content-Length",
+ "" + TEST_DATA_SHORT.length * 2,
+ false
+ );
+ aResponse.write(TEST_DATA_SHORT);
+ },
+ function secondPart(aRequest, aResponse) {
+ aResponse.write(TEST_DATA_SHORT);
+ }
+ );
+
+ registerInterruptibleHandler(
+ "/interruptible_nosize.txt",
+ function firstPart(aRequest, aResponse) {
+ aResponse.setHeader("Content-Type", "text/plain", false);
+ aResponse.write(TEST_DATA_SHORT);
+ },
+ function secondPart(aRequest, aResponse) {
+ aResponse.write(TEST_DATA_SHORT);
+ }
+ );
+
+ registerInterruptibleHandler(
+ "/interruptible_resumable.txt",
+ function firstPart(aRequest, aResponse) {
+ aResponse.setHeader("Content-Type", "text/plain", false);
+
+ // Determine if only part of the data should be sent.
+ let data = TEST_DATA_SHORT + TEST_DATA_SHORT;
+ if (aRequest.hasHeader("Range")) {
+ var matches = aRequest
+ .getHeader("Range")
+ .match(/^\s*bytes=(\d+)?-(\d+)?\s*$/);
+ var firstBytePos = matches[1] === undefined ? 0 : matches[1];
+ var lastBytePos =
+ matches[2] === undefined ? data.length - 1 : matches[2];
+ if (firstBytePos >= data.length) {
+ aResponse.setStatusLine(
+ aRequest.httpVersion,
+ 416,
+ "Requested Range Not Satisfiable"
+ );
+ aResponse.setHeader("Content-Range", "*/" + data.length, false);
+ aResponse.finish();
+ return;
+ }
+
+ aResponse.setStatusLine(aRequest.httpVersion, 206, "Partial Content");
+ aResponse.setHeader(
+ "Content-Range",
+ firstBytePos + "-" + lastBytePos + "/" + data.length,
+ false
+ );
+
+ data = data.substring(firstBytePos, lastBytePos + 1);
+
+ gMostRecentFirstBytePos = firstBytePos;
+ } else {
+ gMostRecentFirstBytePos = 0;
+ }
+
+ aResponse.setHeader("Content-Length", "" + data.length, false);
+
+ aResponse.write(data.substring(0, data.length / 2));
+
+ // Store the second part of the data on the response object, so that it
+ // can be used by the secondPart function.
+ aResponse.secondPartData = data.substring(data.length / 2);
+ },
+ function secondPart(aRequest, aResponse) {
+ aResponse.write(aResponse.secondPartData);
+ }
+ );
+
+ registerInterruptibleHandler(
+ "/interruptible_gzip.txt",
+ function firstPart(aRequest, aResponse) {
+ aResponse.setHeader("Content-Type", "text/plain", false);
+ aResponse.setHeader("Content-Encoding", "gzip", false);
+ aResponse.setHeader(
+ "Content-Length",
+ "" + TEST_DATA_SHORT_GZIP_ENCODED.length
+ );
+
+ let bos = new BinaryOutputStream(aResponse.bodyOutputStream);
+ bos.writeByteArray(TEST_DATA_SHORT_GZIP_ENCODED_FIRST);
+ },
+ function secondPart(aRequest, aResponse) {
+ let bos = new BinaryOutputStream(aResponse.bodyOutputStream);
+ bos.writeByteArray(TEST_DATA_SHORT_GZIP_ENCODED_SECOND);
+ }
+ );
+
+ gHttpServer.registerPathHandler(
+ "/shorter-than-content-length-http-1-1.txt",
+ function (aRequest, aResponse) {
+ aResponse.processAsync();
+ aResponse.setStatusLine("1.1", 200, "OK");
+ aResponse.setHeader("Content-Type", "text/plain", false);
+ aResponse.setHeader(
+ "Content-Length",
+ "" + TEST_DATA_SHORT.length * 2,
+ false
+ );
+ aResponse.write(TEST_DATA_SHORT);
+ aResponse.finish();
+ }
+ );
+
+ gHttpServer.registerPathHandler("/busy.txt", function (aRequest, aResponse) {
+ aResponse.setStatusLine("1.1", 504, "Gateway Timeout");
+ aResponse.setHeader("Content-Type", "text/plain", false);
+ aResponse.setHeader("Content-Length", "" + TEST_DATA_SHORT.length, false);
+ aResponse.write(TEST_DATA_SHORT);
+ });
+
+ gHttpServer.registerPathHandler("/redirect", function (aRequest, aResponse) {
+ aResponse.setStatusLine("1.1", 301, "Moved Permanently");
+ aResponse.setHeader("Location", httpUrl("busy.txt"), false);
+ aResponse.setHeader("Content-Type", "text/javascript", false);
+ aResponse.setHeader("Content-Length", "0", false);
+ });
+
+ // This URL will emulate being blocked by Windows Parental controls
+ gHttpServer.registerPathHandler(
+ "/parentalblocked.zip",
+ function (aRequest, aResponse) {
+ aResponse.setStatusLine(
+ aRequest.httpVersion,
+ 450,
+ "Blocked by Windows Parental Controls"
+ );
+ }
+ );
+
+ // This URL sends some data followed by an RST packet
+ gHttpServer.registerPathHandler(
+ "/netreset.txt",
+ function (aRequest, aResponse) {
+ info("Starting response that will be aborted.");
+ aResponse.processAsync();
+ aResponse.setHeader("Content-Type", "text/plain", false);
+ aResponse.write(TEST_DATA_SHORT);
+ promiseExecuteSoon()
+ .then(() => {
+ aResponse.abort(null, true);
+ aResponse.finish();
+ info("Aborting response with network reset.");
+ })
+ .then(null, console.error);
+ }
+ );
+
+ // During unit tests, most of the functions that require profile access or
+ // operating system features will be disabled. Individual tests may override
+ // them again to check for specific behaviors.
+ Integration.downloads.register(base => {
+ let override = {
+ loadPublicDownloadListFromStore: () => Promise.resolve(),
+ shouldKeepBlockedData: () => Promise.resolve(false),
+ shouldBlockForParentalControls: () => Promise.resolve(false),
+ shouldBlockForReputationCheck: () =>
+ Promise.resolve({
+ shouldBlock: false,
+ verdict: "",
+ }),
+ confirmLaunchExecutable: () => Promise.resolve(),
+ launchFile: () => Promise.resolve(),
+ showContainingDirectory: () => Promise.resolve(),
+ // This flag allows re-enabling the default observers during their tests.
+ allowObservers: false,
+ addListObservers() {
+ return this.allowObservers
+ ? super.addListObservers(...arguments)
+ : Promise.resolve();
+ },
+ // This flag allows re-enabling the download directory logic for its tests.
+ _allowDirectories: false,
+ set allowDirectories(value) {
+ this._allowDirectories = value;
+ // We have to invalidate the previously computed directory path.
+ this._downloadsDirectory = null;
+ },
+ _getDirectory(name) {
+ return super._getDirectory(this._allowDirectories ? name : "TmpD");
+ },
+ };
+ Object.setPrototypeOf(override, base);
+ return override;
+ });
+
+ // Make sure that downloads started using nsIExternalHelperAppService are
+ // saved to disk without asking for a destination interactively.
+ let mock = {
+ QueryInterface: ChromeUtils.generateQI(["nsIHelperAppLauncherDialog"]),
+ promptForSaveToFileAsync(
+ aLauncher,
+ aWindowContext,
+ aDefaultFileName,
+ aSuggestedFileExtension,
+ aForcePrompt
+ ) {
+ // The dialog should create the empty placeholder file.
+ let file = getTempFile(TEST_TARGET_FILE_NAME);
+ file.create(Ci.nsIFile.NORMAL_FILE_TYPE, FileUtils.PERMS_FILE);
+ aLauncher.saveDestinationAvailable(file);
+ },
+ };
+
+ let cid = MockRegistrar.register(
+ "@mozilla.org/helperapplauncherdialog;1",
+ mock
+ );
+ registerCleanupFunction(() => {
+ MockRegistrar.unregister(cid);
+ });
+});