diff options
Diffstat (limited to 'toolkit/components/extensions/test/xpcshell/test_ext_downloads_download.js')
-rw-r--r-- | toolkit/components/extensions/test/xpcshell/test_ext_downloads_download.js | 685 |
1 files changed, 685 insertions, 0 deletions
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_downloads_download.js b/toolkit/components/extensions/test/xpcshell/test_ext_downloads_download.js new file mode 100644 index 0000000000..e2867d1f03 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_downloads_download.js @@ -0,0 +1,685 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { Downloads } = ChromeUtils.importESModule( + "resource://gre/modules/Downloads.sys.mjs" +); + +const gServer = createHttpServer(); +gServer.registerDirectory("/data/", do_get_file("data")); + +gServer.registerPathHandler("/dir/", (_, res) => res.write("length=8")); + +const WINDOWS = AppConstants.platform == "win"; + +const BASE = `http://localhost:${gServer.identity.primaryPort}/`; +const FILE_NAME = "file_download.txt"; +const FILE_NAME_W_SPACES = "file download.txt"; +const FILE_URL = BASE + "data/" + FILE_NAME; +const FILE_NAME_UNIQUE = "file_download(1).txt"; +const FILE_LEN = 46; + +let downloadDir; + +function joinPath(...components) { + const separator = WINDOWS ? "\\" : "/"; + + return components.join(separator); +} + +function setup() { + downloadDir = FileUtils.getDir("TmpD", ["downloads"]); + downloadDir.createUnique( + Ci.nsIFile.DIRECTORY_TYPE, + FileUtils.PERMS_DIRECTORY + ); + info(`Using download directory ${downloadDir.path}`); + + Services.prefs.setIntPref("browser.download.folderList", 2); + Services.prefs.setComplexValue( + "browser.download.dir", + Ci.nsIFile, + downloadDir + ); + + registerCleanupFunction(() => { + Services.prefs.clearUserPref("browser.download.folderList"); + Services.prefs.clearUserPref("browser.download.dir"); + + let entries = downloadDir.directoryEntries; + while (entries.hasMoreElements()) { + let entry = entries.nextFile; + ok(false, `Leftover file ${entry.path} in download directory`); + entry.remove(false); + } + + downloadDir.remove(false); + }); +} + +function backgroundScript() { + let blobUrl; + browser.test.onMessage.addListener(async (msg, ...args) => { + if (msg == "download.request") { + let options = args[0]; + + if (options.blobme) { + let blob = new Blob(options.blobme); + delete options.blobme; + blobUrl = options.url = window.URL.createObjectURL(blob); + } + + try { + let id = await browser.downloads.download(options); + browser.test.sendMessage("download.done", { status: "success", id }); + } catch (error) { + browser.test.sendMessage("download.done", { + status: "error", + errmsg: error.message, + }); + } + } else if (msg == "killTheBlob") { + window.URL.revokeObjectURL(blobUrl); + blobUrl = null; + } + }); + + browser.test.sendMessage("ready"); +} + +// This function is a bit of a sledgehammer, it looks at every download +// the browser knows about and waits for all active downloads to complete. +// But we only start one at a time and only do a handful in total, so +// this lets us test download() without depending on anything else. +async function waitForDownloads() { + let list = await Downloads.getList(Downloads.ALL); + let downloads = await list.getAll(); + + let inprogress = downloads.filter(dl => !dl.stopped); + return Promise.all(inprogress.map(dl => dl.whenSucceeded())); +} + +// Create a file in the downloads directory. +function touch(filename) { + let file = downloadDir.clone(); + file.append(filename); + file.create(Ci.nsIFile.NORMAL_FILE_TYPE, FileUtils.PERMS_FILE); +} + +// Remove a file in the downloads directory. +function remove(filename, recursive = false) { + let file = downloadDir.clone(); + file.append(filename); + file.remove(recursive); +} + +add_task(async function test_downloads() { + setup(); + + let extension = ExtensionTestUtils.loadExtension({ + background: `(${backgroundScript})()`, + manifest: { + permissions: ["downloads"], + }, + incognitoOverride: "spanning", + }); + + function download(options) { + extension.sendMessage("download.request", options); + return extension.awaitMessage("download.done"); + } + + async function testDownload(options, localFile, expectedSize, description) { + let msg = await download(options); + equal( + msg.status, + "success", + `downloads.download() works with ${description}` + ); + + await waitForDownloads(); + + let localPath = downloadDir.clone(); + let parts = Array.isArray(localFile) ? localFile : [localFile]; + + parts.map(p => localPath.append(p)); + equal( + localPath.fileSize, + expectedSize, + "Downloaded file has expected size" + ); + localPath.remove(false); + } + + await extension.startup(); + await extension.awaitMessage("ready"); + info("extension started"); + + // Call download() with just the url property. + await testDownload({ url: FILE_URL }, FILE_NAME, FILE_LEN, "just source"); + + // Call download() with a filename property. + await testDownload( + { + url: FILE_URL, + filename: "newpath.txt", + }, + "newpath.txt", + FILE_LEN, + "source and filename" + ); + + // Call download() with a filename with subdirs. + await testDownload( + { + url: FILE_URL, + filename: "sub/dir/file", + }, + ["sub", "dir", "file"], + FILE_LEN, + "source and filename with subdirs" + ); + + // Call download() with a filename with existing subdirs. + await testDownload( + { + url: FILE_URL, + filename: "sub/dir/file2", + }, + ["sub", "dir", "file2"], + FILE_LEN, + "source and filename with existing subdirs" + ); + + // Only run Windows path separator test on Windows. + if (WINDOWS) { + // Call download() with a filename with Windows path separator. + await testDownload( + { + url: FILE_URL, + filename: "sub\\dir\\file3", + }, + ["sub", "dir", "file3"], + FILE_LEN, + "filename with Windows path separator" + ); + } + remove("sub", true); + + // Call download(), filename with subdir, skipping parts. + await testDownload( + { + url: FILE_URL, + filename: "skip//part", + }, + ["skip", "part"], + FILE_LEN, + "source, filename, with subdir, skipping parts" + ); + remove("skip", true); + + // Check conflictAction of "uniquify". + touch(FILE_NAME); + await testDownload( + { + url: FILE_URL, + conflictAction: "uniquify", + }, + FILE_NAME_UNIQUE, + FILE_LEN, + "conflictAction=uniquify" + ); + // todo check that preexisting file was not modified? + remove(FILE_NAME); + + // Check conflictAction of "overwrite". + touch(FILE_NAME); + await testDownload( + { + url: FILE_URL, + conflictAction: "overwrite", + }, + FILE_NAME, + FILE_LEN, + "conflictAction=overwrite" + ); + + // Try to download in invalid url + await download({ url: "this is not a valid URL" }).then(msg => { + equal(msg.status, "error", "downloads.download() fails with invalid url"); + ok( + /not a valid URL/.test(msg.errmsg), + "error message for invalid url is correct" + ); + }); + + // Try to download to an empty path. + await download({ + url: FILE_URL, + filename: "", + }).then(msg => { + equal( + msg.status, + "error", + "downloads.download() fails with empty filename" + ); + equal( + msg.errmsg, + "filename must not be empty", + "error message for empty filename is correct" + ); + }); + + // Try to download to an absolute path. + const absolutePath = PathUtils.join( + WINDOWS ? "C:\\tmp" : "/tmp", + "file_download.txt" + ); + await download({ + url: FILE_URL, + filename: absolutePath, + }).then(msg => { + equal( + msg.status, + "error", + "downloads.download() fails with absolute filename" + ); + equal( + msg.errmsg, + "filename must not be an absolute path", + `error message for absolute path (${absolutePath}) is correct` + ); + }); + + if (WINDOWS) { + await download({ + url: FILE_URL, + filename: "C:\\file_download.txt", + }).then(msg => { + equal( + msg.status, + "error", + "downloads.download() fails with absolute filename" + ); + equal( + msg.errmsg, + "filename must not be an absolute path", + "error message for absolute path with drive letter is correct" + ); + }); + } + + // Try to download to a relative path containing .. + await download({ + url: FILE_URL, + filename: joinPath("..", "file_download.txt"), + }).then(msg => { + equal( + msg.status, + "error", + "downloads.download() fails with back-references" + ); + equal( + msg.errmsg, + "filename must not contain back-references (..)", + "error message for back-references is correct" + ); + }); + + // Try to download to a long relative path containing .. + await download({ + url: FILE_URL, + filename: joinPath("foo", "..", "..", "file_download.txt"), + }).then(msg => { + equal( + msg.status, + "error", + "downloads.download() fails with back-references" + ); + equal( + msg.errmsg, + "filename must not contain back-references (..)", + "error message for back-references is correct" + ); + }); + + // Test illegal characters. + await download({ + url: FILE_URL, + filename: "like:this", + }).then(msg => { + equal(msg.status, "error", "downloads.download() fails with illegal chars"); + equal( + msg.errmsg, + "filename must not contain illegal characters", + "error message correct" + ); + }); + + // Try to download a blob url + const BLOB_STRING = "Hello, world"; + await testDownload( + { + blobme: [BLOB_STRING], + filename: FILE_NAME, + }, + FILE_NAME, + BLOB_STRING.length, + "blob url" + ); + extension.sendMessage("killTheBlob"); + + // Try to download a blob url without a given filename + await testDownload( + { + blobme: [BLOB_STRING], + }, + "download", + BLOB_STRING.length, + "blob url with no filename" + ); + extension.sendMessage("killTheBlob"); + + // Download a normal URL with an empty filename part. + await testDownload( + { + url: BASE + "dir/", + }, + "download", + 8, + "normal url with empty filename" + ); + + // Download a filename with multiple spaces, url is ignored for this test. + await testDownload( + { + url: FILE_URL, + filename: "a file.txt", + }, + "a file.txt", + FILE_LEN, + "filename with multiple spaces" + ); + + // Download a normal URL with a leafname containing multiple spaces. + // Note: spaces are compressed by file name normalization. + await testDownload( + { + url: BASE + "data/" + FILE_NAME_W_SPACES, + }, + FILE_NAME_W_SPACES.replace(/\s+/, " "), + FILE_LEN, + "leafname with multiple spaces" + ); + + // Check that the "incognito" property is supported. + await testDownload( + { + url: FILE_URL, + incognito: false, + }, + FILE_NAME, + FILE_LEN, + "incognito=false" + ); + + await testDownload( + { + url: FILE_URL, + incognito: true, + }, + FILE_NAME, + FILE_LEN, + "incognito=true" + ); + + await extension.unload(); +}); + +async function testHttpErrors(allowHttpErrors) { + const server = createHttpServer(); + const url = `http://localhost:${server.identity.primaryPort}/error`; + const content = "HTTP Error test"; + + server.registerPathHandler("/error", (request, response) => { + response.setStatusLine( + "1.1", + parseInt(request.queryString, 10), + "Some Error" + ); + response.setHeader("Content-Type", "text/plain", false); + response.setHeader("Content-Length", content.length.toString()); + response.write(content); + }); + + function background(code) { + let dlid = 0; + let expectedState; + browser.test.onMessage.addListener(async options => { + try { + expectedState = options.allowHttpErrors ? "complete" : "interrupted"; + dlid = await browser.downloads.download(options); + } catch (err) { + browser.test.fail(`Unexpected error in downloads.download(): ${err}`); + } + }); + function onChanged({ id, state }) { + if (dlid !== id || !state || state.current === "in_progress") { + return; + } + browser.test.assertEq(state.current, expectedState, "correct state"); + browser.downloads.search({ id }).then(([download]) => { + browser.test.sendMessage("done", download.error); + }); + } + browser.downloads.onChanged.addListener(onChanged); + } + + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["downloads"], + }, + background, + }); + await extension.startup(); + + async function download(code, expected_when_disallowed) { + const options = { + url: url + "?" + code, + filename: `test-${code}`, + conflictAction: "overwrite", + allowHttpErrors, + }; + extension.sendMessage(options); + const rv = await extension.awaitMessage("done"); + + if (allowHttpErrors) { + const localPath = downloadDir.clone(); + localPath.append(options.filename); + equal( + localPath.fileSize, + // The 20x No content errors will not produce any response body, + // only "true" errors do. + code >= 400 ? content.length : 0, + "Downloaded file has expected size" + code + ); + localPath.remove(false); + + ok(!rv, "error must be ignored and hence false-y"); + return; + } + + equal( + rv, + expected_when_disallowed, + "error must have the correct InterruptReason" + ); + } + + await download(204, "SERVER_BAD_CONTENT"); // No Content + await download(205, "SERVER_BAD_CONTENT"); // Reset Content + await download(404, "SERVER_BAD_CONTENT"); // Not Found + await download(403, "SERVER_FORBIDDEN"); // Forbidden + await download(402, "SERVER_UNAUTHORIZED"); // Unauthorized + await download(407, "SERVER_UNAUTHORIZED"); // Proxy auth required + await download(504, "SERVER_FAILED"); //General errors, here Gateway Timeout + + await extension.unload(); +} + +add_task(function test_download_disallowed_http_errors() { + return testHttpErrors(false); +}); + +add_task(function test_download_allowed_http_errors() { + return testHttpErrors(true); +}); + +add_task(async function test_download_http_details() { + const server = createHttpServer(); + const url = `http://localhost:${server.identity.primaryPort}/post-log`; + + let received; + server.registerPathHandler("/post-log", (request, response) => { + received = request; + response.setHeader("Set-Cookie", "monster=", false); + }); + + // Confirm received vs. expected values. + function confirm(method, headers = {}, body) { + equal(received.method, method, "method is correct"); + + for (let name in headers) { + ok(received.hasHeader(name), `header ${name} received`); + equal( + received.getHeader(name), + headers[name], + `header ${name} is correct` + ); + } + + if (body) { + const str = NetUtil.readInputStreamToString( + received.bodyInputStream, + received.bodyInputStream.available() + ); + equal(str, body, "body is correct"); + } + } + + function background() { + browser.test.onMessage.addListener(async options => { + try { + await browser.downloads.download(options); + } catch (err) { + browser.test.sendMessage("done", { err: err.message }); + } + }); + browser.downloads.onChanged.addListener(({ state }) => { + if (state && state.current === "complete") { + browser.test.sendMessage("done", { ok: true }); + } + }); + } + + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["downloads"], + }, + background, + incognitoOverride: "spanning", + }); + await extension.startup(); + + function download(options) { + options.url = url; + options.conflictAction = "overwrite"; + + extension.sendMessage(options); + return extension.awaitMessage("done"); + } + + // Test that site cookies are sent with download requests, + // and "incognito" downloads use a separate cookie jar. + let testDownloadCookie = async function (incognito) { + let result = await download({ incognito }); + ok(result.ok, `preflight to set cookies with incognito=${incognito}`); + ok(!received.hasHeader("cookie"), "first request has no cookies"); + + result = await download({ incognito }); + ok(result.ok, `download with cookie with incognito=${incognito}`); + equal( + received.getHeader("cookie"), + "monster=", + "correct cookie header sent for second download" + ); + }; + + await testDownloadCookie(false); + await testDownloadCookie(true); + + // Test method option. + let result = await download({}); + ok(result.ok, "download works without the method option, defaults to GET"); + confirm("GET"); + + result = await download({ method: "PUT" }); + ok(!result.ok, "download rejected with PUT method"); + ok( + /method: Invalid enumeration/.test(result.err), + "descriptive error message" + ); + + result = await download({ method: "POST" }); + ok(result.ok, "download works with POST method"); + confirm("POST"); + + // Test body option values. + result = await download({ body: [] }); + ok(!result.ok, "download rejected because of non-string body"); + ok(/body: Expected string/.test(result.err), "descriptive error message"); + + result = await download({ method: "POST", body: "of work" }); + ok(result.ok, "download works with POST method and body"); + confirm("POST", { "Content-Length": 7 }, "of work"); + + // Test custom headers. + result = await download({ headers: [{ name: "X-Custom" }] }); + ok(!result.ok, "download rejected because of missing header value"); + ok(/"value" is required/.test(result.err), "descriptive error message"); + + result = await download({ headers: [{ name: "X-Custom", value: "13" }] }); + ok(result.ok, "download works with a custom header"); + confirm("GET", { "X-Custom": "13" }); + + // Test Referer header. + const referer = "http://example.org/test"; + result = await download({ headers: [{ name: "Referer", value: referer }] }); + ok(result.ok, "download works with Referer header"); + confirm("GET", { Referer: referer }); + + // Test forbidden headers. + result = await download({ headers: [{ name: "DNT", value: "1" }] }); + ok(!result.ok, "download rejected because of forbidden header name DNT"); + ok(/Forbidden request header/.test(result.err), "descriptive error message"); + + result = await download({ + headers: [{ name: "Proxy-Connection", value: "keep" }], + }); + ok( + !result.ok, + "download rejected because of forbidden header name prefix Proxy-" + ); + ok(/Forbidden request header/.test(result.err), "descriptive error message"); + + result = await download({ headers: [{ name: "Sec-ret", value: "13" }] }); + ok( + !result.ok, + "download rejected because of forbidden header name prefix Sec-" + ); + ok(/Forbidden request header/.test(result.err), "descriptive error message"); + + remove("post-log"); + await extension.unload(); +}); |