/* -*- 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() {
    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();
});
