/* 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(); });