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

"use strict";

const { BasePromiseWorker } = ChromeUtils.importESModule(
  "resource://gre/modules/PromiseWorker.sys.mjs"
);
const { JsonSchema } = ChromeUtils.importESModule(
  "resource://gre/modules/JsonSchema.sys.mjs"
);
const { UIState } = ChromeUtils.importESModule(
  "resource://services-sync/UIState.sys.mjs"
);
const { ClientID } = ChromeUtils.importESModule(
  "resource://gre/modules/ClientID.sys.mjs"
);
const { ERRORS } = ChromeUtils.importESModule(
  "chrome://browser/content/backup/backup-constants.mjs"
);

const { TestUtils } = ChromeUtils.importESModule(
  "resource://testing-common/TestUtils.sys.mjs"
);

const LAST_BACKUP_TIMESTAMP_PREF_NAME =
  "browser.backup.scheduled.last-backup-timestamp";
const LAST_BACKUP_FILE_NAME_PREF_NAME =
  "browser.backup.scheduled.last-backup-file";
const BACKUP_ARCHIVE_ENABLED_PREF_NAME = "browser.backup.archive.enabled";

/** @type {nsIToolkitProfile} */
let currentProfile;

// Mock backup metadata
const DATE = "2024-06-25T21:59:11.777Z";
const IS_ENCRYPTED = true;
const DEVICE_NAME = "test-device";
const APP_NAME = "test-app-name";
const APP_VERSION = "test-app-version";
const BUILD_ID = "test-build-id";
const OS_NAME = "test-os-name";
const OS_VERSION = "test-os-version";
const TELEMETRY_ENABLED = true;
const LEGACY_CLIENT_ID = "legacy-client-id";
const PROFILE_NAME = "test-profile-name";

add_setup(function () {
  currentProfile = setupProfile();
});

/**
 * A utility function for testing BackupService.createBackup. This helper
 * function:
 *
 * 1. Produces a backup of fake resources
 * 2. Recovers the backup into a new profile directory
 * 3. Ensures that the resources had their backup/recovery methods called
 *
 * @param {object} sandbox
 *   The Sinon sandbox to be used stubs and mocks. The test using this helper
 *   is responsible for creating and resetting this sandbox.
 * @param {function(BackupService, BackupManifest): void} taskFn
 *   A function that is run once all default checks are done.
 *   After this function returns, all resources will be cleaned up.
 * @returns {Promise<undefined>}
 */
async function testCreateBackupHelper(sandbox, taskFn) {
  Services.telemetry.clearEvents();
  Services.fog.testResetFOG();

  // Handle for the metric for total byte size of staging folder
  let totalBackupSizeHistogram = TelemetryTestUtils.getAndClearHistogram(
    "BROWSER_BACKUP_TOTAL_BACKUP_SIZE"
  );
  // Handle for the metric for total byte size of single-file archive
  let compressedArchiveSizeHistogram = TelemetryTestUtils.getAndClearHistogram(
    "BROWSER_BACKUP_COMPRESSED_ARCHIVE_SIZE"
  );
  // Handle for the metric for total time taking by profile backup
  let backupTimerHistogram = TelemetryTestUtils.getAndClearHistogram(
    "BROWSER_BACKUP_TOTAL_BACKUP_TIME_MS"
  );

  const EXPECTED_CLIENT_ID = await ClientID.getClientID();
  const EXPECTED_PROFILE_GROUP_ID = await ClientID.getProfileGroupID();

  // Enable the scheduled backups pref so that backups can be deleted. We're
  // not calling initBackupScheduler on the BackupService that we're
  // constructing, so there's no danger of accidentally having a backup be
  // created during this test if there's an idle period.
  Services.prefs.setBoolPref("browser.backup.scheduled.enabled", true);
  registerCleanupFunction(() => {
    Services.prefs.clearUserPref("browser.backup.scheduled.enabled");
  });

  let fake1ManifestEntry = { fake1: "hello from 1" };
  sandbox
    .stub(FakeBackupResource1.prototype, "backup")
    .resolves(fake1ManifestEntry);
  sandbox.stub(FakeBackupResource1.prototype, "recover").resolves();

  sandbox
    .stub(FakeBackupResource2.prototype, "backup")
    .rejects(new Error("Some failure to backup"));
  sandbox.stub(FakeBackupResource2.prototype, "recover");

  let fake3ManifestEntry = { fake3: "hello from 3" };
  let fake3PostRecoveryEntry = { someData: "hello again from 3" };
  sandbox
    .stub(FakeBackupResource3.prototype, "backup")
    .resolves(fake3ManifestEntry);
  sandbox
    .stub(FakeBackupResource3.prototype, "recover")
    .resolves(fake3PostRecoveryEntry);

  let bs = BackupService.init({
    FakeBackupResource1,
    FakeBackupResource2,
    FakeBackupResource3,
  });

  let fakeProfilePath = await IOUtils.createUniqueDirectory(
    PathUtils.tempDir,
    "createBackupTest"
  );

  Assert.ok(!bs.state.lastBackupDate, "No backup date is stored in state.");
  let { manifest, archivePath: backupFilePath } = await bs.createBackup({
    profilePath: fakeProfilePath,
  });
  Assert.notStrictEqual(
    bs.state.lastBackupDate,
    null,
    "The backup date was recorded."
  );

  let legacyEvents = TelemetryTestUtils.getEvents(
    { category: "browser.backup", method: "created", object: "BackupService" },
    { process: "parent" }
  );
  Assert.equal(legacyEvents.length, 1, "Found the created legacy event.");
  let events = Glean.browserBackup.created.testGetValue();
  Assert.equal(events.length, 1, "Found the created Glean event.");

  // Validate total backup time metrics were recorded
  assertSingleTimeMeasurement(
    Glean.browserBackup.totalBackupTime.testGetValue()
  );
  assertHistogramMeasurementQuantity(
    backupTimerHistogram,
    1,
    "Should have collected a single measurement for total backup time"
  );

  Assert.ok(await IOUtils.exists(backupFilePath), "The backup file exists");

  let archiveDateSuffix = bs.generateArchiveDateSuffix(
    new Date(manifest.meta.date)
  );

  // We also expect the HTML file to have been written to the folder pointed
  // at by browser.backups.location, within backupDirPath folder.
  const EXPECTED_ARCHIVE_PATH = PathUtils.join(
    bs.state.backupDirPath,
    `${BackupService.BACKUP_FILE_NAME}_${manifest.meta.profileName}_${archiveDateSuffix}.html`
  );
  Assert.ok(
    await IOUtils.exists(EXPECTED_ARCHIVE_PATH),
    "Single-file backup archive was written."
  );
  Assert.equal(
    backupFilePath,
    EXPECTED_ARCHIVE_PATH,
    "Backup was written to the configured destination folder"
  );

  let snapshotsDirectoryPath = PathUtils.join(
    fakeProfilePath,
    BackupService.PROFILE_FOLDER_NAME,
    BackupService.SNAPSHOTS_FOLDER_NAME
  );
  let snapshotsDirectoryContentsPaths = await IOUtils.getChildren(
    snapshotsDirectoryPath
  );
  let snapshotsDirectoryContents = await Promise.all(
    snapshotsDirectoryContentsPaths.map(IOUtils.stat)
  );
  let snapshotsDirectorySubdirectories = snapshotsDirectoryContents.filter(
    file => file.type === "directory"
  );
  Assert.equal(
    snapshotsDirectorySubdirectories.length,
    0,
    "Snapshots directory should have had all staging folders cleaned up"
  );

  // 1 mebibyte minimum recorded value if total data size is under 1 mebibyte
  // This assumes that these BackupService tests do not create sizable fake files
  const SMALLEST_BACKUP_SIZE_BYTES = 1048576;
  const SMALLEST_BACKUP_SIZE_MEBIBYTES = 1;

  // Validate total (uncompressed profile data) size
  let totalBackupSize = Glean.browserBackup.totalBackupSize.testGetValue();
  Assert.equal(
    totalBackupSize.count,
    1,
    "Should have collected a single measurement for the total backup size"
  );
  Assert.equal(
    totalBackupSize.sum,
    SMALLEST_BACKUP_SIZE_BYTES,
    "Should have collected the right value for the total backup size"
  );
  TelemetryTestUtils.assertHistogram(
    totalBackupSizeHistogram,
    SMALLEST_BACKUP_SIZE_MEBIBYTES,
    1
  );

  // Validate final archive (compressed/encrypted profile data + HTML) size
  let compressedArchiveSize =
    Glean.browserBackup.compressedArchiveSize.testGetValue();
  Assert.equal(
    compressedArchiveSize.count,
    1,
    "Should have collected a single measurement for the backup compressed archive size"
  );
  Assert.equal(
    compressedArchiveSize.sum,
    SMALLEST_BACKUP_SIZE_BYTES,
    "Should have collected the right value for the backup compressed archive size"
  );
  TelemetryTestUtils.assertHistogram(
    compressedArchiveSizeHistogram,
    SMALLEST_BACKUP_SIZE_MEBIBYTES,
    1
  );

  // Check that resources were called from highest to lowest backup priority.
  sinon.assert.callOrder(
    FakeBackupResource3.prototype.backup,
    FakeBackupResource2.prototype.backup,
    FakeBackupResource1.prototype.backup
  );

  let schema = await BackupService.MANIFEST_SCHEMA;
  let validationResult = JsonSchema.validate(manifest, schema);
  Assert.ok(validationResult.valid, "Schema matches manifest");
  Assert.deepEqual(
    Object.keys(manifest.resources).sort(),
    ["fake1", "fake3"],
    "Manifest contains all expected BackupResource keys"
  );
  Assert.deepEqual(
    manifest.resources.fake1,
    fake1ManifestEntry,
    "Manifest contains the expected entry for FakeBackupResource1"
  );
  Assert.deepEqual(
    manifest.resources.fake3,
    fake3ManifestEntry,
    "Manifest contains the expected entry for FakeBackupResource3"
  );
  Assert.equal(
    manifest.meta.legacyClientID,
    EXPECTED_CLIENT_ID,
    "The client ID was stored properly."
  );
  Assert.equal(
    manifest.meta.profileGroupID,
    EXPECTED_PROFILE_GROUP_ID,
    "The profile group ID was stored properly."
  );
  Assert.equal(
    manifest.meta.profileName,
    currentProfile.name,
    "The profile name was stored properly"
  );

  let recoveredProfilePath = await IOUtils.createUniqueDirectory(
    PathUtils.tempDir,
    "createBackupTestRecoveredProfile"
  );

  let originalProfileName = currentProfile.name;

  let profileSvc = Cc["@mozilla.org/toolkit/profile-service;1"].getService(
    Ci.nsIToolkitProfileService
  );
  // make our current profile default
  profileSvc.defaultProfile = currentProfile;

  await bs.getBackupFileInfo(backupFilePath);
  const restoreID = bs.state.restoreID;

  // Intercept the telemetry that we want to check for before it gets submitted
  // and cleared out.
  let restoreStartedEvents;
  let restoreCompleteEvents;
  let restoreCompleteCallback = () => {
    Services.obs.removeObserver(
      restoreCompleteCallback,
      "browser-backup-restore-complete"
    );
    restoreStartedEvents = Glean.browserBackup.restoreStarted.testGetValue();
    restoreCompleteEvents = Glean.browserBackup.restoreComplete.testGetValue();
  };
  Services.obs.addObserver(
    restoreCompleteCallback,
    "browser-backup-restore-complete"
  );

  let recoveredProfile = await bs.recoverFromBackupArchive(
    backupFilePath,
    null,
    false,
    fakeProfilePath,
    recoveredProfilePath,
    true
  );

  Assert.ok(
    recoveredProfile.name.startsWith(originalProfileName),
    "Should maintain profile name across backup and restore"
  );

  Assert.ok(
    currentProfile.name.startsWith("old-"),
    "The old profile should be prefixed with old-"
  );

  Assert.strictEqual(
    profileSvc.defaultProfile,
    recoveredProfile,
    "The new profile should now be the default"
  );

  Assert.equal(
    restoreStartedEvents.length,
    1,
    "Should be a single restore start event after we start restoring a profile"
  );
  Assert.deepEqual(
    restoreStartedEvents[0].extra,
    {
      restore_id: restoreID,
      replace: "true",
    },
    "Restore start event should have the right data"
  );

  Assert.equal(
    restoreCompleteEvents.length,
    1,
    "Should be a single restore complete event after we start restoring a profile"
  );
  Assert.deepEqual(
    restoreCompleteEvents[0].extra,
    { restore_id: restoreID },
    "Restore complete event should have the right data"
  );

  // Check that resources were recovered from highest to lowest backup priority.
  sinon.assert.callOrder(
    FakeBackupResource3.prototype.recover,
    FakeBackupResource1.prototype.recover
  );

  let postRecoveryFilePath = PathUtils.join(
    recoveredProfilePath,
    BackupService.POST_RECOVERY_FILE_NAME
  );
  Assert.ok(
    await IOUtils.exists(postRecoveryFilePath),
    "Should have created post-recovery data file"
  );
  let postRecoveryData = await IOUtils.readJSON(postRecoveryFilePath);
  Assert.deepEqual(
    postRecoveryData.fake3,
    fake3PostRecoveryEntry,
    "Should have post-recovery data from fake backup 3"
  );

  await Assert.rejects(
    IOUtils.readJSON(
      PathUtils.join(recoveredProfilePath, "datareporting", "state.json")
    ),
    /file does not exist/,
    "The telemetry state was cleared."
  );

  await taskFn(bs, manifest);

  await maybeRemovePath(backupFilePath);
  await maybeRemovePath(fakeProfilePath);
  await maybeRemovePath(recoveredProfilePath);
  await maybeRemovePath(EXPECTED_ARCHIVE_PATH);

  Services.prefs.clearUserPref(LAST_BACKUP_FILE_NAME_PREF_NAME);

  BackupService.uninit();
}

/**
 * A utility function for testing BackupService.deleteLastBackup. This helper
 * function:
 *
 * 1. Clears any pre-existing cached preference values for the last backup
 *    date and file name.
 * 2. Uses testCreateBackupHelper to create a backup file.
 * 3. Ensures that the state has been updated to reflect the created backup,
 *    and that the backup date and file name are cached to preferences.
 * 4. Runs an optional async taskFn
 * 5. Calls deleteLastBackup on the testCreateBackupHelper BackupService
 *    instance.
 * 6. Checks that the BackupService state for the last backup date and file name
 *    have been cleared, and that the preferences caches of those values have
 *    also been cleared.
 *
 * @param {function(string): Promise<void>|null} taskFn
 *   An optional function that is run after we've created a backup, but just
 *   before calling deleteLastBackup(). It is passed the path to the created
 *   backup file.
 * @returns {Promise<undefined>}
 */
async function testDeleteLastBackupHelper(taskFn) {
  let sandbox = sinon.createSandbox();

  // Clear any last backup filenames and timestamps that might be lingering
  // from prior tests.
  Services.prefs.clearUserPref(LAST_BACKUP_TIMESTAMP_PREF_NAME);
  Services.prefs.clearUserPref(LAST_BACKUP_FILE_NAME_PREF_NAME);

  await testCreateBackupHelper(sandbox, async (bs, _manifest) => {
    Assert.notStrictEqual(
      bs.state.lastBackupDate,
      null,
      "Should have a last backup date recorded."
    );
    Assert.ok(
      bs.state.lastBackupFileName,
      "Should have a last backup file name recorded."
    );
    Assert.ok(
      Services.prefs.prefHasUserValue(LAST_BACKUP_TIMESTAMP_PREF_NAME),
      "Last backup date was cached in preferences."
    );
    Assert.ok(
      Services.prefs.prefHasUserValue(LAST_BACKUP_FILE_NAME_PREF_NAME),
      "Last backup file name was cached in preferences."
    );
    const LAST_BACKUP_FILE_PATH = PathUtils.join(
      bs.state.backupDirPath,
      bs.state.lastBackupFileName
    );
    Assert.ok(
      await IOUtils.exists(LAST_BACKUP_FILE_PATH),
      "The backup file was created and is still on the disk."
    );

    if (taskFn) {
      await taskFn(LAST_BACKUP_FILE_PATH);
    }

    // NB: On Windows, deletes of backups in tests run into an issue where
    // the file is locked briefly by the system and deletes fail with
    // NS_ERROR_FILE_IS_LOCKED.  See doFileRemovalOperation for details.
    // We therefore retry this delete a few times before accepting failure.
    await doFileRemovalOperation(async () => await bs.deleteLastBackup());

    Assert.equal(
      bs.state.lastBackupDate,
      null,
      "Should have cleared the last backup date"
    );
    Assert.equal(
      bs.state.lastBackupFileName,
      "",
      "Should have cleared the last backup file name"
    );
    Assert.ok(
      !Services.prefs.prefHasUserValue(LAST_BACKUP_TIMESTAMP_PREF_NAME),
      "Last backup date was cleared in preferences."
    );
    Assert.ok(
      !Services.prefs.prefHasUserValue(LAST_BACKUP_FILE_NAME_PREF_NAME),
      "Last backup file name was cleared in preferences."
    );
    Assert.ok(
      !(await IOUtils.exists(LAST_BACKUP_FILE_PATH)),
      "The backup file was deleted."
    );
  });

  sandbox.restore();
}

/**
 * Tests that calling BackupService.createBackup will call backup on each
 * registered BackupResource, and that each BackupResource will have a folder
 * created for them to write into. Tests in the signed-out state.
 */
add_task(async function test_createBackup_signed_out() {
  let sandbox = sinon.createSandbox();

  sandbox
    .stub(UIState, "get")
    .returns({ status: UIState.STATUS_NOT_CONFIGURED });
  await testCreateBackupHelper(sandbox, (_bs, manifest) => {
    Assert.equal(
      manifest.meta.accountID,
      undefined,
      "Account ID should be undefined."
    );
    Assert.equal(
      manifest.meta.accountEmail,
      undefined,
      "Account email should be undefined."
    );
  });

  sandbox.restore();
});

/**
 * Tests that calling BackupService.createBackup will call backup on each
 * registered BackupResource, and that each BackupResource will have a folder
 * created for them to write into. Tests in the signed-in state.
 */
add_task(async function test_createBackup_signed_in() {
  let sandbox = sinon.createSandbox();

  const TEST_UID = "ThisIsMyTestUID";
  const TEST_EMAIL = "foxy@mozilla.org";

  sandbox.stub(UIState, "get").returns({
    status: UIState.STATUS_SIGNED_IN,
    uid: TEST_UID,
    email: TEST_EMAIL,
  });

  await testCreateBackupHelper(sandbox, (_bs, manifest) => {
    Assert.equal(
      manifest.meta.accountID,
      TEST_UID,
      "Account ID should be set properly."
    );
    Assert.equal(
      manifest.meta.accountEmail,
      TEST_EMAIL,
      "Account email should be set properly."
    );
  });

  sandbox.restore();
});

/**
 * Makes a folder readonly.  Windows does not support read-only folders, so
 * this creates a file inside the folder and makes that read-only.
 *
 * @param {string}  folderpath Full path to folder to make read-only.
 * @param {boolean} isReadonly Whether to set or clear read-only status.
 */
async function makeFolderReadonly(folderpath, isReadonly) {
  if (AppConstants.platform !== "win") {
    await IOUtils.setPermissions(folderpath, isReadonly ? 0o444 : 0o666);
    let folder = await IOUtils.getFile(folderpath);
    Assert.equal(
      folder.isWritable(),
      !isReadonly,
      `folder is ${isReadonly ? "" : "not "}read-only`
    );
  } else if (isReadonly) {
    // Permissions flags like 0o444 are not usually respected on Windows but in
    // the case of creating a unique file, the read-only status is.  See
    // OpenFile in nsLocalFileWin.cpp.
    let tempFilename = await IOUtils.createUniqueFile(
      folderpath,
      "readonlyfile",
      0o444
    );
    let file = await IOUtils.getFile(tempFilename);
    Assert.equal(file.isWritable(), false, "file in folder is read-only");
  } else {
    // Recursively set any folder contents to be writeable.
    let attrs = await IOUtils.getWindowsAttributes(folderpath);
    attrs.readonly = false;
    await IOUtils.setWindowsAttributes(folderpath, attrs, true /* recursive */);
  }
}

/**
 * Tests that read-only files in BackupService.createBackup cause backup
 * failure (createBackup returns null) and does not bubble up any errors.
 */
add_task(
  {
    // We override read-only on Windows -- see
    // test_createBackup_override_readonly below.
    skip_if: () => AppConstants.platform == "win",
  },
  async function test_createBackup_robustToFileSystemErrors() {
    let sandbox = sinon.createSandbox();
    Services.fog.testResetFOG();
    // Handle for the metric for total time taking by profile backup
    let backupTimerHistogram = TelemetryTestUtils.getAndClearHistogram(
      "BROWSER_BACKUP_TOTAL_BACKUP_TIME_MS"
    );

    const TEST_UID = "ThisIsMyTestUID";
    const TEST_EMAIL = "foxy@mozilla.org";

    sandbox.stub(UIState, "get").returns({
      status: UIState.STATUS_SIGNED_IN,
      uid: TEST_UID,
      email: TEST_EMAIL,
    });

    // Create a read-only fake profile folder to which the backup service
    // won't be able to make writes
    let inaccessibleProfilePath = await IOUtils.createUniqueDirectory(
      PathUtils.tempDir,
      "createBackupErrorReadonly"
    );
    await makeFolderReadonly(inaccessibleProfilePath, true);

    const bs = new BackupService({});

    await bs
      .createBackup({ profilePath: inaccessibleProfilePath })
      .then(result => {
        Assert.equal(result, null, "Should return null on error");

        // Validate total backup time metrics were recorded
        const totalBackupTime =
          Glean.browserBackup.totalBackupTime.testGetValue();
        Assert.equal(
          totalBackupTime,
          null,
          "Should not have measured total backup time for failed backup"
        );
        assertHistogramMeasurementQuantity(backupTimerHistogram, 0);
      })
      .catch(() => {
        // Failure bubbles up an error for handling by the caller
      })
      .finally(async () => {
        await makeFolderReadonly(inaccessibleProfilePath, false);
        await IOUtils.remove(inaccessibleProfilePath, { recursive: true });
        sandbox.restore();
      });
  }
);

/**
 * Tests that BackupService.createBackup can override simple read-only status
 * when handling staging files.  That currently only works on Windows.
 */
add_task(
  {
    skip_if: () => AppConstants.platform !== "win",
  },
  async function test_createBackup_override_readonly() {
    let sandbox = sinon.createSandbox();

    const TEST_UID = "ThisIsMyTestUID";
    const TEST_EMAIL = "foxy@mozilla.org";

    sandbox.stub(UIState, "get").returns({
      status: UIState.STATUS_SIGNED_IN,
      uid: TEST_UID,
      email: TEST_EMAIL,
    });

    // Create a fake profile folder that contains a read-only file.  We do this
    // because Windows does not respect read-only status on folders. The
    // file's read-only status will make the folder un(re)movable.
    let inaccessibleProfilePath = await IOUtils.createUniqueDirectory(
      PathUtils.tempDir,
      "createBackupErrorReadonly"
    );
    await makeFolderReadonly(inaccessibleProfilePath, true);
    await Assert.rejects(
      IOUtils.remove(inaccessibleProfilePath),
      /Could not remove/,
      "folder is not removable"
    );

    const bs = new BackupService({});

    await bs
      .createBackup({ profilePath: inaccessibleProfilePath })
      .then(result => {
        Assert.notEqual(result, null, "Should not return null on success");
      })
      .catch(e => {
        console.error(e);
        Assert.ok(false, "Should not have bubbled up an error");
      })
      .finally(async () => {
        await makeFolderReadonly(inaccessibleProfilePath, false);
        await IOUtils.remove(inaccessibleProfilePath, { recursive: true });
        await bs.deleteLastBackup();
        sandbox.restore();
      });
  }
);

/**
 * Creates a unique file in the given folder and tells a worker to keep it
 * open until we post a close message.  Checks that the folder is not
 * removable as a result.
 *
 * @param {string} folderpath
 * @returns {object} {{ path: string, worker: OpenFileWorker }}
 */
async function openUniqueFileInFolder(folderpath) {
  let testFile = await IOUtils.createUniqueFile(folderpath, "openfile");
  await IOUtils.writeUTF8(testFile, "");
  Assert.ok(
    await IOUtils.exists(testFile),
    testFile + " should have been created"
  );
  // Use a worker to keep the testFile open.
  const worker = new BasePromiseWorker(
    "resource://test/data/test_keep_file_open.worker.js"
  );
  await worker.post("open", [testFile]);

  await Assert.rejects(
    IOUtils.remove(folderpath),
    /NS_ERROR_FILE_DIR_NOT_EMPTY/,
    "attempt to remove folder threw an exception"
  );
  Assert.ok(await IOUtils.exists(folderpath), "folder is not removable");
  return { path: testFile, worker };
}

/**
 * Stop the worker returned from openUniqueFileInFolder and close the file.
 *
 * @param {object} worker The worker returned by openUniqueFileInFolder
 */
async function closeTestFile(worker) {
  await worker.post("close", []);
}

/**
 * Run a backup and check that it either succeeded with a response or failed
 * and returned null.
 *
 * @param {object}  backupService Instance of BackupService
 * @param {string}  profilePath   Full path to profile folder
 * @param {boolean} shouldSucceed Whether to expect success or failure
 */
async function checkBackup(backupService, profilePath, shouldSucceed) {
  if (shouldSucceed) {
    await backupService.createBackup({ profilePath }).then(result => {
      Assert.ok(true, "createBackup did not throw an exception");
      Assert.notEqual(
        result,
        null,
        `createBackup should not have returned null`
      );
    });
    await backupService.deleteLastBackup();
    return;
  }

  await Assert.rejects(
    backupService.createBackup({ profilePath }),
    /Failed to remove/,
    "createBackup threw correct exception"
  );
}

/**
 * Checks that browser.backup.max-num-unremovable-staging-items allows backups
 * to succeed if the snapshots folder contains no more than that many
 * unremovable items, and that it fails if there are more than that.
 *
 * @param {number} unremovableItemsLimit Max number of unremovable items that
 *                                       backups can succeed with.
 */
async function checkBackupWithUnremovableItems(unremovableItemsLimit) {
  Services.prefs.setIntPref(
    "browser.backup.max-num-unremovable-staging-items",
    unremovableItemsLimit
  );
  registerCleanupFunction(() =>
    Services.prefs.clearUserPref(
      "browser.backup.max-num-unremovable-staging-items"
    )
  );

  let sandbox = sinon.createSandbox();

  const TEST_UID = "ThisIsMyTestUID";
  const TEST_EMAIL = "foxy@mozilla.org";

  sandbox.stub(UIState, "get").returns({
    status: UIState.STATUS_SIGNED_IN,
    uid: TEST_UID,
    email: TEST_EMAIL,
  });
  const backupService = new BackupService({});

  let profilePath = await IOUtils.createUniqueDirectory(
    PathUtils.tempDir,
    "profileDir"
  );
  let snapshotsFolder = PathUtils.join(
    profilePath,
    BackupService.PROFILE_FOLDER_NAME,
    BackupService.SNAPSHOTS_FOLDER_NAME
  );

  let openFileWorkers = [];
  try {
    for (let i = 0; i < unremovableItemsLimit + 1; i++) {
      info(`Performing backup #${i}`);
      await checkBackup(backupService, profilePath, true /* shouldSucceed */);

      // Create and open a file so that the snapshots folder cannot be
      // emptied.
      openFileWorkers.push(await openUniqueFileInFolder(snapshotsFolder));
    }

    // We are now over the unremovableItemsLimit.
    info(`Performing backup that should fail`);
    await checkBackup(backupService, profilePath, false /* shouldSucceed */);
  } finally {
    await Promise.all(
      openFileWorkers.map(async ofw => await closeTestFile(ofw.worker))
    );
    await Promise.all(
      openFileWorkers.map(async ofw => await IOUtils.remove(ofw.path))
    );
    await IOUtils.remove(profilePath, { recursive: true });
    sandbox.restore();
  }
}

/**
 * Tests that any non-read-only file deletion errors do not prevent backups
 * until the browser.backup.max-num-unremovable-staging-items limit has been
 * reached.
 */
add_task(
  async function test_createBackup_robustToNonReadonlyFileSystemErrorsAllowOneNonReadonly() {
    await checkBackupWithUnremovableItems(1);
  }
);

/**
 * Tests that browser.backup.max-num-unremovable-staging-items works for value
 * 0.
 */
add_task(
  async function test_createBackup_robustToNonReadonlyFileSystemErrors() {
    await checkBackupWithUnremovableItems(0);
  }
);

/**
 * Tests that failure to delete the prior backup doesn't prevent the backup
 * location from being edited.
 */
add_task(
  async function test_editBackupLocation_robustToDeleteLastBackupException() {
    const backupLocationPref = "browser.backup.location";
    const resetLocation = Services.prefs.getStringPref(backupLocationPref);

    const exceptionBackupLocation = await IOUtils.createUniqueDirectory(
      PathUtils.tempDir,
      "exceptionBackupLocation"
    );
    Services.prefs.setStringPref(backupLocationPref, exceptionBackupLocation);

    const newBackupLocation = await IOUtils.createUniqueDirectory(
      PathUtils.tempDir,
      "newBackupLocation"
    );

    const backupService = new BackupService({});

    const sandbox = sinon.createSandbox();
    sandbox
      .stub(backupService, "deleteLastBackup")
      .rejects(new Error("Exception while deleting backup"));

    await backupService.editBackupLocation(newBackupLocation);

    let expectedPath = PathUtils.join(newBackupLocation, "Restore Firefox");
    Assert.equal(
      Services.prefs.getStringPref(backupLocationPref),
      expectedPath,
      "Backup location pref should have updated to the new directory."
    );

    Services.prefs.setStringPref(backupLocationPref, resetLocation);
    sinon.restore();
    await Promise.all([
      IOUtils.remove(exceptionBackupLocation, { recursive: true }),
      IOUtils.remove(newBackupLocation, { recursive: true }),
    ]);
  }
);

/**
 * Tests that if there's a post-recovery.json file in the profile directory
 * when checkForPostRecovery() is called, that it is processed, and the
 * postRecovery methods on the associated BackupResources are called with the
 * entry values from the file.
 */
add_task(async function test_checkForPostRecovery() {
  let sandbox = sinon.createSandbox();

  let testProfilePath = await IOUtils.createUniqueDirectory(
    PathUtils.tempDir,
    "checkForPostRecoveryTest"
  );
  let fakePostRecoveryObject = {
    [FakeBackupResource1.key]: "test 1",
    [FakeBackupResource3.key]: "test 3",
  };
  await IOUtils.writeJSON(
    PathUtils.join(testProfilePath, BackupService.POST_RECOVERY_FILE_NAME),
    fakePostRecoveryObject
  );

  sandbox.stub(FakeBackupResource1.prototype, "postRecovery").resolves();
  sandbox.stub(FakeBackupResource2.prototype, "postRecovery").resolves();
  sandbox.stub(FakeBackupResource3.prototype, "postRecovery").resolves();

  let bs = new BackupService({
    FakeBackupResource1,
    FakeBackupResource2,
    FakeBackupResource3,
  });

  await bs.checkForPostRecovery(testProfilePath);
  await bs.postRecoveryComplete;

  Assert.ok(
    FakeBackupResource1.prototype.postRecovery.calledOnce,
    "FakeBackupResource1.postRecovery was called once"
  );
  Assert.ok(
    FakeBackupResource2.prototype.postRecovery.notCalled,
    "FakeBackupResource2.postRecovery was not called"
  );
  Assert.ok(
    FakeBackupResource3.prototype.postRecovery.calledOnce,
    "FakeBackupResource3.postRecovery was called once"
  );
  Assert.ok(
    FakeBackupResource1.prototype.postRecovery.calledWith(
      fakePostRecoveryObject[FakeBackupResource1.key]
    ),
    "FakeBackupResource1.postRecovery was called with the expected argument"
  );
  Assert.ok(
    FakeBackupResource3.prototype.postRecovery.calledWith(
      fakePostRecoveryObject[FakeBackupResource3.key]
    ),
    "FakeBackupResource3.postRecovery was called with the expected argument"
  );

  await IOUtils.remove(testProfilePath, { recursive: true });
  sandbox.restore();
});

/**
 * Tests that if one resource's postRecovery rejects, the remaining resources
 * still get their postRecovery called and the post-recovery.json file is
 * cleaned up.
 */
add_task(
  async function test_checkForPostRecovery_resilient_to_resource_failure() {
    let sandbox = sinon.createSandbox();

    let testProfilePath = await IOUtils.createUniqueDirectory(
      PathUtils.tempDir,
      "checkForPostRecoveryResilienceTest"
    );
    let fakePostRecoveryObject = {
      [FakeBackupResource1.key]: "test 1",
      [FakeBackupResource3.key]: "test 3",
    };
    await IOUtils.writeJSON(
      PathUtils.join(testProfilePath, BackupService.POST_RECOVERY_FILE_NAME),
      fakePostRecoveryObject
    );

    sandbox
      .stub(FakeBackupResource1.prototype, "postRecovery")
      .rejects(new Error("Simulated postRecovery failure"));
    sandbox.stub(FakeBackupResource2.prototype, "postRecovery").resolves();
    sandbox.stub(FakeBackupResource3.prototype, "postRecovery").resolves();

    let bs = new BackupService({
      FakeBackupResource1,
      FakeBackupResource2,
      FakeBackupResource3,
    });

    await bs.checkForPostRecovery(testProfilePath);
    await bs.postRecoveryComplete;

    Assert.ok(
      FakeBackupResource1.prototype.postRecovery.calledOnce,
      "FakeBackupResource1.postRecovery was called once (and rejected)"
    );
    Assert.ok(
      FakeBackupResource2.prototype.postRecovery.notCalled,
      "FakeBackupResource2.postRecovery was not called (no entry in post-recovery)"
    );
    Assert.ok(
      FakeBackupResource3.prototype.postRecovery.calledOnce,
      "FakeBackupResource3.postRecovery was still called despite FakeBackupResource1 failure"
    );

    let postRecoveryFilePath = PathUtils.join(
      testProfilePath,
      BackupService.POST_RECOVERY_FILE_NAME
    );
    Assert.ok(
      !(await IOUtils.exists(postRecoveryFilePath)),
      "post-recovery.json should be cleaned up even after a resource failure"
    );

    await IOUtils.remove(testProfilePath, { recursive: true });
    sandbox.restore();
  }
);

/**
 * Tests that getBackupFileInfo updates backupFileInfo in the state with a subset
 * of info from the fake SampleArchiveResult returned by sampleArchive().
 */
add_task(async function test_getBackupFileInfo() {
  let sandbox = sinon.createSandbox();

  let fakeSampleArchiveResult = {
    isEncrypted: IS_ENCRYPTED,
    startByteOffset: 26985,
    contentType: "multipart/mixed",
    archiveJSON: {
      version: 1,
      meta: {
        date: DATE,
        deviceName: DEVICE_NAME,
        appName: APP_NAME,
        appVersion: APP_VERSION,
        buildID: BUILD_ID,
        osName: OS_NAME,
        osVersion: OS_VERSION,
        healthTelemetryEnabled: TELEMETRY_ENABLED,
        legacyClientID: LEGACY_CLIENT_ID,
        profileName: PROFILE_NAME,
      },
      encConfig: {},
    },
  };

  sandbox
    .stub(BackupService.prototype, "sampleArchive")
    .resolves(fakeSampleArchiveResult);

  let bs = new BackupService();

  await bs.getBackupFileInfo("fake-archive.html");

  Assert.ok(
    BackupService.prototype.sampleArchive.calledOnce,
    "sampleArchive was called once"
  );

  Assert.deepEqual(
    bs.state.backupFileInfo,
    {
      isEncrypted: IS_ENCRYPTED,
      date: DATE,
      deviceName: DEVICE_NAME,
      appName: APP_NAME,
      appVersion: APP_VERSION,
      buildID: BUILD_ID,
      osName: OS_NAME,
      osVersion: OS_VERSION,
      healthTelemetryEnabled: TELEMETRY_ENABLED,
      legacyClientID: LEGACY_CLIENT_ID,
      profileName: PROFILE_NAME,
    },
    "State should match a subset from the archive sample."
  );

  sandbox.restore();
});

/**
 * Tests that deleting the last backup will delete the last known backup file if
 * it exists, and will clear the last backup timestamp and filename state
 * properties and preferences.
 */
add_task(async function test_deleteLastBackup_file_exists() {
  await testDeleteLastBackupHelper();
});

/**
 * Tests that deleting the last backup does not reject if the last backup file
 * does not exist, and will still clear the last backup timestamp and filename
 * state properties and preferences.
 */
add_task(async function test__deleteLastBackup_file_does_not_exist() {
  // Now delete the file ourselves before we call deleteLastBackup,
  // so that it's missing from the disk.
  await testDeleteLastBackupHelper(async lastBackupFilePath => {
    await maybeRemovePath(lastBackupFilePath);
  });
});

/**
 * Tests that getBackupFileInfo properly handles errors, and clears file info
 * for errors that indicate that the file is invalid.
 */
add_task(async function test_getBackupFileInfo_error_handling() {
  let sandbox = sinon.createSandbox();

  const errorTypes = [
    ERRORS.FILE_SYSTEM_ERROR,
    ERRORS.CORRUPTED_ARCHIVE,
    ERRORS.UNSUPPORTED_BACKUP_VERSION,
    ERRORS.INTERNAL_ERROR,
    ERRORS.UNINITIALIZED,
    ERRORS.INVALID_PASSWORD,
  ];

  for (const testError of errorTypes) {
    let bs = new BackupService();

    let fakeSampleArchiveResult = {
      isEncrypted: IS_ENCRYPTED,
      startByteOffset: 26985,
      contentType: "multipart/mixed",
      archiveJSON: {
        version: 1,
        meta: {
          date: DATE,
          deviceName: DEVICE_NAME,
          appName: APP_NAME,
          appVersion: APP_VERSION,
          buildID: BUILD_ID,
          osName: OS_NAME,
          osVersion: OS_VERSION,
          healthTelemetryEnabled: TELEMETRY_ENABLED,
          legacyClientID: LEGACY_CLIENT_ID,
          profileName: PROFILE_NAME,
        },
        encConfig: {},
      },
    };

    sandbox
      .stub(BackupService.prototype, "sampleArchive")
      .resolves(fakeSampleArchiveResult);
    await bs.getBackupFileInfo("test-backup.html");

    // Verify initial state was set
    Assert.deepEqual(
      bs.state.backupFileInfo,
      {
        isEncrypted: IS_ENCRYPTED,
        date: DATE,
        deviceName: DEVICE_NAME,
        appName: APP_NAME,
        appVersion: APP_VERSION,
        buildID: BUILD_ID,
        osName: OS_NAME,
        osVersion: OS_VERSION,
        healthTelemetryEnabled: TELEMETRY_ENABLED,
        legacyClientID: LEGACY_CLIENT_ID,
        profileName: PROFILE_NAME,
      },
      "Initial state should be set correctly"
    );
    Assert.strictEqual(
      bs.state.backupFileToRestore,
      "test-backup.html",
      "Initial backupFileToRestore should be set correctly"
    );

    // Test when sampleArchive throws an error
    sandbox.restore();
    sandbox
      .stub(BackupService.prototype, "sampleArchive")
      .rejects(new Error("Test error", { cause: testError }));
    const setRecoveryErrorStub = sandbox.stub(bs, "setRecoveryError");

    try {
      await bs.getBackupFileInfo("test-backup.html");
    } catch (error) {
      Assert.ok(
        false,
        `Expected getBackupFileInfo to throw for error ${testError}`
      );
    }

    Assert.ok(
      setRecoveryErrorStub.calledOnceWith(testError),
      `setRecoveryError should be called with ${testError}`
    );

    Assert.strictEqual(
      bs.state.backupFileInfo,
      null,
      `backupFileInfo should be cleared for error ${testError}`
    );
    Assert.strictEqual(
      bs.state.backupFileToRestore,
      "test-backup.html",
      `backupFileToRestore should be kept the same`
    );

    sandbox.restore();
  }
});

/**
 * Tests changing the status prefs to ensure that backup is cleaned up if being disabled.
 */
add_task(async function test_changing_prefs_cleanup() {
  let sandbox = sinon.createSandbox();
  Services.prefs.setBoolPref(BACKUP_ARCHIVE_ENABLED_PREF_NAME, true);
  let bs = new BackupService();
  bs.initStatusObservers();
  let cleanupStub = sandbox.stub(bs, "cleanupBackupFiles");
  let statusUpdatePromise = TestUtils.topicObserved(
    "backup-service-status-updated"
  );
  Services.prefs.setBoolPref(BACKUP_ARCHIVE_ENABLED_PREF_NAME, false);
  await statusUpdatePromise;

  Assert.equal(
    cleanupStub.callCount,
    1,
    "Cleanup backup files was called on pref change"
  );

  Services.prefs.setBoolPref(BACKUP_ARCHIVE_ENABLED_PREF_NAME, true);

  Assert.equal(
    cleanupStub.callCount,
    1,
    "Cleanup backup files should not have been called when enabling backups"
  );

  Services.prefs.clearUserPref(BACKUP_ARCHIVE_ENABLED_PREF_NAME);
});
