/* Any copyright is dedicated to the Public Domain.
   http://creativecommons.org/publicdomain/zero/1.0/ */

"use strict";

/**
 * Tests Curl Utils functionality.
 */

const {
  Curl,
  CurlUtils,
} = require("resource://devtools/client/shared/curl.js");

add_task(async function () {
  const { tab, monitor } = await initNetMonitor(HTTPS_CURL_UTILS_URL, {
    requestCount: 1,
  });
  info("Starting test... ");

  const { store, windowRequire, connector } = monitor.panelWin;
  const Actions = windowRequire("devtools/client/netmonitor/src/actions/index");
  const { getSortedRequests } = windowRequire(
    "devtools/client/netmonitor/src/selectors/index"
  );
  const { getLongString, requestData } = connector;

  store.dispatch(Actions.batchEnable(false));

  const wait = waitForNetworkEvents(monitor, 6);
  await SpecialPowers.spawn(
    tab.linkedBrowser,
    [HTTPS_SIMPLE_SJS],
    async function (url) {
      content.wrappedJSObject.performRequests(url);
    }
  );
  await wait;

  const requests = {
    get: getSortedRequests(store.getState())[0],
    post: getSortedRequests(store.getState())[1],
    postJson: getSortedRequests(store.getState())[2],
    patch: getSortedRequests(store.getState())[3],
    multipart: getSortedRequests(store.getState())[4],
    multipartForm: getSortedRequests(store.getState())[5],
  };

  let data = await createCurlData(requests.get, getLongString, requestData);
  testFindHeader(data);

  data = await createCurlData(requests.post, getLongString, requestData);
  testIsUrlEncodedRequest(data);
  testWritePostDataTextParams(data);
  testWriteEmptyPostDataTextParams(data);
  testDataArgumentOnGeneratedCommand(data);

  data = await createCurlData(requests.patch, getLongString, requestData);
  testWritePostDataTextParams(data);
  testDataArgumentOnGeneratedCommand(data);

  data = await createCurlData(requests.postJson, getLongString, requestData);
  testDataEscapeOnGeneratedCommand(data);

  data = await createCurlData(requests.multipart, getLongString, requestData);
  testIsMultipartRequest(data);
  testGetMultipartBoundary(data);
  testMultiPartHeaders(data);
  testRemoveBinaryDataFromMultipartText(data);

  data = await createCurlData(
    requests.multipartForm,
    getLongString,
    requestData
  );
  testMultiPartHeaders(data);

  testGetHeadersFromMultipartText({
    postDataText: "Content-Type: text/plain\r\n\r\n",
  });

  if (Services.appinfo.OS != "WINNT") {
    testEscapeStringPosix();
  } else {
    testEscapeStringWin();
  }

  await teardown(monitor);
});

function testIsUrlEncodedRequest(data) {
  const isUrlEncoded = CurlUtils.isUrlEncodedRequest(data);
  ok(isUrlEncoded, "Should return true for url encoded requests.");
}

function testIsMultipartRequest(data) {
  const isMultipart = CurlUtils.isMultipartRequest(data);
  ok(isMultipart, "Should return true for multipart/form-data requests.");
}

function testFindHeader(data) {
  const { headers } = data;
  const hostName = CurlUtils.findHeader(headers, "Host");
  const requestedWithLowerCased = CurlUtils.findHeader(
    headers,
    "x-requested-with"
  );
  const doesNotExist = CurlUtils.findHeader(headers, "X-Does-Not-Exist");

  is(
    hostName,
    "example.com",
    "Header with name 'Host' should be found in the request array."
  );
  is(
    requestedWithLowerCased,
    "XMLHttpRequest",
    "The search should be case insensitive."
  );
  is(doesNotExist, null, "Should return null when a header is not found.");
}

function testMultiPartHeaders(data) {
  const { headers } = data;
  const contentType = CurlUtils.findHeader(headers, "Content-Type");

  ok(
    contentType.startsWith("multipart/form-data; boundary="),
    "Multi-part content type header is present in headers array"
  );
}

function testWritePostDataTextParams(data) {
  const params = CurlUtils.writePostDataTextParams(data.postDataText);
  is(
    params,
    "param1=value1&param2=value2&param3=value3",
    "Should return a serialized representation of the request parameters"
  );
}

function testWriteEmptyPostDataTextParams() {
  const params = CurlUtils.writePostDataTextParams(null);
  is(params, "", "Should return a empty string when no parameters provided");
}

function testDataArgumentOnGeneratedCommand(data) {
  const curlCommand = Curl.generateCommand(data);
  ok(
    curlCommand.includes("--data-raw"),
    "Should return a curl command with --data-raw"
  );
}

function testDataEscapeOnGeneratedCommand(data) {
  const paramsWin = `--data-raw "{""param1"":""value1"",""param2"":""value2""}"`;
  const paramsPosix = `--data-raw '{"param1":"value1","param2":"value2"}'`;

  let curlCommand = Curl.generateCommand(data, "WINNT");
  ok(
    curlCommand.includes(paramsWin),
    "Should return a curl command with --data-raw escaped for Windows systems"
  );

  curlCommand = Curl.generateCommand(data, "Linux");
  ok(
    curlCommand.includes(paramsPosix),
    "Should return a curl command with --data-raw escaped for Posix systems"
  );
}

function testGetMultipartBoundary(data) {
  const boundary = CurlUtils.getMultipartBoundary(data);
  ok(
    /-{3,}\w+/.test(boundary),
    "A boundary string should be found in a multipart request."
  );
}

function testRemoveBinaryDataFromMultipartText(data) {
  const generatedBoundary = CurlUtils.getMultipartBoundary(data);
  const text = data.postDataText;
  const binaryRemoved = CurlUtils.removeBinaryDataFromMultipartText(
    text,
    generatedBoundary
  );
  const boundary = "--" + generatedBoundary;

  const EXPECTED_POSIX_RESULT = [
    "$'",
    boundary,
    "\\r\\n",
    'Content-Disposition: form-data; name="param1"',
    "\\r\\n\\r\\n",
    "value1",
    "\\r\\n",
    boundary,
    "\\r\\n",
    'Content-Disposition: form-data; name="file"; filename="filename.png"',
    "\\r\\n",
    "Content-Type: image/png",
    "\\r\\n\\r\\n",
    boundary + "--",
    "\\r\\n",
    "'",
  ].join("");

  const EXPECTED_WIN_RESULT = [
    '"',
    boundary,
    '"^\u000d\u000A\u000d\u000A"',
    'Content-Disposition: form-data; name=""param1""',
    '"^\u000d\u000A\u000d\u000A""^\u000d\u000A\u000d\u000A"',
    "value1",
    '"^\u000d\u000A\u000d\u000A"',
    boundary,
    '"^\u000d\u000A\u000d\u000A"',
    'Content-Disposition: form-data; name=""file""; filename=""filename.png""',
    '"^\u000d\u000A\u000d\u000A"',
    "Content-Type: image/png",
    '"^\u000d\u000A\u000d\u000A""^\u000d\u000A\u000d\u000A"',
    boundary + "--",
    '"^\u000d\u000A\u000d\u000A"',
    '"',
  ].join("");

  if (Services.appinfo.OS != "WINNT") {
    is(
      CurlUtils.escapeStringPosix(binaryRemoved),
      EXPECTED_POSIX_RESULT,
      "The mulitpart request payload should not contain binary data."
    );
  } else {
    is(
      CurlUtils.escapeStringWin(binaryRemoved),
      EXPECTED_WIN_RESULT,
      "WinNT: The mulitpart request payload should not contain binary data."
    );
  }
}

function testGetHeadersFromMultipartText(data) {
  const headers = CurlUtils.getHeadersFromMultipartText(data.postDataText);

  ok(Array.isArray(headers), "Should return an array.");
  ok(!!headers.length, "There should exist at least one request header.");
  is(
    headers[0].name,
    "Content-Type",
    "The first header name should be 'Content-Type'."
  );
}

function testEscapeStringPosix() {
  const surroundedWithQuotes = "A simple string";
  is(
    CurlUtils.escapeStringPosix(surroundedWithQuotes),
    "'A simple string'",
    "The string should be surrounded with single quotes."
  );

  const singleQuotes = "It's unusual to put crickets in your coffee.";
  is(
    CurlUtils.escapeStringPosix(singleQuotes),
    "$'It\\'s unusual to put crickets in your coffee.'",
    "Single quotes should be escaped."
  );

  const escapeChar = "'!ls:q:gs|ls|;ping 8.8.8.8;|";
  is(
    CurlUtils.escapeStringPosix(escapeChar),
    "$'\\'\\041ls:q:gs|ls|;ping 8.8.8.8;|'",
    "'!' should be escaped."
  );

  const newLines = "Line 1\r\nLine 2\u000d\u000ALine3";
  is(
    CurlUtils.escapeStringPosix(newLines),
    "$'Line 1\\r\\nLine 2\\r\\nLine3'",
    "Newlines should be escaped."
  );

  const controlChars = "\u0007 \u0009 \u000C \u001B";
  is(
    CurlUtils.escapeStringPosix(controlChars),
    "$'\\x07 \\x09 \\x0c \\x1b'",
    "Control characters should be escaped."
  );

  // æ ø ü ß ö é
  const extendedAsciiChars =
    "\xc3\xa6 \xc3\xb8 \xc3\xbc \xc3\x9f \xc3\xb6 \xc3\xa9";
  is(
    CurlUtils.escapeStringPosix(extendedAsciiChars),
    "$'\\xc3\\xa6 \\xc3\\xb8 \\xc3\\xbc \\xc3\\x9f \\xc3\\xb6 \\xc3\\xa9'",
    "Character codes outside of the decimal range 32 - 126 should be escaped."
  );
}

function testEscapeStringWin() {
  const surroundedWithDoubleQuotes = "A simple string";
  is(
    CurlUtils.escapeStringWin(surroundedWithDoubleQuotes),
    '"A simple string"',
    "The string should be surrounded with double quotes."
  );

  const doubleQuotes = 'Quote: "Time is an illusion. Lunchtime doubly so."';
  is(
    CurlUtils.escapeStringWin(doubleQuotes),
    '"Quote: ""Time is an illusion. Lunchtime doubly so."""',
    "Double quotes should be escaped."
  );

  const percentSigns = "%TEMP% %@foo% %2XX% %_XX% %?XX%";
  is(
    CurlUtils.escapeStringWin(percentSigns),
    '"^%^TEMP^% ^%^@foo^% ^%^2XX^% ^%^_XX^% ^%?XX^%"',
    "Percent signs should be escaped."
  );

  const backslashes = "\\A simple string\\";
  is(
    CurlUtils.escapeStringWin(backslashes),
    '"\\\\A simple string\\\\"',
    "Backslashes should be escaped."
  );

  const newLines = "line1\r\nline2\r\rline3\n\nline4";
  is(
    CurlUtils.escapeStringWin(newLines),
    '"line1"^\r\n\r\n"line2"^\r\n\r\n""^\r\n\r\n"line3"^\r\n\r\n""^\r\n\r\n"line4"',
    "Newlines should be escaped."
  );

  const dollarSignCommand = "$(calc.exe)";
  is(
    CurlUtils.escapeStringWin(dollarSignCommand),
    '"\\$(calc.exe)"',
    "Dollar sign should be escaped."
  );

  const tickSignCommand = "`$(calc.exe)";
  is(
    CurlUtils.escapeStringWin(tickSignCommand),
    '"\\`\\$(calc.exe)"',
    "Both the tick and dollar signs should be escaped."
  );

  const evilCommand = `query=evil\r\rcmd" /c timeout /t 3 & calc.exe\r\r`;
  is(
    CurlUtils.escapeStringWin(evilCommand),
    '"query=evil"^\r\n\r\n""^\r\n\r\n"cmd"" /c timeout /t 3 & calc.exe"^\r\n\r\n""^\r\n\r\n""',
    "The evil command is escaped properly"
  );
}

async function createCurlData(selected, getLongString, requestData) {
  const { id, url, method, httpVersion } = selected;

  // Create a sanitized object for the Curl command generator.
  const data = {
    url,
    method,
    headers: [],
    httpVersion,
    postDataText: null,
  };

  const requestHeaders = await requestData(id, "requestHeaders");
  // Fetch header values.
  for (const { name, value } of requestHeaders.headers) {
    const text = await getLongString(value);
    data.headers.push({ name, value: text });
  }

  const requestPostData = await requestData(id, "requestPostData");
  // Fetch the request payload.
  if (requestPostData) {
    const postData = requestPostData.postData.text;
    data.postDataText = await getLongString(postData);
  }

  return data;
}
