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

"use strict";

const { PreferencesBackupResource } = ChromeUtils.importESModule(
  "resource:///modules/backup/PreferencesBackupResource.sys.mjs"
);
const { SearchUtils } = ChromeUtils.importESModule(
  "moz-src:///toolkit/components/search/SearchUtils.sys.mjs"
);

/**
 * Test that the measure method correctly collects the disk-sizes of things that
 * the PreferencesBackupResource is meant to back up.
 */
add_task(async function test_measure() {
  Services.fog.testResetFOG();

  const EXPECTED_PREFERENCES_KILOBYTES_SIZE = 415;
  const tempDir = await IOUtils.createUniqueDirectory(
    PathUtils.tempDir,
    "PreferencesBackupResource-measure-test"
  );
  const mockFiles = [
    { path: "prefs.js", sizeInKB: 20 },
    { path: "xulstore.json", sizeInKB: 1 },
    { path: "permissions.sqlite", sizeInKB: 100 },
    { path: "content-prefs.sqlite", sizeInKB: 260 },
    { path: "containers.json", sizeInKB: 1 },
    { path: "handlers.json", sizeInKB: 1 },
    { path: "search.json.mozlz4", sizeInKB: 1 },
    { path: "user.js", sizeInKB: 2 },
    { path: ["chrome", "userChrome.css"], sizeInKB: 5 },
    { path: ["chrome", "userContent.css"], sizeInKB: 5 },
    { path: ["chrome", "css", "mockStyles.css"], sizeInKB: 5 },
  ];

  await createTestFiles(tempDir, mockFiles);

  let preferencesBackupResource = new PreferencesBackupResource();

  await preferencesBackupResource.measure(tempDir);

  let measurement = Glean.browserBackup.preferencesSize.testGetValue();
  let scalars = TelemetryTestUtils.getProcessScalars("parent", false, false);

  TelemetryTestUtils.assertScalar(
    scalars,
    "browser.backup.preferences_size",
    measurement,
    "Glean and telemetry measurements for preferences data should be equal"
  );
  Assert.equal(
    measurement,
    EXPECTED_PREFERENCES_KILOBYTES_SIZE,
    "Should have collected the correct glean measurement for preferences files"
  );

  await maybeRemovePath(tempDir);
});

/**
 * Test that the backup method correctly copies items from the profile directory
 * into the staging directory.
 */
add_task(async function test_backup() {
  let sandbox = sinon.createSandbox();

  let preferencesBackupResource = new PreferencesBackupResource();
  let sourcePath = await IOUtils.createUniqueDirectory(
    PathUtils.tempDir,
    "PreferencesBackupResource-source-test"
  );
  let stagingPath = await IOUtils.createUniqueDirectory(
    PathUtils.tempDir,
    "PreferencesBackupResource-staging-test"
  );

  const simpleCopyFiles = [
    { path: "xulstore.json" },
    { path: "containers.json" },
    { path: "handlers.json" },
    { path: "search.json.mozlz4" },
    { path: "user.js" },
    { path: ["chrome", "userChrome.css"] },
    { path: ["chrome", "userContent.css"] },
    { path: ["chrome", "childFolder", "someOtherStylesheet.css"] },
  ];
  await createTestFiles(sourcePath, simpleCopyFiles);

  // Create our fake database files. We don't expect these to be copied to the
  // staging directory in this test due to our stubbing of the backup method, so
  // we don't include it in `simpleCopyFiles`.
  await createTestFiles(sourcePath, [
    { path: "permissions.sqlite" },
    { path: "content-prefs.sqlite" },
  ]);

  // We have no need to test that Sqlite.sys.mjs's backup method is working -
  // this is something that is tested in Sqlite's own tests. We can just make
  // sure that it's being called using sinon. Unfortunately, we cannot do the
  // same thing with IOUtils.copy, as its methods are not stubbable.
  let fakeConnection = {
    backup: sandbox.stub().resolves(true),
    close: sandbox.stub().resolves(true),
  };
  sandbox.stub(Sqlite, "openConnection").returns(fakeConnection);

  let manifestEntry = await preferencesBackupResource.backup(
    stagingPath,
    sourcePath
  );
  Assert.deepEqual(
    manifestEntry,
    { profilePath: sourcePath },
    "PreferencesBackupResource.backup should return the original profile path " +
      "in its ManifestEntry"
  );

  await assertFilesExist(stagingPath, simpleCopyFiles);

  // Next, we'll make sure that the Sqlite connection had `backup` called on it
  // with the right arguments.
  Assert.ok(
    fakeConnection.backup.calledTwice,
    "Called backup the expected number of times for all connections"
  );
  Assert.ok(
    fakeConnection.backup.firstCall.calledWith(
      PathUtils.join(stagingPath, "permissions.sqlite")
    ),
    "Called backup on the permissions.sqlite Sqlite connection"
  );
  Assert.ok(
    fakeConnection.backup.secondCall.calledWith(
      PathUtils.join(stagingPath, "content-prefs.sqlite")
    ),
    "Called backup on the content-prefs.sqlite Sqlite connection"
  );

  // And we'll make sure that preferences were properly written out.
  Assert.ok(
    await IOUtils.exists(PathUtils.join(stagingPath, "prefs.js")),
    "prefs.js should exist in the staging folder"
  );

  await maybeRemovePath(stagingPath);
  await maybeRemovePath(sourcePath);

  sandbox.restore();
});

/**
 * Tests that the backup method does not copy the permissions or content prefs
 * databases if the browser is configured to not save history - either while
 * running, or to clear it at shutdown.
 */
add_task(async function test_backup_no_saved_history() {
  let preferencesBackupResource = new PreferencesBackupResource();
  let sourcePath = await IOUtils.createUniqueDirectory(
    PathUtils.tempDir,
    "PreferencesBackupResource-source-test"
  );
  let stagingPath = await IOUtils.createUniqueDirectory(
    PathUtils.tempDir,
    "PreferencesBackupResource-staging-test"
  );

  let sandbox = sinon.createSandbox();
  let fakeConnection = {
    backup: sandbox.stub().resolves(true),
    close: sandbox.stub().resolves(true),
  };
  sandbox.stub(Sqlite, "openConnection").returns(fakeConnection);

  // First, we'll try with browsing history in general being disabled.
  Services.prefs.setBoolPref(HISTORY_ENABLED_PREF, false);
  Services.prefs.setBoolPref(SANITIZE_ON_SHUTDOWN_PREF, false);

  let manifestEntry = await preferencesBackupResource.backup(
    stagingPath,
    sourcePath
  );
  Assert.deepEqual(
    manifestEntry,
    { profilePath: sourcePath },
    "PreferencesBackupResource.backup should return the original profile path " +
      "in its ManifestEntry"
  );

  Assert.ok(
    fakeConnection.backup.notCalled,
    "No sqlite connections should have been made with remember history disabled"
  );

  // Now verify that the sanitize shutdown pref also prevents us from backing
  // up site permissions and preferences
  Services.prefs.setBoolPref(HISTORY_ENABLED_PREF, true);
  Services.prefs.setBoolPref(SANITIZE_ON_SHUTDOWN_PREF, true);

  fakeConnection.backup.resetHistory();
  manifestEntry = await preferencesBackupResource.backup(
    stagingPath,
    sourcePath
  );
  Assert.deepEqual(
    manifestEntry,
    { profilePath: sourcePath },
    "PreferencesBackupResource.backup should return the original profile path " +
      "in its ManifestEntry"
  );

  Assert.ok(
    fakeConnection.backup.notCalled,
    "No sqlite connections should have been made with sanitize shutdown enabled"
  );

  await maybeRemovePath(stagingPath);
  await maybeRemovePath(sourcePath);

  sandbox.restore();
  Services.prefs.clearUserPref(HISTORY_ENABLED_PREF);
  Services.prefs.clearUserPref(SANITIZE_ON_SHUTDOWN_PREF);
});

/**
 * Tests that the backup method correctly skips backing up the permissions and
 * content prefs databases if permanent private browsing mode is enabled.
 */
add_task(async function test_backup_private_browsing() {
  let sandbox = sinon.createSandbox();

  let preferencesBackupResource = new PreferencesBackupResource();
  let sourcePath = await IOUtils.createUniqueDirectory(
    PathUtils.tempDir,
    "PreferencesBackupResource-source-test"
  );
  let stagingPath = await IOUtils.createUniqueDirectory(
    PathUtils.tempDir,
    "PreferencesBackupResource-staging-test"
  );

  let fakeConnection = {
    backup: sandbox.stub().resolves(true),
    close: sandbox.stub().resolves(true),
  };
  sandbox.stub(Sqlite, "openConnection").returns(fakeConnection);
  sandbox.stub(PrivateBrowsingUtils, "permanentPrivateBrowsing").value(true);

  let manifestEntry = await preferencesBackupResource.backup(
    stagingPath,
    sourcePath
  );
  Assert.deepEqual(
    manifestEntry,
    { profilePath: sourcePath },
    "PreferencesBackupResource.backup should return the original profile path " +
      "in its ManifestEntry"
  );

  Assert.ok(
    fakeConnection.backup.notCalled,
    "No sqlite connections should have been made with permanent private browsing enabled"
  );

  await maybeRemovePath(stagingPath);
  await maybeRemovePath(sourcePath);

  sandbox.restore();
});

/**
 * Check that prefs.js has "browser.backup.profile-restoration-date".  Due to
 * concerns over potential time skips in automation, we only check that the
 * timestamp is not more than a week before/after now (we would expect the
 * difference to be more like a few milliseconds).
 *
 * @param {string} prefsJsPath
 */
async function checkPrefsJsHasValidRecoveryTime(prefsJsPath) {
  Assert.equal(
    Services.prefs.getPrefType("browser.backup.profile-restoration-date"),
    Services.prefs.PREF_INVALID,
    "Restoration pref not set since current profile was not restored"
  );

  // NB: The non-profile-restoration-date part of the prefs file is junk made
  // by `createTestFiles`.  We don't care about that here.
  const contents = await IOUtils.readUTF8(prefsJsPath);
  const dateRegex =
    /pref\("browser\.backup\.profile-restoration-date", (\d+)\);/;
  let restoreDate = contents.match(dateRegex);
  Assert.equal(restoreDate.length, 2, "found the restoration date");

  const kOneWeekAgoInSec =
    60 /* sec/min */ * 60 /* min/hr */ * 24 /* hr/day */ * 7; /* day/wk */
  const nowInSeconds = Math.round(Date.now() / 1000);
  Assert.lessOrEqual(
    Math.abs(nowInSeconds - Number(restoreDate[1])),
    kOneWeekAgoInSec,
    "timestamp was within one week of now"
  );
}

/**
 * Test that the recover method correctly copies items from the recovery
 * directory into the destination profile directory.
 */
add_task(async function test_recover() {
  let sandbox = sinon.createSandbox();
  let preferencesBackupResource = new PreferencesBackupResource();
  let recoveryPath = await IOUtils.createUniqueDirectory(
    PathUtils.tempDir,
    "PreferencesBackupResource-recovery-test"
  );
  let destProfilePath = await IOUtils.createUniqueDirectory(
    PathUtils.tempDir,
    "PreferencesBackupResource-test-profile"
  );

  const simpleCopyFiles = [
    { path: "prefs.js" },
    { path: "xulstore.json" },
    { path: "permissions.sqlite" },
    { path: "content-prefs.sqlite" },
    { path: "containers.json" },
    { path: "handlers.json" },
    { path: "user.js" },
    { path: ["chrome", "userChrome.css"] },
    { path: ["chrome", "userContent.css"] },
    { path: ["chrome", "childFolder", "someOtherStylesheet.css"] },
  ];
  await createTestFiles(recoveryPath, simpleCopyFiles);

  // We'll now hand-prepare enough of a search.json.mozlz4 file that we can
  // ensure that PreferencesBackupResource knows how to update the
  // verification hashes for non-default engines.
  const TEST_SEARCH_ENGINE_LOAD_PATH = "some/path/on/disk";
  const TEST_SEARCH_ENGINE_LOAD_PATH_HASH = "some pre-existing hash";
  const TEST_DEFAULT_ENGINE_ID = "bugle";
  const TEST_DEFAULT_ENGINE_ID_HASH = "default engine original hash";
  const TEST_PRIVATE_DEFAULT_ENGINE_ID = "goose";
  const TEST_PRIVATE_DEFAULT_ENGINE_ID_HASH =
    "private default engine original hash";

  let fakeSearchPrefs = {
    metaData: {
      defaultEngineId: TEST_DEFAULT_ENGINE_ID,
      defaultEngineIdHash: TEST_DEFAULT_ENGINE_ID_HASH,
      privateDefaultEngineId: TEST_PRIVATE_DEFAULT_ENGINE_ID,
      privateDefaultEngineIdHash: TEST_PRIVATE_DEFAULT_ENGINE_ID_HASH,
    },
    engines: [
      {
        _loadPath: TEST_SEARCH_ENGINE_LOAD_PATH,
        _metaData: {
          loadPathHash: TEST_SEARCH_ENGINE_LOAD_PATH_HASH,
        },
      },
    ],
  };

  const SEARCH_PREFS_FILENAME = "search.json.mozlz4";
  await IOUtils.writeJSON(
    PathUtils.join(recoveryPath, SEARCH_PREFS_FILENAME),
    fakeSearchPrefs,
    {
      compress: true,
    }
  );

  const EXPECTED_HASH = "this is some newly generated hash";
  sandbox
    .stub(SearchUtils, "getVerificationHash")
    .onCall(0)
    .returns(TEST_SEARCH_ENGINE_LOAD_PATH_HASH)
    .onCall(1)
    .returns(EXPECTED_HASH)
    .onCall(2)
    .returns(TEST_DEFAULT_ENGINE_ID_HASH)
    .onCall(3)
    .returns(EXPECTED_HASH)
    .onCall(4)
    .returns(TEST_PRIVATE_DEFAULT_ENGINE_ID_HASH)
    .onCall(5)
    .returns(EXPECTED_HASH);

  const PRETEND_ORIGINAL_PATH = "some/original/path";

  // The backup method is expected to have returned a null ManifestEntry
  let postRecoveryEntry = await preferencesBackupResource.recover(
    { profilePath: PRETEND_ORIGINAL_PATH },
    recoveryPath,
    destProfilePath
  );
  Assert.equal(
    postRecoveryEntry,
    null,
    "PreferencesBackupResource.recover should return null as its post recovery entry"
  );

  await assertFilesExist(destProfilePath, simpleCopyFiles);
  await checkPrefsJsHasValidRecoveryTime(
    PathUtils.join(destProfilePath, "prefs.js")
  );

  // Now ensure that the verification was properly recomputed. We should
  // Have called getVerificationHash 6 times - twice each for:
  //
  // - The single engine in the engines list
  // - The defaultEngineId
  // - The privateDefaultEngineId
  //
  // The first call is to verify the hash against the original profile path,
  // and the second call is to generate the hash for the new profile path.
  Assert.equal(
    SearchUtils.getVerificationHash.callCount,
    6,
    "SearchUtils.getVerificationHash was called the right number of times."
  );
  Assert.ok(
    SearchUtils.getVerificationHash
      .getCall(0)
      .calledWith(TEST_SEARCH_ENGINE_LOAD_PATH, PRETEND_ORIGINAL_PATH),
    "SearchUtils.getVerificationHash first call called with the right arguments."
  );
  Assert.ok(
    SearchUtils.getVerificationHash
      .getCall(1)
      .calledWith(TEST_SEARCH_ENGINE_LOAD_PATH, destProfilePath),
    "SearchUtils.getVerificationHash second call called with the right arguments."
  );
  Assert.ok(
    SearchUtils.getVerificationHash
      .getCall(2)
      .calledWith(TEST_DEFAULT_ENGINE_ID, PRETEND_ORIGINAL_PATH),
    "SearchUtils.getVerificationHash third call called with the right arguments."
  );
  Assert.ok(
    SearchUtils.getVerificationHash
      .getCall(3)
      .calledWith(TEST_DEFAULT_ENGINE_ID, destProfilePath),
    "SearchUtils.getVerificationHash fourth call called with the right arguments."
  );
  Assert.ok(
    SearchUtils.getVerificationHash
      .getCall(4)
      .calledWith(TEST_PRIVATE_DEFAULT_ENGINE_ID, PRETEND_ORIGINAL_PATH),
    "SearchUtils.getVerificationHash fifth call called with the right arguments."
  );
  Assert.ok(
    SearchUtils.getVerificationHash
      .getCall(5)
      .calledWith(TEST_PRIVATE_DEFAULT_ENGINE_ID, destProfilePath),
    "SearchUtils.getVerificationHash sixth call called with the right arguments."
  );

  let recoveredSearchPrefs = await IOUtils.readJSON(
    PathUtils.join(destProfilePath, SEARCH_PREFS_FILENAME),
    { decompress: true }
  );
  Assert.equal(
    recoveredSearchPrefs.engines.length,
    1,
    "Should still have 1 search engine"
  );
  Assert.equal(
    recoveredSearchPrefs.engines[0]._metaData.loadPathHash,
    EXPECTED_HASH,
    "The expected hash was written for the single engine."
  );
  Assert.equal(
    recoveredSearchPrefs.metaData.defaultEngineIdHash,
    EXPECTED_HASH,
    "The expected hash was written for the default engine."
  );
  Assert.equal(
    recoveredSearchPrefs.metaData.privateDefaultEngineIdHash,
    EXPECTED_HASH,
    "The expected hash was written for the private default engine."
  );

  await maybeRemovePath(recoveryPath);
  await maybeRemovePath(destProfilePath);
  sandbox.restore();
});
