/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

"use strict";

/* Unit tests for the nsIUrlClassifierRemoteSettingsService implementation. */

const { RemoteSettings } = ChromeUtils.importESModule(
  "resource://services-settings/remote-settings.sys.mjs"
);
const { SBRS_UPDATE_MINIMUM_DELAY } = ChromeUtils.importESModule(
  "resource://gre/modules/UrlClassifierRemoteSettingsService.sys.mjs"
);

const COLLECTION_NAME = "tracking-protection-lists";

const REMOTE_SETTINGS_DATA = [
  {
    Name: "content-fingerprinting-track-digest256",
    attachment: {
      hash: "96a4a850a1a475001148fa8a3a5efea58951f7176d3624ad7614fbf32732ee48",
      size: 948,
      filename: "content-fingerprinting-track-digest256",
      location:
        "main-workspace/tracking-protection-lists/content-fingerprinting-track-digest256",
      mimetype: "text/plain",
    },
    id: "content-fingerprinting-track-digest256",
    Version: 1597417364,
  },
  {
    Name: "mozplugin-block-digest256",
    attachment: {
      hash: "dd2b800c7e4bad17e1c79f3e530c0b94e0a039adf4566f30bc3c285a547fa4fc",
      size: 3029,
      filename: "mozplugin-block-digest256",
      location:
        "main-workspace/tracking-protection-lists/mozplugin-block-digest256",
      mimetype: "text/plain",
    },
    id: "mozplugin-block-digest256",
    Version: 1575583456,
  },
  {
    Name: "google-trackwhite-digest256",
    attachment: {
      hash: "1cd6d9353e97d66ac737a9716cd3a33416d6a4884dd12dcd1d65266e4c81dfad",
      size: 1470328,
      filename: "google-trackwhite-digest256",
      location:
        "main-workspace/tracking-protection-lists/google-trackwhite-digest256",
      mimetype: "text/plain",
    },
    id: "google-trackwhite-digest256",
    Version: 1575583456,
  },
  // Entry with non-exist attachment
  {
    Name: "social-track-digest256",
    attachment: {
      location: "main-workspace/tracking-protection-lists/not-exist",
    },
    id: "social-track-digest256",
    Version: 1111111111,
  },
  // Entry with corrupted attachment
  {
    Name: "analytic-track-digest256",
    attachment: {
      hash: "644a0662bcf7313570ee68490e3805f5cc7a0503c097f040525c28dc5bfe4c97",
      size: 58,
      filename: "invalid.chunk",
      location: "main-workspace/tracking-protection-lists/invalid.chunk",
      mimetype: "text/plain",
    },
    id: "analytic-track-digest256",
    Version: 1111111111,
  },
];

let gListService = Cc["@mozilla.org/url-classifier/list-service;1"].getService(
  Ci.nsIUrlClassifierRemoteSettingsService
);
let gDbService = Cc["@mozilla.org/url-classifier/dbservice;1"].getService(
  Ci.nsIUrlClassifierDBService
);

class UpdateEvent extends EventTarget {}
function waitForEvent(element, eventName) {
  return new Promise(function (resolve) {
    element.addEventListener(eventName, e => resolve(e.detail), { once: true });
  });
}

function buildPayload(tables) {
  let payload = ``;
  for (let table of tables) {
    payload += table[0];
    if (table[1] != null) {
      payload += `;a:${table[1]}`;
    }
    payload += `\n`;
  }
  return payload;
}

let server;
add_setup(async function init() {
  Services.prefs.setCharPref(
    "browser.safebrowsing.provider.mozilla.updateURL",
    `moz-sbrs://tracking-protection-list`
  );
  // Setup HTTP server for remote setting
  server = new HttpServer();
  server.start(-1);
  registerCleanupFunction(() => server.stop(() => {}));

  server.registerDirectory(
    "/cdn/main-workspace/tracking-protection-lists/",
    do_get_file("data")
  );

  server.registerPathHandler("/v1/", (request, response) => {
    response.write(
      JSON.stringify({
        capabilities: {
          attachments: {
            base_url: `http://localhost:${server.identity.primaryPort}/cdn/`,
          },
        },
      })
    );
    response.setHeader("Content-Type", "application/json; charset=UTF-8");
    response.setStatusLine(null, 200, "OK");
  });

  Services.prefs.setCharPref(
    "services.settings.server",
    `http://localhost:${server.identity.primaryPort}/v1`
  );

  // Setup remote setting initial data
  let db = await RemoteSettings(COLLECTION_NAME).db;
  await db.importChanges({}, 42, REMOTE_SETTINGS_DATA);

  registerCleanupFunction(() => {
    Services.prefs.clearUserPref(
      "browser.safebrowsing.provider.mozilla.updateURL"
    );
    Services.prefs.clearUserPref("services.settings.server");
  });
});

// Test updates from RemoteSettings when there is no local data
add_task(async function test_empty_update() {
  let updateEvent = new UpdateEvent();
  let promise = waitForEvent(updateEvent, "update");

  const TEST_TABLES = [
    ["mozplugin-block-digest256", null], // empty
    ["content-fingerprinting-track-digest256", null], // empty
  ];

  gListService.fetchList(buildPayload(TEST_TABLES), {
    // nsIStreamListener observer
    onStartRequest() {},
    onDataAvailable(aRequest, aStream, aOffset, aCount) {
      let stream = Cc["@mozilla.org/scriptableinputstream;1"].createInstance(
        Ci.nsIScriptableInputStream
      );
      stream.init(aStream);
      let event = new CustomEvent("update", {
        detail: stream.readBytes(aCount),
      });
      updateEvent.dispatchEvent(event);
    },
    onStopRequest() {},
  });

  let expected = "n:" + SBRS_UPDATE_MINIMUM_DELAY + "\n";
  for (const table of TEST_TABLES) {
    expected += `i:${table[0]}\n` + readFileToString(`data/${table[0]}`);
  }

  Assert.equal(
    await promise,
    expected,
    "Receive expected data from onDataAvailable"
  );
  gListService.clear();
});

// Test updates from RemoteSettings when we have an empty table,
// a table with an older version, and a table which is up-to-date.
add_task(async function test_update() {
  let updateEvent = new UpdateEvent();
  let promise = waitForEvent(updateEvent, "update");

  const TEST_TABLES = [
    ["mozplugin-block-digest256", 1575583456], // up-to-date
    ["content-fingerprinting-track-digest256", 1575583456 - 1], // older version
  ];

  gListService.fetchList(buildPayload(TEST_TABLES), {
    // observer
    // nsIStreamListener observer
    onStartRequest() {},
    onDataAvailable(aRequest, aStream, aOffset, aCount) {
      let stream = Cc["@mozilla.org/scriptableinputstream;1"].createInstance(
        Ci.nsIScriptableInputStream
      );
      stream.init(aStream);
      let event = new CustomEvent("update", {
        detail: stream.readBytes(aCount),
      });
      updateEvent.dispatchEvent(event);
    },
    onStopRequest() {},
  });

  // Build request with no version
  let expected = "n:" + SBRS_UPDATE_MINIMUM_DELAY + "\n";
  for (const table of TEST_TABLES) {
    if (["content-fingerprinting-track-digest256"].includes(table[0])) {
      expected += `i:${table[0]}\n` + readFileToString(`data/${table[0]}`);
    }
  }

  Assert.equal(
    await promise,
    expected,
    "Receive expected data from onDataAvailable"
  );
  gListService.clear();
});

// Test updates from RemoteSettings service when all tables are up-to-date.
add_task(async function test_no_update() {
  let updateEvent = new UpdateEvent();
  let promise = waitForEvent(updateEvent, "update");

  const TEST_TABLES = [
    ["mozplugin-block-digest256", 1575583456], // up-to-date
    ["content-fingerprinting-track-digest256", 1597417364], // up-to-date
  ];

  gListService.fetchList(buildPayload(TEST_TABLES), {
    // nsIStreamListener observer
    onStartRequest() {},
    onDataAvailable(aRequest, aStream, aOffset, aCount) {
      let stream = Cc["@mozilla.org/scriptableinputstream;1"].createInstance(
        Ci.nsIScriptableInputStream
      );
      stream.init(aStream);
      let event = new CustomEvent("update", {
        detail: stream.readBytes(aCount),
      });
      updateEvent.dispatchEvent(event);
    },
    onStopRequest() {},
  });

  // No data is expected
  let expected = "n:" + SBRS_UPDATE_MINIMUM_DELAY + "\n";

  Assert.equal(
    await promise,
    expected,
    "Receive expected data from onDataAvailable"
  );
  gListService.clear();
});

add_test(function test_update() {
  let streamUpdater = Cc[
    "@mozilla.org/url-classifier/streamupdater;1"
  ].getService(Ci.nsIUrlClassifierStreamUpdater);

  // Download some updates, and don't continue until the downloads are done.
  function updateSuccess(aEvent) {
    Assert.equal(SBRS_UPDATE_MINIMUM_DELAY, aEvent);
    info("All data processed");
    run_next_test();
  }
  // Just throw if we ever get an update or download error.
  function handleError(aEvent) {
    do_throw("We didn't download or update correctly: " + aEvent);
  }

  streamUpdater.downloadUpdates(
    "content-fingerprinting-track-digest256",
    "content-fingerprinting-track-digest256;\n",
    true,
    "moz-sbrs://remote-setting",
    updateSuccess,
    handleError,
    handleError
  );
});

add_test(function test_url_not_denylisted() {
  let uri = Services.io.newURI("http://example.com");
  let principal = Services.scriptSecurityManager.createContentPrincipal(
    uri,
    {}
  );
  gDbService.lookup(
    principal,
    "content-fingerprinting-track-digest256",
    function handleEvent(aEvent) {
      // This URI is not on any lists.
      Assert.equal("", aEvent);
      run_next_test();
    }
  );
});

add_test(function test_url_denylisted() {
  let uri = Services.io.newURI("https://www.foresee.com");
  let principal = Services.scriptSecurityManager.createContentPrincipal(
    uri,
    {}
  );
  gDbService.lookup(
    principal,
    "content-fingerprinting-track-digest256",
    function handleEvent(aEvent) {
      Assert.equal("content-fingerprinting-track-digest256", aEvent);
      run_next_test();
    }
  );
});

add_test(function test_update_download_error() {
  let streamUpdater = Cc[
    "@mozilla.org/url-classifier/streamupdater;1"
  ].getService(Ci.nsIUrlClassifierStreamUpdater);

  // Download some updates, and don't continue until the downloads are done.
  function updateSuccessOrError() {
    do_throw("Should be downbload error");
  }
  // Just throw if we ever get an update or download error.
  function downloadError() {
    run_next_test();
  }

  streamUpdater.downloadUpdates(
    "social-track-digest256",
    "social-track-digest256;\n",
    true,
    "moz-sbrs://remote-setting",
    updateSuccessOrError,
    updateSuccessOrError,
    downloadError
  );
});

add_test(function test_update_update_error() {
  let streamUpdater = Cc[
    "@mozilla.org/url-classifier/streamupdater;1"
  ].getService(Ci.nsIUrlClassifierStreamUpdater);

  // Download some updates, and don't continue until the downloads are done.
  function updateSuccessOrDownloadError() {
    do_throw("Should be update error");
  }
  // Just throw if we ever get an update or download error.
  function updateError() {
    run_next_test();
  }

  streamUpdater.downloadUpdates(
    "analytic-track-digest256",
    "analytic-track-digest256;\n",
    true,
    "moz-sbrs://remote-setting",
    updateSuccessOrDownloadError,
    updateError,
    updateSuccessOrDownloadError
  );
});

add_task(async function test_update_large_file() {
  let updateEvent = new UpdateEvent();
  let promise = waitForEvent(updateEvent, "update");

  const TEST_TABLES = [
    ["google-trackwhite-digest256", 1575583456 - 1], // up-to-date
  ];

  gListService.fetchList(buildPayload(TEST_TABLES), {
    // observer
    // nsIStreamListener observer
    onStartRequest() {},
    onDataAvailable(aRequest, aStream, aOffset, aCount) {
      let stream = Cc["@mozilla.org/scriptableinputstream;1"].createInstance(
        Ci.nsIScriptableInputStream
      );
      stream.init(aStream);
      let event = new CustomEvent("update", {
        detail: stream.readBytes(aCount),
      });
      updateEvent.dispatchEvent(event);
    },
    onStopRequest() {},
  });

  // Build request with no version
  let expected = "n:" + SBRS_UPDATE_MINIMUM_DELAY + "\n";
  for (const table of TEST_TABLES) {
    if (["google-trackwhite-digest256"].includes(table[0])) {
      expected += `i:${table[0]}\n` + readFileToString(`data/${table[0]}`);
    }
  }

  Assert.equal(
    await promise,
    expected,
    "Receive expected data from onDataAvailable"
  );
  gListService.clear();
});
