/* Any copyright is dedicated to the Public Domain. * http://creativecommons.org/publicdomain/zero/1.0/ */ /** * Tests the DownloadImport object. */ "use strict"; // Globals XPCOMUtils.defineLazyModuleGetter(this, "Sqlite", "resource://gre/modules/Sqlite.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "DownloadImport", "resource://gre/modules/DownloadImport.jsm"); // Importable states const DOWNLOAD_NOTSTARTED = -1; const DOWNLOAD_DOWNLOADING = 0; const DOWNLOAD_PAUSED = 4; const DOWNLOAD_QUEUED = 5; // Non importable states const DOWNLOAD_FAILED = 2; const DOWNLOAD_CANCELED = 3; const DOWNLOAD_BLOCKED_PARENTAL = 6; const DOWNLOAD_SCANNING = 7; const DOWNLOAD_DIRTY = 8; const DOWNLOAD_BLOCKED_POLICY = 9; // The TEST_DATA_TAINTED const is a version of TEST_DATA_SHORT in which the // beginning of the data was changed (with the TEST_DATA_REPLACEMENT value). // We use this to test that the entityID is properly imported and the download // can be resumed from where it was paused. // For simplification purposes, the test requires that TEST_DATA_SHORT and // TEST_DATA_TAINTED have the same length. const TEST_DATA_REPLACEMENT = "-changed- "; const TEST_DATA_TAINTED = TEST_DATA_REPLACEMENT + TEST_DATA_SHORT.substr(TEST_DATA_REPLACEMENT.length); const TEST_DATA_LENGTH = TEST_DATA_SHORT.length; // The length of the partial file that we'll write to disk as an existing // ongoing download. const TEST_DATA_PARTIAL_LENGTH = TEST_DATA_REPLACEMENT.length; // The value of the "maxBytes" column stored in the DB about the downloads. // It's intentionally different than TEST_DATA_LENGTH to test that each value // is seen when expected. const MAXBYTES_IN_DB = TEST_DATA_LENGTH - 10; var gDownloadsRowToImport; var gDownloadsRowNonImportable; /** * Creates a database with an empty moz_downloads table and leaves an * open connection to it. * * @param aPath * String containing the path of the database file to be created. * @param aSchemaVersion * Number with the version of the database schema to set. * * @return {Promise} * @resolves The open connection to the database. * @rejects If an error occurred during the database creation. */ function promiseEmptyDatabaseConnection({aPath, aSchemaVersion}) { return Task.spawn(function* () { let connection = yield Sqlite.openConnection({ path: aPath }); yield connection.execute("CREATE TABLE moz_downloads (" + "id INTEGER PRIMARY KEY," + "name TEXT," + "source TEXT," + "target TEXT," + "tempPath TEXT," + "startTime INTEGER," + "endTime INTEGER," + "state INTEGER," + "referrer TEXT," + "entityID TEXT," + "currBytes INTEGER NOT NULL DEFAULT 0," + "maxBytes INTEGER NOT NULL DEFAULT -1," + "mimeType TEXT," + "preferredApplication TEXT," + "preferredAction INTEGER NOT NULL DEFAULT 0," + "autoResume INTEGER NOT NULL DEFAULT 0," + "guid TEXT)"); yield connection.setSchemaVersion(aSchemaVersion); return connection; }); } /** * Inserts a new entry in the database with the given columns' values. * * @param aConnection * The database connection. * @param aDownloadRow * An object representing the values for each column of the row * being inserted. * * @return {Promise} * @resolves When the operation completes. * @rejects If there's an error inserting the row. */ function promiseInsertRow(aConnection, aDownloadRow) { // We can't use the aDownloadRow obj directly in the execute statement // because the obj bind code in Sqlite.jsm doesn't allow objects // with extra properties beyond those being binded. So we might as well // use an array as it is simpler. let values = [ aDownloadRow.source, aDownloadRow.target, aDownloadRow.tempPath, aDownloadRow.startTime.getTime() * 1000, aDownloadRow.state, aDownloadRow.referrer, aDownloadRow.entityID, aDownloadRow.maxBytes, aDownloadRow.mimeType, aDownloadRow.preferredApplication, aDownloadRow.preferredAction, aDownloadRow.autoResume ]; return aConnection.execute("INSERT INTO moz_downloads (" + "name, source, target, tempPath, startTime," + "endTime, state, referrer, entityID, currBytes," + "maxBytes, mimeType, preferredApplication," + "preferredAction, autoResume, guid)" + "VALUES (" + "'', ?, ?, ?, ?, " // name, + "0, ?, ?, ?, 0, " // endTime, currBytes + " ?, ?, ?, " // + " ?, ?, '')", // and guid are not imported values); } /** * Retrieves the number of rows in the moz_downloads table of the * database. * * @param aConnection * The database connection. * * @return {Promise} * @resolves With the number of rows. * @rejects Never. */ function promiseTableCount(aConnection) { return aConnection.execute("SELECT COUNT(*) FROM moz_downloads") .then(res => res[0].getResultByName("COUNT(*)")) .then(null, Cu.reportError); } /** * Briefly opens a network channel to a given URL to retrieve * the entityID of this url, as generated by the network code. * * @param aUrl * The URL to retrieve the entityID. * * @return {Promise} * @resolves The EntityID of the given URL. * @rejects When there's a problem accessing the URL. */ function promiseEntityID(aUrl) { let deferred = Promise.defer(); let entityID = ""; let channel = NetUtil.newChannel({ uri: NetUtil.newURI(aUrl), loadUsingSystemPrincipal: true }); channel.asyncOpen2({ onStartRequest: function (aRequest) { if (aRequest instanceof Ci.nsIResumableChannel) { entityID = aRequest.entityID; } aRequest.cancel(Cr.NS_BINDING_ABORTED); }, onStopRequest: function (aRequest, aContext, aStatusCode) { if (aStatusCode == Cr.NS_BINDING_ABORTED) { deferred.resolve(entityID); } else { deferred.reject("Unexpected status code received"); } }, onDataAvailable: function () {} }); return deferred.promise; } /** * Gets a file path to a temporary writeable download target, in the * correct format as expected to be stored in the downloads database, * which is file:///absolute/path/to/file * * @param aLeafName * A hint leaf name for the file. * * @return String The path to the download target. */ function getDownloadTarget(aLeafName) { return NetUtil.newURI(getTempFile(aLeafName)).spec; } /** * Generates a temporary partial file to use as an in-progress * download. The file is written to disk with a part of the total expected * download content pre-written. * * @param aLeafName * A hint leaf name for the file. * @param aTainted * A boolean value. When true, the partial content of the file * will be different from the expected content of the original source * file. See the declaration of TEST_DATA_TAINTED for more information. * * @return {Promise} * @resolves When the operation completes, and returns a string with the path * to the generated file. * @rejects If there's an error writing the file. */ function getPartialFile(aLeafName, aTainted = false) { let tempDownload = getTempFile(aLeafName); let partialContent = aTainted ? TEST_DATA_TAINTED.substr(0, TEST_DATA_PARTIAL_LENGTH) : TEST_DATA_SHORT.substr(0, TEST_DATA_PARTIAL_LENGTH); return OS.File.writeAtomic(tempDownload.path, partialContent, { tmpPath: tempDownload.path + ".tmp", flush: true }) .then(() => tempDownload.path); } /** * Generates a Date object to be used as the startTime for the download rows * in the DB. A date that is obviously different from the current time is * generated to make sure this stored data and a `new Date()` can't collide. * * @param aOffset * A offset from the base generated date is used to differentiate each * row in the database. * * @return A Date object. */ function getStartTime(aOffset) { return new Date(1000000 + (aOffset * 10000)); } /** * Performs various checks on an imported Download object to make sure * all properties are properly set as expected from the import procedure. * * @param aDownload * The Download object to be checked. * @param aDownloadRow * An object that represents a row from the original database table, * with extra properties describing expected values that are not * explictly part of the database. * * @return {Promise} * @resolves When the operation completes * @rejects Never */ function checkDownload(aDownload, aDownloadRow) { return Task.spawn(function*() { do_check_eq(aDownload.source.url, aDownloadRow.source); do_check_eq(aDownload.source.referrer, aDownloadRow.referrer); do_check_eq(aDownload.target.path, NetUtil.newURI(aDownloadRow.target) .QueryInterface(Ci.nsIFileURL).file.path); do_check_eq(aDownload.target.partFilePath, aDownloadRow.tempPath); if (aDownloadRow.expectedResume) { do_check_true(!aDownload.stopped || aDownload.succeeded); yield promiseDownloadStopped(aDownload); do_check_true(aDownload.succeeded); do_check_eq(aDownload.progress, 100); // If the download has resumed, a new startTime will be set. // By calling toJSON we're also testing that startTime is a Date object. do_check_neq(aDownload.startTime.toJSON(), aDownloadRow.startTime.toJSON()); } else { do_check_false(aDownload.succeeded); do_check_eq(aDownload.startTime.toJSON(), aDownloadRow.startTime.toJSON()); } do_check_eq(aDownload.stopped, true); let serializedSaver = aDownload.saver.toSerializable(); if (typeof(serializedSaver) == "object") { do_check_eq(serializedSaver.type, "copy"); } else { do_check_eq(serializedSaver, "copy"); } if (aDownloadRow.entityID) { do_check_eq(aDownload.saver.entityID, aDownloadRow.entityID); } do_check_eq(aDownload.currentBytes, aDownloadRow.expectedCurrentBytes); do_check_eq(aDownload.totalBytes, aDownloadRow.expectedTotalBytes); if (aDownloadRow.expectedContent) { let fileToCheck = aDownloadRow.expectedResume ? aDownload.target.path : aDownload.target.partFilePath; yield promiseVerifyContents(fileToCheck, aDownloadRow.expectedContent); } do_check_eq(aDownload.contentType, aDownloadRow.expectedContentType); do_check_eq(aDownload.launcherPath, aDownloadRow.preferredApplication); do_check_eq(aDownload.launchWhenSucceeded, aDownloadRow.preferredAction != Ci.nsIMIMEInfo.saveToDisk); }); } // Preparation tasks /** * Prepares the list of downloads to be added to the database that should * be imported by the import procedure. */ add_task(function* prepareDownloadsToImport() { let sourceUrl = httpUrl("source.txt"); let sourceEntityId = yield promiseEntityID(sourceUrl); gDownloadsRowToImport = [ // Paused download with autoResume and a partial file. By // setting the correct entityID the download can resume from // where it stopped, and to test that this works properly we // intentionally set different data in the beginning of the // partial file to make sure it was not replaced. { source: sourceUrl, target: getDownloadTarget("inprogress1.txt"), tempPath: yield getPartialFile("inprogress1.txt.part", true), startTime: getStartTime(1), state: DOWNLOAD_PAUSED, referrer: httpUrl("referrer1"), entityID: sourceEntityId, maxBytes: MAXBYTES_IN_DB, mimeType: "mimeType1", preferredAction: Ci.nsIMIMEInfo.saveToDisk, preferredApplication: "prerredApplication1", autoResume: 1, // Even though the information stored in the DB said // maxBytes was MAXBYTES_IN_DB, the download turned out to be // a different length. Here we make sure the totalBytes property // was correctly set with the actual value. The same consideration // applies to the contentType. expectedCurrentBytes: TEST_DATA_LENGTH, expectedTotalBytes: TEST_DATA_LENGTH, expectedResume: true, expectedContentType: "text/plain", expectedContent: TEST_DATA_TAINTED, }, // Paused download with autoResume and a partial file, // but missing entityID. This means that the download will // start from beginning, and the entire original content of the // source file should replace the different data that was stored // in the partial file. { source: sourceUrl, target: getDownloadTarget("inprogress2.txt"), tempPath: yield getPartialFile("inprogress2.txt.part", true), startTime: getStartTime(2), state: DOWNLOAD_PAUSED, referrer: httpUrl("referrer2"), entityID: "", maxBytes: MAXBYTES_IN_DB, mimeType: "mimeType2", preferredAction: Ci.nsIMIMEInfo.saveToDisk, preferredApplication: "prerredApplication2", autoResume: 1, expectedCurrentBytes: TEST_DATA_LENGTH, expectedTotalBytes: TEST_DATA_LENGTH, expectedResume: true, expectedContentType: "text/plain", expectedContent: TEST_DATA_SHORT }, // Paused download with no autoResume and a partial file. { source: sourceUrl, target: getDownloadTarget("inprogress3.txt"), tempPath: yield getPartialFile("inprogress3.txt.part"), startTime: getStartTime(3), state: DOWNLOAD_PAUSED, referrer: httpUrl("referrer3"), entityID: "", maxBytes: MAXBYTES_IN_DB, mimeType: "mimeType3", preferredAction: Ci.nsIMIMEInfo.saveToDisk, preferredApplication: "prerredApplication3", autoResume: 0, // Since this download has not been resumed, the actual data // about its total size and content type is not known. // Therefore, we're going by the information imported from the DB. expectedCurrentBytes: TEST_DATA_PARTIAL_LENGTH, expectedTotalBytes: MAXBYTES_IN_DB, expectedResume: false, expectedContentType: "mimeType3", expectedContent: TEST_DATA_SHORT.substr(0, TEST_DATA_PARTIAL_LENGTH), }, // Paused download with autoResume and no partial file. { source: sourceUrl, target: getDownloadTarget("inprogress4.txt"), tempPath: "", startTime: getStartTime(4), state: DOWNLOAD_PAUSED, referrer: httpUrl("referrer4"), entityID: "", maxBytes: MAXBYTES_IN_DB, mimeType: "text/plain", preferredAction: Ci.nsIMIMEInfo.useHelperApp, preferredApplication: "prerredApplication4", autoResume: 1, expectedCurrentBytes: TEST_DATA_LENGTH, expectedTotalBytes: TEST_DATA_LENGTH, expectedResume: true, expectedContentType: "text/plain", expectedContent: TEST_DATA_SHORT }, // Paused download with no autoResume and no partial file. { source: sourceUrl, target: getDownloadTarget("inprogress5.txt"), tempPath: "", startTime: getStartTime(5), state: DOWNLOAD_PAUSED, referrer: httpUrl("referrer4"), entityID: "", maxBytes: MAXBYTES_IN_DB, mimeType: "text/plain", preferredAction: Ci.nsIMIMEInfo.useSystemDefault, preferredApplication: "prerredApplication5", autoResume: 0, expectedCurrentBytes: 0, expectedTotalBytes: MAXBYTES_IN_DB, expectedResume: false, expectedContentType: "text/plain", }, // Queued download with no autoResume and no partial file. // Even though autoResume=0, queued downloads always autoResume. { source: sourceUrl, target: getDownloadTarget("inprogress6.txt"), tempPath: "", startTime: getStartTime(6), state: DOWNLOAD_QUEUED, referrer: httpUrl("referrer6"), entityID: "", maxBytes: MAXBYTES_IN_DB, mimeType: "text/plain", preferredAction: Ci.nsIMIMEInfo.useHelperApp, preferredApplication: "prerredApplication6", autoResume: 0, expectedCurrentBytes: TEST_DATA_LENGTH, expectedTotalBytes: TEST_DATA_LENGTH, expectedResume: true, expectedContentType: "text/plain", expectedContent: TEST_DATA_SHORT }, // Notstarted download with no autoResume and no partial file. // Even though autoResume=0, notstarted downloads always autoResume. { source: sourceUrl, target: getDownloadTarget("inprogress7.txt"), tempPath: "", startTime: getStartTime(7), state: DOWNLOAD_NOTSTARTED, referrer: httpUrl("referrer7"), entityID: "", maxBytes: MAXBYTES_IN_DB, mimeType: "text/plain", preferredAction: Ci.nsIMIMEInfo.useHelperApp, preferredApplication: "prerredApplication7", autoResume: 0, expectedCurrentBytes: TEST_DATA_LENGTH, expectedTotalBytes: TEST_DATA_LENGTH, expectedResume: true, expectedContentType: "text/plain", expectedContent: TEST_DATA_SHORT }, // Downloading download with no autoResume and a partial file. // Even though autoResume=0, downloading downloads always autoResume. { source: sourceUrl, target: getDownloadTarget("inprogress8.txt"), tempPath: yield getPartialFile("inprogress8.txt.part", true), startTime: getStartTime(8), state: DOWNLOAD_DOWNLOADING, referrer: httpUrl("referrer8"), entityID: sourceEntityId, maxBytes: MAXBYTES_IN_DB, mimeType: "text/plain", preferredAction: Ci.nsIMIMEInfo.saveToDisk, preferredApplication: "prerredApplication8", autoResume: 0, expectedCurrentBytes: TEST_DATA_LENGTH, expectedTotalBytes: TEST_DATA_LENGTH, expectedResume: true, expectedContentType: "text/plain", expectedContent: TEST_DATA_TAINTED }, ]; }); /** * Prepares the list of downloads to be added to the database that should * *not* be imported by the import procedure. */ add_task(function* prepareNonImportableDownloads() { gDownloadsRowNonImportable = [ // Download with no source (should never happen in normal circumstances). { source: "", target: "nonimportable1.txt", tempPath: "", startTime: getStartTime(1), state: DOWNLOAD_PAUSED, referrer: "", entityID: "", maxBytes: MAXBYTES_IN_DB, mimeType: "mimeType1", preferredAction: Ci.nsIMIMEInfo.saveToDisk, preferredApplication: "prerredApplication1", autoResume: 1 }, // state = DOWNLOAD_FAILED { source: httpUrl("source.txt"), target: "nonimportable2.txt", tempPath: "", startTime: getStartTime(2), state: DOWNLOAD_FAILED, referrer: "", entityID: "", maxBytes: MAXBYTES_IN_DB, mimeType: "mimeType2", preferredAction: Ci.nsIMIMEInfo.saveToDisk, preferredApplication: "prerredApplication2", autoResume: 1 }, // state = DOWNLOAD_CANCELED { source: httpUrl("source.txt"), target: "nonimportable3.txt", tempPath: "", startTime: getStartTime(3), state: DOWNLOAD_CANCELED, referrer: "", entityID: "", maxBytes: MAXBYTES_IN_DB, mimeType: "mimeType3", preferredAction: Ci.nsIMIMEInfo.saveToDisk, preferredApplication: "prerredApplication3", autoResume: 1 }, // state = DOWNLOAD_BLOCKED_PARENTAL { source: httpUrl("source.txt"), target: "nonimportable4.txt", tempPath: "", startTime: getStartTime(4), state: DOWNLOAD_BLOCKED_PARENTAL, referrer: "", entityID: "", maxBytes: MAXBYTES_IN_DB, mimeType: "mimeType4", preferredAction: Ci.nsIMIMEInfo.saveToDisk, preferredApplication: "prerredApplication4", autoResume: 1 }, // state = DOWNLOAD_SCANNING { source: httpUrl("source.txt"), target: "nonimportable5.txt", tempPath: "", startTime: getStartTime(5), state: DOWNLOAD_SCANNING, referrer: "", entityID: "", maxBytes: MAXBYTES_IN_DB, mimeType: "mimeType5", preferredAction: Ci.nsIMIMEInfo.saveToDisk, preferredApplication: "prerredApplication5", autoResume: 1 }, // state = DOWNLOAD_DIRTY { source: httpUrl("source.txt"), target: "nonimportable6.txt", tempPath: "", startTime: getStartTime(6), state: DOWNLOAD_DIRTY, referrer: "", entityID: "", maxBytes: MAXBYTES_IN_DB, mimeType: "mimeType6", preferredAction: Ci.nsIMIMEInfo.saveToDisk, preferredApplication: "prerredApplication6", autoResume: 1 }, // state = DOWNLOAD_BLOCKED_POLICY { source: httpUrl("source.txt"), target: "nonimportable7.txt", tempPath: "", startTime: getStartTime(7), state: DOWNLOAD_BLOCKED_POLICY, referrer: "", entityID: "", maxBytes: MAXBYTES_IN_DB, mimeType: "mimeType7", preferredAction: Ci.nsIMIMEInfo.saveToDisk, preferredApplication: "prerredApplication7", autoResume: 1 }, ]; }); // Test /** * Creates a temporary Sqlite database with download data and perform an * import of that data to the new Downloads API to verify that the import * worked correctly. */ add_task(function* test_downloadImport() { let connection = null; let downloadsSqlite = getTempFile("downloads.sqlite").path; try { // Set up the database. connection = yield promiseEmptyDatabaseConnection({ aPath: downloadsSqlite, aSchemaVersion: 9 }); // Insert both the importable and non-importable // downloads together. for (let downloadRow of gDownloadsRowToImport) { yield promiseInsertRow(connection, downloadRow); } for (let downloadRow of gDownloadsRowNonImportable) { yield promiseInsertRow(connection, downloadRow); } // Check that every item was inserted. do_check_eq((yield promiseTableCount(connection)), gDownloadsRowToImport.length + gDownloadsRowNonImportable.length); } finally { // Close the connection so that DownloadImport can open it. yield connection.close(); } // Import items. let list = yield promiseNewList(false); yield new DownloadImport(list, downloadsSqlite).import(); let items = yield list.getAll(); do_check_eq(items.length, gDownloadsRowToImport.length); for (let i = 0; i < gDownloadsRowToImport.length; i++) { yield checkDownload(items[i], gDownloadsRowToImport[i]); } })