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

"use strict";

// Tests for the FxA storage manager.

const { FxAccountsStorageManager } = ChromeUtils.importESModule(
  "resource://gre/modules/FxAccountsStorage.sys.mjs"
);
const { DATA_FORMAT_VERSION, log } = ChromeUtils.importESModule(
  "resource://gre/modules/FxAccountsCommon.sys.mjs"
);

initTestLogging("Trace");
log.level = Log.Level.Trace;

const DEVICE_REGISTRATION_VERSION = 42;

// A couple of mocks we can use.
function MockedPlainStorage(accountData) {
  let data = null;
  if (accountData) {
    data = {
      version: DATA_FORMAT_VERSION,
      accountData,
    };
  }
  this.data = data;
  this.numReads = 0;
}
MockedPlainStorage.prototype = {
  async get() {
    this.numReads++;
    Assert.equal(this.numReads, 1, "should only ever be 1 read of acct data");
    return this.data;
  },

  async set(data) {
    this.data = data;
  },
};

function MockedSecureStorage(accountData) {
  let data = null;
  if (accountData) {
    data = {
      version: DATA_FORMAT_VERSION,
      accountData,
    };
  }
  this.data = data;
  this.numReads = 0;
}

MockedSecureStorage.prototype = {
  fetchCount: 0,
  locked: false,
  /* eslint-disable object-shorthand */
  // This constructor must be declared without
  // object shorthand or we get an exception of
  // "TypeError: this.STORAGE_LOCKED is not a constructor"
  STORAGE_LOCKED: function () {},
  /* eslint-enable object-shorthand */
  async get() {
    this.fetchCount++;
    if (this.locked) {
      throw new this.STORAGE_LOCKED();
    }
    this.numReads++;
    Assert.equal(
      this.numReads,
      1,
      "should only ever be 1 read of unlocked data"
    );
    return this.data;
  },

  async set(uid, contents) {
    this.data = contents;
  },
};

function add_storage_task(testFunction) {
  add_task(async function () {
    print("Starting test with secure storage manager");
    await testFunction(new FxAccountsStorageManager());
  });
  add_task(async function () {
    print("Starting test with simple storage manager");
    await testFunction(new FxAccountsStorageManager({ useSecure: false }));
  });
}

// initialized without account data and there's nothing to read. Not logged in.
add_storage_task(async function checkInitializedEmpty(sm) {
  if (sm.secureStorage) {
    sm.secureStorage = new MockedSecureStorage(null);
  }
  await sm.initialize();
  Assert.strictEqual(await sm.getAccountData(), null);
  await Assert.rejects(
    sm.updateAccountData({ scopedKeys: { ...MOCK_ACCOUNT_KEYS.scopedKeys } }),
    /No user is logged in/
  );
});

// Initialized with account data (ie, simulating a new user being logged in).
// Should reflect the initial data and be written to storage.
add_storage_task(async function checkNewUser(sm) {
  let initialAccountData = {
    uid: "uid",
    email: "someone@somewhere.com",
    scopedKeys: { ...MOCK_ACCOUNT_KEYS.scopedKeys },
    device: {
      id: "device id",
    },
  };
  sm.plainStorage = new MockedPlainStorage();
  if (sm.secureStorage) {
    sm.secureStorage = new MockedSecureStorage(null);
  }
  await sm.initialize(initialAccountData);
  let accountData = await sm.getAccountData();
  Assert.equal(accountData.uid, initialAccountData.uid);
  Assert.equal(accountData.email, initialAccountData.email);
  Assert.deepEqual(accountData.scopedKeys, initialAccountData.scopedKeys);
  Assert.deepEqual(accountData.device, initialAccountData.device);

  // and it should have been written to storage.
  Assert.equal(sm.plainStorage.data.accountData.uid, initialAccountData.uid);
  Assert.equal(
    sm.plainStorage.data.accountData.email,
    initialAccountData.email
  );
  Assert.deepEqual(
    sm.plainStorage.data.accountData.device,
    initialAccountData.device
  );
  // check secure
  if (sm.secureStorage) {
    Assert.deepEqual(
      sm.secureStorage.data.accountData.scopedKeys,
      initialAccountData.scopedKeys
    );
  } else {
    Assert.deepEqual(
      sm.plainStorage.data.accountData.scopedKeys,
      initialAccountData.scopedKeys
    );
  }
});

// Initialized without account data but storage has it available.
add_storage_task(async function checkEverythingRead(sm) {
  sm.plainStorage = new MockedPlainStorage({
    uid: "uid",
    email: "someone@somewhere.com",
    device: {
      id: "wibble",
      registrationVersion: null,
    },
  });
  if (sm.secureStorage) {
    sm.secureStorage = new MockedSecureStorage(null);
  }
  await sm.initialize();
  let accountData = await sm.getAccountData();
  Assert.ok(accountData, "read account data");
  Assert.equal(accountData.uid, "uid");
  Assert.equal(accountData.email, "someone@somewhere.com");
  Assert.deepEqual(accountData.device, {
    id: "wibble",
    registrationVersion: null,
  });
  // Update the data - we should be able to fetch it back and it should appear
  // in our storage.
  await sm.updateAccountData({
    verified: true,
    scopedKeys: { ...MOCK_ACCOUNT_KEYS.scopedKeys },
    device: {
      id: "wibble",
      registrationVersion: DEVICE_REGISTRATION_VERSION,
    },
  });
  accountData = await sm.getAccountData();
  Assert.deepEqual(accountData.scopedKeys, MOCK_ACCOUNT_KEYS.scopedKeys);
  Assert.deepEqual(accountData.device, {
    id: "wibble",
    registrationVersion: DEVICE_REGISTRATION_VERSION,
  });
  // Check the new value was written to storage.
  await sm._promiseStorageComplete; // storage is written in the background.
  Assert.equal(sm.plainStorage.data.accountData.verified, true);
  Assert.deepEqual(sm.plainStorage.data.accountData.device, {
    id: "wibble",
    registrationVersion: DEVICE_REGISTRATION_VERSION,
  });
  // derive keys are secure
  if (sm.secureStorage) {
    Assert.deepEqual(
      sm.secureStorage.data.accountData.scopedKeys,
      MOCK_ACCOUNT_KEYS.scopedKeys
    );
  } else {
    Assert.deepEqual(
      sm.plainStorage.data.accountData.scopedKeys,
      MOCK_ACCOUNT_KEYS.scopedKeys
    );
  }
});

add_storage_task(async function checkInvalidUpdates(sm) {
  sm.plainStorage = new MockedPlainStorage({
    uid: "uid",
    email: "someone@somewhere.com",
  });
  if (sm.secureStorage) {
    sm.secureStorage = new MockedSecureStorage(null);
  }
  await sm.initialize();

  await Assert.rejects(
    sm.updateAccountData({ uid: "another" }),
    /Can't change uid/
  );
});

add_storage_task(async function checkNullUpdatesRemovedUnlocked(sm) {
  if (sm.secureStorage) {
    sm.plainStorage = new MockedPlainStorage({
      uid: "uid",
      email: "someone@somewhere.com",
    });
    sm.secureStorage = new MockedSecureStorage({
      scopedKeys: { ...MOCK_ACCOUNT_KEYS.scopedKeys },
      unwrapBKey: "unwrapBKey",
    });
  } else {
    sm.plainStorage = new MockedPlainStorage({
      uid: "uid",
      email: "someone@somewhere.com",
      scopedKeys: { ...MOCK_ACCOUNT_KEYS.scopedKeys },
      unwrapBKey: "unwrapBKey",
    });
  }
  await sm.initialize();

  await sm.updateAccountData({ unwrapBKey: null });
  let accountData = await sm.getAccountData();
  Assert.ok(!accountData.unwrapBKey);
  Assert.deepEqual(accountData.scopedKeys, MOCK_ACCOUNT_KEYS.scopedKeys);
});

add_storage_task(async function checkNullRemovesUnlistedFields(sm) {
  // kA and kB are not listed in FXA_PWDMGR_*_FIELDS, but we still want to
  // be able to delete them (migration case).
  if (sm.secureStorage) {
    sm.plainStorage = new MockedPlainStorage({
      uid: "uid",
      email: "someone@somewhere.com",
    });
    sm.secureStorage = new MockedSecureStorage({ kA: "kA", kb: "kB" });
  } else {
    sm.plainStorage = new MockedPlainStorage({
      uid: "uid",
      email: "someone@somewhere.com",
      kA: "kA",
      kb: "kB",
    });
  }
  await sm.initialize();

  await sm.updateAccountData({ kA: null, kB: null });
  let accountData = await sm.getAccountData();
  Assert.ok(!accountData.kA);
  Assert.ok(!accountData.kB);
});

add_storage_task(async function checkDelete(sm) {
  if (sm.secureStorage) {
    sm.plainStorage = new MockedPlainStorage({
      uid: "uid",
      email: "someone@somewhere.com",
    });
    sm.secureStorage = new MockedSecureStorage({
      scopedKeys: { ...MOCK_ACCOUNT_KEYS.scopedKeys },
    });
  } else {
    sm.plainStorage = new MockedPlainStorage({
      uid: "uid",
      email: "someone@somewhere.com",
      scopedKeys: { ...MOCK_ACCOUNT_KEYS.scopedKeys },
    });
  }
  await sm.initialize();

  await sm.deleteAccountData();
  // Storage should have been reset to null.
  Assert.equal(sm.plainStorage.data, null);
  if (sm.secureStorage) {
    Assert.equal(sm.secureStorage.data, null);
  }
  // And everything should reflect no user.
  Assert.equal(await sm.getAccountData(), null);
});

// Some tests only for the secure storage manager.
add_task(async function checkNullUpdatesRemovedLocked() {
  let sm = new FxAccountsStorageManager();
  sm.plainStorage = new MockedPlainStorage({
    uid: "uid",
    email: "someone@somewhere.com",
  });
  sm.secureStorage = new MockedSecureStorage({
    scopedKeys: { ...MOCK_ACCOUNT_KEYS.scopedKeys },
    unwrapBKey: "unwrapBKey is another secure value",
  });
  sm.secureStorage.locked = true;
  await sm.initialize();

  await sm.updateAccountData({ scopedKeys: null });
  let accountData = await sm.getAccountData();
  // No scopedKeys because it was removed.
  Assert.ok(!accountData.scopedKeys);
  // No unwrapBKey because we are locked
  Assert.ok(!accountData.unwrapBKey);

  // now unlock - should still be no scopedKeys but unwrapBKey should appear.
  sm.secureStorage.locked = false;
  accountData = await sm.getAccountData();
  Assert.ok(!accountData.scopedKeys);
  Assert.equal(accountData.unwrapBKey, "unwrapBKey is another secure value");
  // And secure storage should have been written with our previously-cached
  // data.
  Assert.strictEqual(sm.secureStorage.data.accountData.scopedKeys, undefined);
  Assert.strictEqual(
    sm.secureStorage.data.accountData.unwrapBKey,
    "unwrapBKey is another secure value"
  );
});

add_task(async function checkEverythingReadSecure() {
  let sm = new FxAccountsStorageManager();
  sm.plainStorage = new MockedPlainStorage({
    uid: "uid",
    email: "someone@somewhere.com",
  });
  sm.secureStorage = new MockedSecureStorage({
    scopedKeys: { ...MOCK_ACCOUNT_KEYS.scopedKeys },
  });
  await sm.initialize();

  let accountData = await sm.getAccountData();
  Assert.ok(accountData, "read account data");
  Assert.equal(accountData.uid, "uid");
  Assert.equal(accountData.email, "someone@somewhere.com");
  Assert.deepEqual(accountData.scopedKeys, MOCK_ACCOUNT_KEYS.scopedKeys);
});

add_task(async function checkExplicitGet() {
  let sm = new FxAccountsStorageManager();
  sm.plainStorage = new MockedPlainStorage({
    uid: "uid",
    email: "someone@somewhere.com",
  });
  sm.secureStorage = new MockedSecureStorage({
    scopedKeys: { ...MOCK_ACCOUNT_KEYS.scopedKeys },
  });
  await sm.initialize();

  let accountData = await sm.getAccountData(["uid", "scopedKeys"]);
  Assert.ok(accountData, "read account data");
  Assert.equal(accountData.uid, "uid");
  Assert.deepEqual(accountData.scopedKeys, MOCK_ACCOUNT_KEYS.scopedKeys);
  // We didn't ask for email so shouldn't have got it.
  Assert.strictEqual(accountData.email, undefined);
});

add_task(async function checkExplicitGetNoSecureRead() {
  let sm = new FxAccountsStorageManager();
  sm.plainStorage = new MockedPlainStorage({
    uid: "uid",
    email: "someone@somewhere.com",
  });
  sm.secureStorage = new MockedSecureStorage({
    scopedKeys: { ...MOCK_ACCOUNT_KEYS.scopedKeys },
  });
  await sm.initialize();

  Assert.equal(sm.secureStorage.fetchCount, 0);
  // request 2 fields in secure storage - it should have caused a single fetch.
  let accountData = await sm.getAccountData(["email", "uid"]);
  Assert.ok(accountData, "read account data");
  Assert.equal(accountData.uid, "uid");
  Assert.equal(accountData.email, "someone@somewhere.com");
  Assert.strictEqual(accountData.scopedKeys, undefined);
  Assert.equal(sm.secureStorage.fetchCount, 1);
});

add_task(async function checkLockedUpdates() {
  let sm = new FxAccountsStorageManager();
  sm.plainStorage = new MockedPlainStorage({
    uid: "uid",
    email: "someone@somewhere.com",
  });
  sm.secureStorage = new MockedSecureStorage({
    scopedKeys: { ...MOCK_ACCOUNT_KEYS.scopedKeys },
    unwrapBKey: "unwrapBKey",
  });
  sm.secureStorage.locked = true;
  await sm.initialize();

  let accountData = await sm.getAccountData();
  // requesting scopedKeys will fail as storage is locked.
  Assert.ok(!accountData.scopedKeys);
  // While locked we can still update it and see the updated value.
  sm.updateAccountData({ unwrapBKey: "new-unwrapBKey" });
  accountData = await sm.getAccountData();
  Assert.equal(accountData.unwrapBKey, "new-unwrapBKey");
  // unlock.
  sm.secureStorage.locked = false;
  accountData = await sm.getAccountData();
  // should reflect the value we updated and the one we didn't.
  Assert.equal(accountData.unwrapBKey, "new-unwrapBKey");
  Assert.deepEqual(accountData.scopedKeys, MOCK_ACCOUNT_KEYS.scopedKeys);
  // And storage should also reflect it.
  Assert.deepEqual(
    sm.secureStorage.data.accountData.scopedKeys,
    MOCK_ACCOUNT_KEYS.scopedKeys
  );
  Assert.strictEqual(
    sm.secureStorage.data.accountData.unwrapBKey,
    "new-unwrapBKey"
  );
});

// Some tests for the "storage queue" functionality.

// A helper for our queued tests. It creates a StorageManager and then queues
// an unresolved promise. The tests then do additional setup and checks, then
// resolves or rejects the blocked promise.
async function setupStorageManagerForQueueTest() {
  let sm = new FxAccountsStorageManager();
  sm.plainStorage = new MockedPlainStorage({
    uid: "uid",
    email: "someone@somewhere.com",
  });
  sm.secureStorage = new MockedSecureStorage({
    scopedKeys: { ...MOCK_ACCOUNT_KEYS.scopedKeys },
  });
  sm.secureStorage.locked = true;
  await sm.initialize();

  let resolveBlocked, rejectBlocked;
  let blockedPromise = new Promise((resolve, reject) => {
    resolveBlocked = resolve;
    rejectBlocked = reject;
  });

  sm._queueStorageOperation(() => blockedPromise);
  return { sm, blockedPromise, resolveBlocked, rejectBlocked };
}

// First the general functionality.
add_task(async function checkQueueSemantics() {
  let { sm, resolveBlocked } = await setupStorageManagerForQueueTest();

  // We've one unresolved promise in the queue - add another promise.
  let resolveSubsequent;
  let subsequentPromise = new Promise(resolve => {
    resolveSubsequent = resolve;
  });
  let subsequentCalled = false;

  sm._queueStorageOperation(() => {
    subsequentCalled = true;
    resolveSubsequent();
    return subsequentPromise;
  });

  // Our "subsequent" function should not have been called yet.
  Assert.ok(!subsequentCalled);

  // Release our blocked promise.
  resolveBlocked();

  // Our subsequent promise should end up resolved.
  await subsequentPromise;
  Assert.ok(subsequentCalled);
  await sm.finalize();
});

// Check that a queued promise being rejected works correctly.
add_task(async function checkQueueSemanticsOnError() {
  let { sm, blockedPromise, rejectBlocked } =
    await setupStorageManagerForQueueTest();

  let resolveSubsequent;
  let subsequentPromise = new Promise(resolve => {
    resolveSubsequent = resolve;
  });
  let subsequentCalled = false;

  sm._queueStorageOperation(() => {
    subsequentCalled = true;
    resolveSubsequent();
    return subsequentPromise;
  });

  // Our "subsequent" function should not have been called yet.
  Assert.ok(!subsequentCalled);

  // Reject our blocked promise - the subsequent operations should still work
  // correctly.
  rejectBlocked("oh no");

  // Our subsequent promise should end up resolved.
  await subsequentPromise;
  Assert.ok(subsequentCalled);

  // But the first promise should reflect the rejection.
  try {
    await blockedPromise;
    Assert.ok(false, "expected this promise to reject");
  } catch (ex) {
    Assert.equal(ex, "oh no");
  }
  await sm.finalize();
});

// And some tests for the specific operations that are queued.
add_task(async function checkQueuedReadAndUpdate() {
  let { sm, resolveBlocked } = await setupStorageManagerForQueueTest();
  // Mock the underlying operations
  // _doReadAndUpdateSecure is queued by _maybeReadAndUpdateSecure
  let _doReadCalled = false;
  sm._doReadAndUpdateSecure = () => {
    _doReadCalled = true;
    return Promise.resolve();
  };

  let resultPromise = sm._maybeReadAndUpdateSecure();
  Assert.ok(!_doReadCalled);

  resolveBlocked();
  await resultPromise;
  Assert.ok(_doReadCalled);
  await sm.finalize();
});

add_task(async function checkQueuedWrite() {
  let { sm, resolveBlocked } = await setupStorageManagerForQueueTest();
  // Mock the underlying operations
  let __writeCalled = false;
  sm.__write = () => {
    __writeCalled = true;
    return Promise.resolve();
  };

  let writePromise = sm._write();
  Assert.ok(!__writeCalled);

  resolveBlocked();
  await writePromise;
  Assert.ok(__writeCalled);
  await sm.finalize();
});

add_task(async function checkQueuedDelete() {
  let { sm, resolveBlocked } = await setupStorageManagerForQueueTest();
  // Mock the underlying operations
  let _deleteCalled = false;
  sm._deleteAccountData = () => {
    _deleteCalled = true;
    return Promise.resolve();
  };

  let resultPromise = sm.deleteAccountData();
  Assert.ok(!_deleteCalled);

  resolveBlocked();
  await resultPromise;
  Assert.ok(_deleteCalled);
  await sm.finalize();
});
