diff options
Diffstat (limited to 'services/sync/tests/unit/test_clients_engine.js')
-rw-r--r-- | services/sync/tests/unit/test_clients_engine.js | 2108 |
1 files changed, 2108 insertions, 0 deletions
diff --git a/services/sync/tests/unit/test_clients_engine.js b/services/sync/tests/unit/test_clients_engine.js new file mode 100644 index 0000000000..d910a67503 --- /dev/null +++ b/services/sync/tests/unit/test_clients_engine.js @@ -0,0 +1,2108 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { ClientEngine, ClientsRec } = ChromeUtils.importESModule( + "resource://services-sync/engines/clients.sys.mjs" +); +const { CryptoWrapper } = ChromeUtils.importESModule( + "resource://services-sync/record.sys.mjs" +); +const { Service } = ChromeUtils.importESModule( + "resource://services-sync/service.sys.mjs" +); + +const MORE_THAN_CLIENTS_TTL_REFRESH = 691200; // 8 days +const LESS_THAN_CLIENTS_TTL_REFRESH = 86400; // 1 day + +let engine; + +/** + * Unpack the record with this ID, and verify that it has the same version that + * we should be putting into records. + */ +async function check_record_version(user, id) { + let payload = user.collection("clients").wbo(id).data; + + let rec = new CryptoWrapper(); + rec.id = id; + rec.collection = "clients"; + rec.ciphertext = payload.ciphertext; + rec.hmac = payload.hmac; + rec.IV = payload.IV; + + let cleartext = await rec.decrypt( + Service.collectionKeys.keyForCollection("clients") + ); + + _("Payload is " + JSON.stringify(cleartext)); + equal(Services.appinfo.version, cleartext.version); + equal(1, cleartext.protocols.length); + equal("1.5", cleartext.protocols[0]); +} + +// compare 2 different command arrays, taking into account that a flowID +// attribute must exist, be unique in the commands, but isn't specified in +// "expected" as the value isn't known. +function compareCommands(actual, expected, description) { + let tweakedActual = JSON.parse(JSON.stringify(actual)); + tweakedActual.map(elt => delete elt.flowID); + deepEqual(tweakedActual, expected, description); + // each item must have a unique flowID. + let allIDs = new Set(actual.map(elt => elt.flowID).filter(fid => !!fid)); + equal(allIDs.size, actual.length, "all items have unique IDs"); +} + +async function syncClientsEngine(server) { + engine._lastFxADevicesFetch = 0; + engine.lastModified = server.getCollection("foo", "clients").timestamp; + await engine._sync(); +} + +add_task(async function setup() { + engine = Service.clientsEngine; +}); + +async function cleanup() { + for (const pref of Svc.PrefBranch.getChildList("")) { + Svc.PrefBranch.clearUserPref(pref); + } + await engine._tracker.clearChangedIDs(); + await engine._resetClient(); + // un-cleanup the logs (the resetBranch will have reset their levels), since + // not all the tests use SyncTestingInfrastructure, and it's cheap. + syncTestLogging(); + // We don't finalize storage at cleanup, since we use the same clients engine + // instance across all tests. +} + +add_task(async function test_bad_hmac() { + _("Ensure that Clients engine deletes corrupt records."); + let deletedCollections = []; + let deletedItems = []; + let callback = { + onItemDeleted(username, coll, wboID) { + deletedItems.push(coll + "/" + wboID); + }, + onCollectionDeleted(username, coll) { + deletedCollections.push(coll); + }, + }; + Object.setPrototypeOf(callback, SyncServerCallback); + let server = await serverForFoo(engine, callback); + let user = server.user("foo"); + + function check_clients_count(expectedCount) { + let coll = user.collection("clients"); + + // Treat a non-existent collection as empty. + equal(expectedCount, coll ? coll.count() : 0); + } + + function check_client_deleted(id) { + let coll = user.collection("clients"); + let wbo = coll.wbo(id); + return !wbo || !wbo.payload; + } + + async function uploadNewKeys() { + await generateNewKeys(Service.collectionKeys); + let serverKeys = Service.collectionKeys.asWBO("crypto", "keys"); + await serverKeys.encrypt(Service.identity.syncKeyBundle); + ok( + (await serverKeys.upload(Service.resource(Service.cryptoKeysURL))).success + ); + } + + try { + await configureIdentity({ username: "foo" }, server); + await Service.login(); + + await generateNewKeys(Service.collectionKeys); + + _("First sync, client record is uploaded"); + equal(engine.lastRecordUpload, 0); + ok(engine.isFirstSync); + check_clients_count(0); + await syncClientsEngine(server); + check_clients_count(1); + ok(engine.lastRecordUpload > 0); + ok(!engine.isFirstSync); + + // Our uploaded record has a version. + await check_record_version(user, engine.localID); + + // Initial setup can wipe the server, so clean up. + deletedCollections = []; + deletedItems = []; + + _("Change our keys and our client ID, reupload keys."); + let oldLocalID = engine.localID; // Preserve to test for deletion! + engine.localID = Utils.makeGUID(); + await engine.resetClient(); + await generateNewKeys(Service.collectionKeys); + let serverKeys = Service.collectionKeys.asWBO("crypto", "keys"); + await serverKeys.encrypt(Service.identity.syncKeyBundle); + ok( + (await serverKeys.upload(Service.resource(Service.cryptoKeysURL))).success + ); + + _("Sync."); + await syncClientsEngine(server); + + _("Old record " + oldLocalID + " was deleted, new one uploaded."); + check_clients_count(1); + check_client_deleted(oldLocalID); + + _( + "Now change our keys but don't upload them. " + + "That means we get an HMAC error but redownload keys." + ); + Service.lastHMACEvent = 0; + engine.localID = Utils.makeGUID(); + await engine.resetClient(); + await generateNewKeys(Service.collectionKeys); + deletedCollections = []; + deletedItems = []; + check_clients_count(1); + await syncClientsEngine(server); + + _("Old record was not deleted, new one uploaded."); + equal(deletedCollections.length, 0); + equal(deletedItems.length, 0); + check_clients_count(2); + + _( + "Now try the scenario where our keys are wrong *and* there's a bad record." + ); + // Clean up and start fresh. + user.collection("clients")._wbos = {}; + Service.lastHMACEvent = 0; + engine.localID = Utils.makeGUID(); + await engine.resetClient(); + deletedCollections = []; + deletedItems = []; + check_clients_count(0); + + await uploadNewKeys(); + + // Sync once to upload a record. + await syncClientsEngine(server); + check_clients_count(1); + + // Generate and upload new keys, so the old client record is wrong. + await uploadNewKeys(); + + // Create a new client record and new keys. Now our keys are wrong, as well + // as the object on the server. We'll download the new keys and also delete + // the bad client record. + oldLocalID = engine.localID; // Preserve to test for deletion! + engine.localID = Utils.makeGUID(); + await engine.resetClient(); + await generateNewKeys(Service.collectionKeys); + let oldKey = Service.collectionKeys.keyForCollection(); + + equal(deletedCollections.length, 0); + equal(deletedItems.length, 0); + await syncClientsEngine(server); + equal(deletedItems.length, 1); + check_client_deleted(oldLocalID); + check_clients_count(1); + let newKey = Service.collectionKeys.keyForCollection(); + ok(!oldKey.equals(newKey)); + } finally { + await cleanup(); + await promiseStopServer(server); + } +}); + +add_task(async function test_properties() { + _("Test lastRecordUpload property"); + try { + equal( + Svc.PrefBranch.getPrefType("clients.lastRecordUpload"), + Ci.nsIPrefBranch.PREF_INVALID + ); + equal(engine.lastRecordUpload, 0); + + let now = Date.now(); + engine.lastRecordUpload = now / 1000; + equal(engine.lastRecordUpload, Math.floor(now / 1000)); + } finally { + await cleanup(); + } +}); + +add_task(async function test_full_sync() { + _("Ensure that Clients engine fetches all records for each sync."); + + let now = new_timestamp(); + let server = await serverForFoo(engine); + let user = server.user("foo"); + + await SyncTestingInfrastructure(server); + await generateNewKeys(Service.collectionKeys); + + let activeID = Utils.makeGUID(); + user.collection("clients").insertRecord( + { + id: activeID, + name: "Active client", + type: "desktop", + commands: [], + version: "48", + protocols: ["1.5"], + }, + now - 10 + ); + + let deletedID = Utils.makeGUID(); + user.collection("clients").insertRecord( + { + id: deletedID, + name: "Client to delete", + type: "desktop", + commands: [], + version: "48", + protocols: ["1.5"], + }, + now - 10 + ); + + try { + let store = engine._store; + + _("First sync. 2 records downloaded; our record uploaded."); + strictEqual(engine.lastRecordUpload, 0); + ok(engine.isFirstSync); + await syncClientsEngine(server); + ok(engine.lastRecordUpload > 0); + ok(!engine.isFirstSync); + deepEqual( + user.collection("clients").keys().sort(), + [activeID, deletedID, engine.localID].sort(), + "Our record should be uploaded on first sync" + ); + let ids = await store.getAllIDs(); + deepEqual( + Object.keys(ids).sort(), + [activeID, deletedID, engine.localID].sort(), + "Other clients should be downloaded on first sync" + ); + + _("Delete a record, then sync again"); + let collection = server.getCollection("foo", "clients"); + collection.remove(deletedID); + // Simulate a timestamp update in info/collections. + await syncClientsEngine(server); + + _("Record should be updated"); + ids = await store.getAllIDs(); + deepEqual( + Object.keys(ids).sort(), + [activeID, engine.localID].sort(), + "Deleted client should be removed on next sync" + ); + } finally { + await cleanup(); + + try { + server.deleteCollections("foo"); + } finally { + await promiseStopServer(server); + } + } +}); + +add_task(async function test_sync() { + _("Ensure that Clients engine uploads a new client record once a week."); + + let server = await serverForFoo(engine); + let user = server.user("foo"); + + await SyncTestingInfrastructure(server); + await generateNewKeys(Service.collectionKeys); + + function clientWBO() { + return user.collection("clients").wbo(engine.localID); + } + + try { + _("First sync. Client record is uploaded."); + equal(clientWBO(), undefined); + equal(engine.lastRecordUpload, 0); + ok(engine.isFirstSync); + await syncClientsEngine(server); + ok(!!clientWBO().payload); + ok(engine.lastRecordUpload > 0); + ok(!engine.isFirstSync); + + _( + "Let's time travel more than a week back, new record should've been uploaded." + ); + engine.lastRecordUpload -= MORE_THAN_CLIENTS_TTL_REFRESH; + let lastweek = engine.lastRecordUpload; + clientWBO().payload = undefined; + await syncClientsEngine(server); + ok(!!clientWBO().payload); + ok(engine.lastRecordUpload > lastweek); + ok(!engine.isFirstSync); + + _("Remove client record."); + await engine.removeClientData(); + equal(clientWBO().payload, undefined); + + _("Time travel one day back, no record uploaded."); + engine.lastRecordUpload -= LESS_THAN_CLIENTS_TTL_REFRESH; + let yesterday = engine.lastRecordUpload; + await syncClientsEngine(server); + equal(clientWBO().payload, undefined); + equal(engine.lastRecordUpload, yesterday); + ok(!engine.isFirstSync); + } finally { + await cleanup(); + await promiseStopServer(server); + } +}); + +add_task(async function test_client_name_change() { + _("Ensure client name change incurs a client record update."); + + let tracker = engine._tracker; + + engine.localID; // Needed to increase the tracker changedIDs count. + let initialName = engine.localName; + + tracker.start(); + _("initial name: " + initialName); + + // Tracker already has data, so clear it. + await tracker.clearChangedIDs(); + + let initialScore = tracker.score; + + let changedIDs = await tracker.getChangedIDs(); + equal(Object.keys(changedIDs).length, 0); + + Services.prefs.setStringPref( + "identity.fxaccounts.account.device.name", + "new name" + ); + await tracker.asyncObserver.promiseObserversComplete(); + + _("new name: " + engine.localName); + notEqual(initialName, engine.localName); + changedIDs = await tracker.getChangedIDs(); + equal(Object.keys(changedIDs).length, 1); + ok(engine.localID in changedIDs); + ok(tracker.score > initialScore); + ok(tracker.score >= SCORE_INCREMENT_XLARGE); + + await tracker.stop(); + + await cleanup(); +}); + +add_task(async function test_fxa_device_id_change() { + _("Ensure an FxA device ID change incurs a client record update."); + + let tracker = engine._tracker; + + engine.localID; // Needed to increase the tracker changedIDs count. + + tracker.start(); + + // Tracker already has data, so clear it. + await tracker.clearChangedIDs(); + + let initialScore = tracker.score; + + let changedIDs = await tracker.getChangedIDs(); + equal(Object.keys(changedIDs).length, 0); + + Services.obs.notifyObservers(null, "fxaccounts:new_device_id"); + await tracker.asyncObserver.promiseObserversComplete(); + + changedIDs = await tracker.getChangedIDs(); + equal(Object.keys(changedIDs).length, 1); + ok(engine.localID in changedIDs); + ok(tracker.score > initialScore); + ok(tracker.score >= SINGLE_USER_THRESHOLD); + + await tracker.stop(); + + await cleanup(); +}); + +add_task(async function test_last_modified() { + _("Ensure that remote records have a sane serverLastModified attribute."); + + let now = new_timestamp(); + let server = await serverForFoo(engine); + let user = server.user("foo"); + + await SyncTestingInfrastructure(server); + await generateNewKeys(Service.collectionKeys); + + let activeID = Utils.makeGUID(); + user.collection("clients").insertRecord( + { + id: activeID, + name: "Active client", + type: "desktop", + commands: [], + version: "48", + protocols: ["1.5"], + }, + now - 10 + ); + + try { + let collection = user.collection("clients"); + + _("Sync to download the record"); + await syncClientsEngine(server); + + equal( + engine._store._remoteClients[activeID].serverLastModified, + now - 10, + "last modified in the local record is correctly the server last-modified" + ); + + _("Modify the record and re-upload it"); + // set a new name to make sure we really did upload. + engine._store._remoteClients[activeID].name = "New name"; + engine._modified.set(activeID, 0); + // The sync above also did a POST, so adjust our lastModified. + engine.lastModified = server.getCollection("foo", "clients").timestamp; + await engine._uploadOutgoing(); + + _("Local record should have updated timestamp"); + ok(engine._store._remoteClients[activeID].serverLastModified >= now); + + _("Record on the server should have new name but not serverLastModified"); + let payload = collection.cleartext(activeID); + equal(payload.name, "New name"); + equal(payload.serverLastModified, undefined); + } finally { + await cleanup(); + server.deleteCollections("foo"); + await promiseStopServer(server); + } +}); + +add_task(async function test_send_command() { + _("Verifies _sendCommandToClient puts commands in the outbound queue."); + + let store = engine._store; + let tracker = engine._tracker; + let remoteId = Utils.makeGUID(); + let rec = new ClientsRec("clients", remoteId); + + await store.create(rec); + await store.createRecord(remoteId, "clients"); + + let action = "testCommand"; + let args = ["foo", "bar"]; + let extra = { flowID: "flowy" }; + + await engine._sendCommandToClient(action, args, remoteId, extra); + + let newRecord = store._remoteClients[remoteId]; + let clientCommands = (await engine._readCommands())[remoteId]; + notEqual(newRecord, undefined); + equal(clientCommands.length, 1); + + let command = clientCommands[0]; + equal(command.command, action); + equal(command.args.length, 2); + deepEqual(command.args, args); + ok(command.flowID); + + const changes = await tracker.getChangedIDs(); + notEqual(changes[remoteId], undefined); + + await cleanup(); +}); + +// The browser UI might call _addClientCommand indirectly without awaiting on the returned promise. +// We need to make sure this doesn't result on commands not being saved. +add_task(async function test_add_client_command_race() { + let promises = []; + for (let i = 0; i < 100; i++) { + promises.push( + engine._addClientCommand(`client-${i}`, { command: "cmd", args: [] }) + ); + } + await Promise.all(promises); + + let localCommands = await engine._readCommands(); + for (let i = 0; i < 100; i++) { + equal(localCommands[`client-${i}`].length, 1); + } +}); + +add_task(async function test_command_validation() { + _("Verifies that command validation works properly."); + + let store = engine._store; + + let testCommands = [ + ["resetAll", [], true], + ["resetAll", ["foo"], false], + ["resetEngine", ["tabs"], true], + ["resetEngine", [], false], + ["wipeEngine", ["tabs"], true], + ["wipeEngine", [], false], + ["logout", [], true], + ["logout", ["foo"], false], + ["__UNKNOWN__", [], false], + ]; + + for (let [action, args, expectedResult] of testCommands) { + let remoteId = Utils.makeGUID(); + let rec = new ClientsRec("clients", remoteId); + + await store.create(rec); + await store.createRecord(remoteId, "clients"); + + await engine.sendCommand(action, args, remoteId); + + let newRecord = store._remoteClients[remoteId]; + notEqual(newRecord, undefined); + + let clientCommands = (await engine._readCommands())[remoteId]; + + if (expectedResult) { + _("Ensuring command is sent: " + action); + equal(clientCommands.length, 1); + + let command = clientCommands[0]; + equal(command.command, action); + deepEqual(command.args, args); + + notEqual(engine._tracker, undefined); + const changes = await engine._tracker.getChangedIDs(); + notEqual(changes[remoteId], undefined); + } else { + _("Ensuring command is scrubbed: " + action); + equal(clientCommands, undefined); + + if (store._tracker) { + equal(engine._tracker[remoteId], undefined); + } + } + } + await cleanup(); +}); + +add_task(async function test_command_duplication() { + _("Ensures duplicate commands are detected and not added"); + + let store = engine._store; + let remoteId = Utils.makeGUID(); + let rec = new ClientsRec("clients", remoteId); + await store.create(rec); + await store.createRecord(remoteId, "clients"); + + let action = "resetAll"; + let args = []; + + await engine.sendCommand(action, args, remoteId); + await engine.sendCommand(action, args, remoteId); + + let clientCommands = (await engine._readCommands())[remoteId]; + equal(clientCommands.length, 1); + + _("Check variant args length"); + await engine._saveCommands({}); + + action = "resetEngine"; + await engine.sendCommand(action, [{ x: "foo" }], remoteId); + await engine.sendCommand(action, [{ x: "bar" }], remoteId); + + _("Make sure we spot a real dupe argument."); + await engine.sendCommand(action, [{ x: "bar" }], remoteId); + + clientCommands = (await engine._readCommands())[remoteId]; + equal(clientCommands.length, 2); + + await cleanup(); +}); + +add_task(async function test_command_invalid_client() { + _("Ensures invalid client IDs are caught"); + + let id = Utils.makeGUID(); + let error; + + try { + await engine.sendCommand("wipeEngine", ["tabs"], id); + } catch (ex) { + error = ex; + } + + equal(error.message.indexOf("Unknown remote client ID: "), 0); + + await cleanup(); +}); + +add_task(async function test_process_incoming_commands() { + _("Ensures local commands are executed"); + + engine.localCommands = [{ command: "logout", args: [] }]; + + let ev = "weave:service:logout:finish"; + + let logoutPromise = new Promise(resolve => { + var handler = function () { + Svc.Obs.remove(ev, handler); + + resolve(); + }; + + Svc.Obs.add(ev, handler); + }); + + // logout command causes processIncomingCommands to return explicit false. + ok(!(await engine.processIncomingCommands())); + + await logoutPromise; + + await cleanup(); +}); + +add_task(async function test_filter_duplicate_names() { + _( + "Ensure that we exclude clients with identical names that haven't synced in a week." + ); + + let now = new_timestamp(); + let server = await serverForFoo(engine); + let user = server.user("foo"); + + await SyncTestingInfrastructure(server); + await generateNewKeys(Service.collectionKeys); + + // Synced recently. + let recentID = Utils.makeGUID(); + user.collection("clients").insertRecord( + { + id: recentID, + name: "My Phone", + type: "mobile", + commands: [], + version: "48", + protocols: ["1.5"], + }, + now - 10 + ); + + // Dupe of our client, synced more than 1 week ago. + let dupeID = Utils.makeGUID(); + user.collection("clients").insertRecord( + { + id: dupeID, + name: engine.localName, + type: "desktop", + commands: [], + version: "48", + protocols: ["1.5"], + }, + now - 604820 + ); + + // Synced more than 1 week ago, but not a dupe. + let oldID = Utils.makeGUID(); + user.collection("clients").insertRecord( + { + id: oldID, + name: "My old desktop", + type: "desktop", + commands: [], + version: "48", + protocols: ["1.5"], + }, + now - 604820 + ); + + try { + let store = engine._store; + + _("First sync"); + strictEqual(engine.lastRecordUpload, 0); + ok(engine.isFirstSync); + await syncClientsEngine(server); + ok(engine.lastRecordUpload > 0); + ok(!engine.isFirstSync); + deepEqual( + user.collection("clients").keys().sort(), + [recentID, dupeID, oldID, engine.localID].sort(), + "Our record should be uploaded on first sync" + ); + + let ids = await store.getAllIDs(); + deepEqual( + Object.keys(ids).sort(), + [recentID, dupeID, oldID, engine.localID].sort(), + "Duplicate ID should remain in getAllIDs" + ); + ok( + await engine._store.itemExists(dupeID), + "Dupe ID should be considered as existing for Sync methods." + ); + ok( + !engine.remoteClientExists(dupeID), + "Dupe ID should not be considered as existing for external methods." + ); + + // dupe desktop should not appear in .deviceTypes. + equal(engine.deviceTypes.get("desktop"), 2); + equal(engine.deviceTypes.get("mobile"), 1); + + // dupe desktop should not appear in stats + deepEqual(engine.stats, { + hasMobile: 1, + names: [engine.localName, "My Phone", "My old desktop"], + numClients: 3, + }); + + ok(engine.remoteClientExists(oldID), "non-dupe ID should exist."); + ok(!engine.remoteClientExists(dupeID), "dupe ID should not exist"); + equal( + engine.remoteClients.length, + 2, + "dupe should not be in remoteClients" + ); + + // Check that a subsequent Sync doesn't report anything as being processed. + let counts; + Svc.Obs.add("weave:engine:sync:applied", function observe(subject, data) { + Svc.Obs.remove("weave:engine:sync:applied", observe); + counts = subject; + }); + + await syncClientsEngine(server); + equal(counts.applied, 0); // We didn't report applying any records. + equal(counts.reconciled, 4); // We reported reconcilliation for all records + equal(counts.succeeded, 0); + equal(counts.failed, 0); + equal(counts.newFailed, 0); + + _("Broadcast logout to all clients"); + await engine.sendCommand("logout", []); + await syncClientsEngine(server); + + let collection = server.getCollection("foo", "clients"); + let recentPayload = collection.cleartext(recentID); + compareCommands( + recentPayload.commands, + [{ command: "logout", args: [] }], + "Should send commands to the recent client" + ); + + let oldPayload = collection.cleartext(oldID); + compareCommands( + oldPayload.commands, + [{ command: "logout", args: [] }], + "Should send commands to the week-old client" + ); + + let dupePayload = collection.cleartext(dupeID); + deepEqual( + dupePayload.commands, + [], + "Should not send commands to the dupe client" + ); + + _("Update the dupe client's modified time"); + collection.insertRecord( + { + id: dupeID, + name: engine.localName, + type: "desktop", + commands: [], + version: "48", + protocols: ["1.5"], + }, + now - 10 + ); + + _("Second sync."); + await syncClientsEngine(server); + + ids = await store.getAllIDs(); + deepEqual( + Object.keys(ids).sort(), + [recentID, oldID, dupeID, engine.localID].sort(), + "Stale client synced, so it should no longer be marked as a dupe" + ); + + ok( + engine.remoteClientExists(dupeID), + "Dupe ID should appear as it synced." + ); + + // Recently synced dupe desktop should appear in .deviceTypes. + equal(engine.deviceTypes.get("desktop"), 3); + + // Recently synced dupe desktop should now appear in stats + deepEqual(engine.stats, { + hasMobile: 1, + names: [engine.localName, "My Phone", engine.localName, "My old desktop"], + numClients: 4, + }); + + ok( + engine.remoteClientExists(dupeID), + "recently synced dupe ID should now exist" + ); + equal( + engine.remoteClients.length, + 3, + "recently synced dupe should now be in remoteClients" + ); + } finally { + await cleanup(); + + try { + server.deleteCollections("foo"); + } finally { + await promiseStopServer(server); + } + } +}); + +add_task(async function test_command_sync() { + _("Ensure that commands are synced across clients."); + + await engine._store.wipe(); + await generateNewKeys(Service.collectionKeys); + + let server = await serverForFoo(engine); + await SyncTestingInfrastructure(server); + + let user = server.user("foo"); + let remoteId = Utils.makeGUID(); + + function clientWBO(id) { + return user.collection("clients").wbo(id); + } + + _("Create remote client record"); + user.collection("clients").insertRecord({ + id: remoteId, + name: "Remote client", + type: "desktop", + commands: [], + version: "48", + protocols: ["1.5"], + }); + + try { + _("Syncing."); + await syncClientsEngine(server); + + _("Checking remote record was downloaded."); + let clientRecord = engine._store._remoteClients[remoteId]; + notEqual(clientRecord, undefined); + equal(clientRecord.commands.length, 0); + + _("Send a command to the remote client."); + await engine.sendCommand("wipeEngine", ["tabs"]); + let clientCommands = (await engine._readCommands())[remoteId]; + equal(clientCommands.length, 1); + await syncClientsEngine(server); + + _("Checking record was uploaded."); + notEqual(clientWBO(engine.localID).payload, undefined); + ok(engine.lastRecordUpload > 0); + ok(!engine.isFirstSync); + + notEqual(clientWBO(remoteId).payload, undefined); + + Svc.PrefBranch.setStringPref("client.GUID", remoteId); + await engine._resetClient(); + equal(engine.localID, remoteId); + _("Performing sync on resetted client."); + await syncClientsEngine(server); + notEqual(engine.localCommands, undefined); + equal(engine.localCommands.length, 1); + + let command = engine.localCommands[0]; + equal(command.command, "wipeEngine"); + equal(command.args.length, 1); + equal(command.args[0], "tabs"); + } finally { + await cleanup(); + + try { + let collection = server.getCollection("foo", "clients"); + collection.remove(remoteId); + } finally { + await promiseStopServer(server); + } + } +}); + +add_task(async function test_clients_not_in_fxa_list() { + _("Ensure that clients not in the FxA devices list are marked as stale."); + + await engine._store.wipe(); + await generateNewKeys(Service.collectionKeys); + + let server = await serverForFoo(engine); + await SyncTestingInfrastructure(server); + + let remoteId = Utils.makeGUID(); + let remoteId2 = Utils.makeGUID(); + let collection = server.getCollection("foo", "clients"); + + _("Create remote client records"); + collection.insertRecord({ + id: remoteId, + name: "Remote client", + type: "desktop", + commands: [], + version: "48", + fxaDeviceId: remoteId, + protocols: ["1.5"], + }); + + collection.insertRecord({ + id: remoteId2, + name: "Remote client 2", + type: "desktop", + commands: [], + version: "48", + fxaDeviceId: remoteId2, + protocols: ["1.5"], + }); + + let fxAccounts = engine.fxAccounts; + engine.fxAccounts = { + notifyDevices() { + return Promise.resolve(true); + }, + device: { + getLocalId() { + return fxAccounts.device.getLocalId(); + }, + getLocalName() { + return fxAccounts.device.getLocalName(); + }, + getLocalType() { + return fxAccounts.device.getLocalType(); + }, + recentDeviceList: [{ id: remoteId }], + refreshDeviceList() { + return Promise.resolve(true); + }, + }, + _internal: { + now() { + return Date.now(); + }, + }, + }; + + try { + _("Syncing."); + await syncClientsEngine(server); + + ok(!engine._store._remoteClients[remoteId].stale); + ok(engine._store._remoteClients[remoteId2].stale); + } finally { + engine.fxAccounts = fxAccounts; + await cleanup(); + + try { + collection.remove(remoteId); + } finally { + await promiseStopServer(server); + } + } +}); + +add_task(async function test_dupe_device_ids() { + _( + "Ensure that we mark devices with duplicate fxaDeviceIds but older lastModified as stale." + ); + + await engine._store.wipe(); + await generateNewKeys(Service.collectionKeys); + + let server = await serverForFoo(engine); + await SyncTestingInfrastructure(server); + + let remoteId = Utils.makeGUID(); + let remoteId2 = Utils.makeGUID(); + let remoteDeviceId = Utils.makeGUID(); + + let collection = server.getCollection("foo", "clients"); + + _("Create remote client records"); + collection.insertRecord( + { + id: remoteId, + name: "Remote client", + type: "desktop", + commands: [], + version: "48", + fxaDeviceId: remoteDeviceId, + protocols: ["1.5"], + }, + new_timestamp() - 3 + ); + collection.insertRecord({ + id: remoteId2, + name: "Remote client", + type: "desktop", + commands: [], + version: "48", + fxaDeviceId: remoteDeviceId, + protocols: ["1.5"], + }); + + let fxAccounts = engine.fxAccounts; + engine.fxAccounts = { + notifyDevices() { + return Promise.resolve(true); + }, + device: { + getLocalId() { + return fxAccounts.device.getLocalId(); + }, + getLocalName() { + return fxAccounts.device.getLocalName(); + }, + getLocalType() { + return fxAccounts.device.getLocalType(); + }, + recentDeviceList: [{ id: remoteDeviceId }], + refreshDeviceList() { + return Promise.resolve(true); + }, + }, + _internal: { + now() { + return Date.now(); + }, + }, + }; + + try { + _("Syncing."); + await syncClientsEngine(server); + + ok(engine._store._remoteClients[remoteId].stale); + ok(!engine._store._remoteClients[remoteId2].stale); + } finally { + engine.fxAccounts = fxAccounts; + await cleanup(); + + try { + collection.remove(remoteId); + } finally { + await promiseStopServer(server); + } + } +}); + +add_task(async function test_refresh_fxa_device_list() { + _("Ensure we refresh the fxa device list when we expect to."); + + await engine._store.wipe(); + engine._lastFxaDeviceRefresh = 0; + await generateNewKeys(Service.collectionKeys); + + let server = await serverForFoo(engine); + await SyncTestingInfrastructure(server); + + let numRefreshes = 0; + let now = Date.now(); + let fxAccounts = engine.fxAccounts; + engine.fxAccounts = { + notifyDevices() { + return Promise.resolve(true); + }, + device: { + getLocalId() { + return fxAccounts.device.getLocalId(); + }, + getLocalName() { + return fxAccounts.device.getLocalName(); + }, + getLocalType() { + return fxAccounts.device.getLocalType(); + }, + recentDeviceList: [], + refreshDeviceList() { + numRefreshes += 1; + return Promise.resolve(true); + }, + }, + _internal: { + now() { + return now; + }, + }, + }; + + try { + _("Syncing."); + await syncClientsEngine(server); + Assert.equal(numRefreshes, 1, "first sync should refresh"); + now += 1000; // a second later. + await syncClientsEngine(server); + Assert.equal(numRefreshes, 1, "next sync should not refresh"); + now += 60 * 60 * 2 * 1000; // 2 hours later + await syncClientsEngine(server); + Assert.equal(numRefreshes, 2, "2 hours later should refresh"); + now += 1000; // a second later. + Assert.equal(numRefreshes, 2, "next sync should not refresh"); + } finally { + await cleanup(); + await promiseStopServer(server); + } +}); + +add_task(async function test_optional_client_fields() { + _("Ensure that we produce records with the fields added in Bug 1097222."); + + const SUPPORTED_PROTOCOL_VERSIONS = ["1.5"]; + let local = await engine._store.createRecord(engine.localID, "clients"); + equal(local.name, engine.localName); + equal(local.type, engine.localType); + equal(local.version, Services.appinfo.version); + deepEqual(local.protocols, SUPPORTED_PROTOCOL_VERSIONS); + + // Optional fields. + // Make sure they're what they ought to be... + equal(local.os, Services.appinfo.OS); + equal(local.appPackage, Services.appinfo.ID); + + // ... and also that they're non-empty. + ok(!!local.os); + ok(!!local.appPackage); + ok(!!local.application); + + // We don't currently populate device or formfactor. + // See Bug 1100722, Bug 1100723. + + await cleanup(); +}); + +add_task(async function test_merge_commands() { + _("Verifies local commands for remote clients are merged with the server's"); + + let now = new_timestamp(); + let server = await serverForFoo(engine); + await SyncTestingInfrastructure(server); + await generateNewKeys(Service.collectionKeys); + + let collection = server.getCollection("foo", "clients"); + + let desktopID = Utils.makeGUID(); + collection.insertRecord( + { + id: desktopID, + name: "Desktop client", + type: "desktop", + commands: [ + { + command: "wipeEngine", + args: ["history"], + flowID: Utils.makeGUID(), + }, + ], + version: "48", + protocols: ["1.5"], + }, + now - 10 + ); + + let mobileID = Utils.makeGUID(); + collection.insertRecord( + { + id: mobileID, + name: "Mobile client", + type: "mobile", + commands: [ + { + command: "logout", + args: [], + flowID: Utils.makeGUID(), + }, + ], + version: "48", + protocols: ["1.5"], + }, + now - 10 + ); + + try { + _("First sync. 2 records downloaded."); + strictEqual(engine.lastRecordUpload, 0); + ok(engine.isFirstSync); + await syncClientsEngine(server); + + _("Broadcast logout to all clients"); + await engine.sendCommand("logout", []); + await syncClientsEngine(server); + + let desktopPayload = collection.cleartext(desktopID); + compareCommands( + desktopPayload.commands, + [ + { + command: "wipeEngine", + args: ["history"], + }, + { + command: "logout", + args: [], + }, + ], + "Should send the logout command to the desktop client" + ); + + let mobilePayload = collection.cleartext(mobileID); + compareCommands( + mobilePayload.commands, + [{ command: "logout", args: [] }], + "Should not send a duplicate logout to the mobile client" + ); + } finally { + await cleanup(); + + try { + server.deleteCollections("foo"); + } finally { + await promiseStopServer(server); + } + } +}); + +add_task(async function test_duplicate_remote_commands() { + _( + "Verifies local commands for remote clients are sent only once (bug 1289287)" + ); + + let now = new_timestamp(); + let server = await serverForFoo(engine); + + await SyncTestingInfrastructure(server); + await generateNewKeys(Service.collectionKeys); + + let collection = server.getCollection("foo", "clients"); + + let desktopID = Utils.makeGUID(); + collection.insertRecord( + { + id: desktopID, + name: "Desktop client", + type: "desktop", + commands: [], + version: "48", + protocols: ["1.5"], + }, + now - 10 + ); + + try { + _("First sync. 1 record downloaded."); + strictEqual(engine.lastRecordUpload, 0); + ok(engine.isFirstSync); + await syncClientsEngine(server); + + _("Send command to client to wipe history engine"); + await engine.sendCommand("wipeEngine", ["history"]); + await syncClientsEngine(server); + + _( + "Simulate the desktop client consuming the command and syncing to the server" + ); + collection.insertRecord( + { + id: desktopID, + name: "Desktop client", + type: "desktop", + commands: [], + version: "48", + protocols: ["1.5"], + }, + now - 10 + ); + + _("Send another command to the desktop client to wipe tabs engine"); + await engine.sendCommand("wipeEngine", ["tabs"], desktopID); + await syncClientsEngine(server); + + let desktopPayload = collection.cleartext(desktopID); + compareCommands( + desktopPayload.commands, + [ + { + command: "wipeEngine", + args: ["tabs"], + }, + ], + "Should only send the second command to the desktop client" + ); + } finally { + await cleanup(); + + try { + server.deleteCollections("foo"); + } finally { + await promiseStopServer(server); + } + } +}); + +add_task(async function test_upload_after_reboot() { + _("Multiple downloads, reboot, then upload (bug 1289287)"); + + let now = new_timestamp(); + let server = await serverForFoo(engine); + + await SyncTestingInfrastructure(server); + await generateNewKeys(Service.collectionKeys); + + let collection = server.getCollection("foo", "clients"); + + let deviceBID = Utils.makeGUID(); + let deviceCID = Utils.makeGUID(); + collection.insertRecord( + { + id: deviceBID, + name: "Device B", + type: "desktop", + commands: [ + { + command: "wipeEngine", + args: ["history"], + flowID: Utils.makeGUID(), + }, + ], + version: "48", + protocols: ["1.5"], + }, + now - 10 + ); + collection.insertRecord( + { + id: deviceCID, + name: "Device C", + type: "desktop", + commands: [], + version: "48", + protocols: ["1.5"], + }, + now - 10 + ); + + try { + _("First sync. 2 records downloaded."); + strictEqual(engine.lastRecordUpload, 0); + ok(engine.isFirstSync); + await syncClientsEngine(server); + + _("Send command to client to wipe tab engine"); + await engine.sendCommand("wipeEngine", ["tabs"], deviceBID); + + const oldUploadOutgoing = SyncEngine.prototype._uploadOutgoing; + SyncEngine.prototype._uploadOutgoing = async () => + engine._onRecordsWritten([], [deviceBID]); + await syncClientsEngine(server); + + let deviceBPayload = collection.cleartext(deviceBID); + compareCommands( + deviceBPayload.commands, + [ + { + command: "wipeEngine", + args: ["history"], + }, + ], + "Should be the same because the upload failed" + ); + + _("Simulate the client B consuming the command and syncing to the server"); + collection.insertRecord( + { + id: deviceBID, + name: "Device B", + type: "desktop", + commands: [], + version: "48", + protocols: ["1.5"], + }, + now - 10 + ); + + // Simulate reboot + SyncEngine.prototype._uploadOutgoing = oldUploadOutgoing; + engine = Service.clientsEngine = new ClientEngine(Service); + await engine.initialize(); + + await syncClientsEngine(server); + + deviceBPayload = collection.cleartext(deviceBID); + compareCommands( + deviceBPayload.commands, + [ + { + command: "wipeEngine", + args: ["tabs"], + }, + ], + "Should only had written our outgoing command" + ); + } finally { + await cleanup(); + + try { + server.deleteCollections("foo"); + } finally { + await promiseStopServer(server); + } + } +}); + +add_task(async function test_keep_cleared_commands_after_reboot() { + _( + "Download commands, fail upload, reboot, then apply new commands (bug 1289287)" + ); + + let now = new_timestamp(); + let server = await serverForFoo(engine); + + await SyncTestingInfrastructure(server); + await generateNewKeys(Service.collectionKeys); + + let collection = server.getCollection("foo", "clients"); + + let deviceBID = Utils.makeGUID(); + let deviceCID = Utils.makeGUID(); + collection.insertRecord( + { + id: engine.localID, + name: "Device A", + type: "desktop", + commands: [ + { + command: "wipeEngine", + args: ["history"], + flowID: Utils.makeGUID(), + }, + { + command: "wipeEngine", + args: ["tabs"], + flowID: Utils.makeGUID(), + }, + ], + version: "48", + protocols: ["1.5"], + }, + now - 10 + ); + collection.insertRecord( + { + id: deviceBID, + name: "Device B", + type: "desktop", + commands: [], + version: "48", + protocols: ["1.5"], + }, + now - 10 + ); + collection.insertRecord( + { + id: deviceCID, + name: "Device C", + type: "desktop", + commands: [], + version: "48", + protocols: ["1.5"], + }, + now - 10 + ); + + try { + _("First sync. Download remote and our record."); + strictEqual(engine.lastRecordUpload, 0); + ok(engine.isFirstSync); + + const oldUploadOutgoing = SyncEngine.prototype._uploadOutgoing; + SyncEngine.prototype._uploadOutgoing = async () => + engine._onRecordsWritten([], [deviceBID]); + let commandsProcessed = 0; + engine.service.wipeClient = _engine => { + commandsProcessed++; + }; + + await syncClientsEngine(server); + await engine.processIncomingCommands(); // Not called by the engine.sync(), gotta call it ourselves + equal(commandsProcessed, 2, "We processed 2 commands"); + + let localRemoteRecord = collection.cleartext(engine.localID); + compareCommands( + localRemoteRecord.commands, + [ + { + command: "wipeEngine", + args: ["history"], + }, + { + command: "wipeEngine", + args: ["tabs"], + }, + ], + "Should be the same because the upload failed" + ); + + // Another client sends a wipe command + collection.insertRecord( + { + id: engine.localID, + name: "Device A", + type: "desktop", + commands: [ + { + command: "wipeEngine", + args: ["history"], + flowID: Utils.makeGUID(), + }, + { + command: "wipeEngine", + args: ["tabs"], + flowID: Utils.makeGUID(), + }, + { + command: "wipeEngine", + args: ["bookmarks"], + flowID: Utils.makeGUID(), + }, + ], + version: "48", + protocols: ["1.5"], + }, + now - 5 + ); + + // Simulate reboot + SyncEngine.prototype._uploadOutgoing = oldUploadOutgoing; + engine = Service.clientsEngine = new ClientEngine(Service); + await engine.initialize(); + + commandsProcessed = 0; + engine.service.wipeClient = _engine => { + commandsProcessed++; + }; + await syncClientsEngine(server); + await engine.processIncomingCommands(); + equal( + commandsProcessed, + 1, + "We processed one command (the other were cleared)" + ); + + localRemoteRecord = collection.cleartext(deviceBID); + deepEqual(localRemoteRecord.commands, [], "Should be empty"); + } finally { + await cleanup(); + + // Reset service (remove mocks) + engine = Service.clientsEngine = new ClientEngine(Service); + await engine.initialize(); + await engine._resetClient(); + + try { + server.deleteCollections("foo"); + } finally { + await promiseStopServer(server); + } + } +}); + +add_task(async function test_deleted_commands() { + _("Verifies commands for a deleted client are discarded"); + + let now = new_timestamp(); + let server = await serverForFoo(engine); + + await SyncTestingInfrastructure(server); + await generateNewKeys(Service.collectionKeys); + + let collection = server.getCollection("foo", "clients"); + + let activeID = Utils.makeGUID(); + collection.insertRecord( + { + id: activeID, + name: "Active client", + type: "desktop", + commands: [], + version: "48", + protocols: ["1.5"], + }, + now - 10 + ); + + let deletedID = Utils.makeGUID(); + collection.insertRecord( + { + id: deletedID, + name: "Client to delete", + type: "desktop", + commands: [], + version: "48", + protocols: ["1.5"], + }, + now - 10 + ); + + try { + _("First sync. 2 records downloaded."); + await syncClientsEngine(server); + + _("Delete a record on the server."); + collection.remove(deletedID); + + _("Broadcast a command to all clients"); + await engine.sendCommand("logout", []); + await syncClientsEngine(server); + + deepEqual( + collection.keys().sort(), + [activeID, engine.localID].sort(), + "Should not reupload deleted clients" + ); + + let activePayload = collection.cleartext(activeID); + compareCommands( + activePayload.commands, + [{ command: "logout", args: [] }], + "Should send the command to the active client" + ); + } finally { + await cleanup(); + + try { + server.deleteCollections("foo"); + } finally { + await promiseStopServer(server); + } + } +}); + +add_task(async function test_command_sync() { + _("Notify other clients when writing their record."); + + await engine._store.wipe(); + await generateNewKeys(Service.collectionKeys); + + let server = await serverForFoo(engine); + await SyncTestingInfrastructure(server); + + let collection = server.getCollection("foo", "clients"); + let remoteId = Utils.makeGUID(); + let remoteId2 = Utils.makeGUID(); + + _("Create remote client record 1"); + collection.insertRecord({ + id: remoteId, + name: "Remote client", + type: "desktop", + commands: [], + version: "48", + protocols: ["1.5"], + }); + + _("Create remote client record 2"); + collection.insertRecord({ + id: remoteId2, + name: "Remote client 2", + type: "mobile", + commands: [], + version: "48", + protocols: ["1.5"], + }); + + try { + equal(collection.count(), 2, "2 remote records written"); + await syncClientsEngine(server); + equal( + collection.count(), + 3, + "3 remote records written (+1 for the synced local record)" + ); + + await engine.sendCommand("wipeEngine", ["tabs"]); + await engine._tracker.addChangedID(engine.localID); + const getClientFxaDeviceId = sinon + .stub(engine, "getClientFxaDeviceId") + .callsFake(id => "fxa-" + id); + const engineMock = sinon.mock(engine); + let _notifyCollectionChanged = engineMock + .expects("_notifyCollectionChanged") + .withArgs(["fxa-" + remoteId, "fxa-" + remoteId2]); + _("Syncing."); + await syncClientsEngine(server); + _notifyCollectionChanged.verify(); + + engineMock.restore(); + getClientFxaDeviceId.restore(); + } finally { + await cleanup(); + await engine._tracker.clearChangedIDs(); + + try { + server.deleteCollections("foo"); + } finally { + await promiseStopServer(server); + } + } +}); + +add_task(async function ensureSameFlowIDs() { + let events = []; + let origRecordTelemetryEvent = Service.recordTelemetryEvent; + Service.recordTelemetryEvent = (object, method, value, extra) => { + events.push({ object, method, value, extra }); + }; + + let server = await serverForFoo(engine); + try { + // Setup 2 clients, send them a command, and ensure we get to events + // written, both with the same flowID. + await SyncTestingInfrastructure(server); + let collection = server.getCollection("foo", "clients"); + + let remoteId = Utils.makeGUID(); + let remoteId2 = Utils.makeGUID(); + + _("Create remote client record 1"); + collection.insertRecord({ + id: remoteId, + name: "Remote client", + type: "desktop", + commands: [], + version: "48", + protocols: ["1.5"], + }); + + _("Create remote client record 2"); + collection.insertRecord({ + id: remoteId2, + name: "Remote client 2", + type: "mobile", + commands: [], + version: "48", + protocols: ["1.5"], + }); + + await syncClientsEngine(server); + await engine.sendCommand("wipeEngine", ["tabs"]); + await syncClientsEngine(server); + equal(events.length, 2); + // we don't know what the flowID is, but do know it should be the same. + equal(events[0].extra.flowID, events[1].extra.flowID); + // Wipe remote clients to ensure deduping doesn't prevent us from adding the command. + for (let client of Object.values(engine._store._remoteClients)) { + client.commands = []; + } + // check it's correctly used when we specify a flow ID + events.length = 0; + let flowID = Utils.makeGUID(); + await engine.sendCommand("wipeEngine", ["tabs"], null, { flowID }); + await syncClientsEngine(server); + equal(events.length, 2); + equal(events[0].extra.flowID, flowID); + equal(events[1].extra.flowID, flowID); + + // Wipe remote clients to ensure deduping doesn't prevent us from adding the command. + for (let client of Object.values(engine._store._remoteClients)) { + client.commands = []; + } + + // and that it works when something else is in "extra" + events.length = 0; + await engine.sendCommand("wipeEngine", ["tabs"], null, { + reason: "testing", + }); + await syncClientsEngine(server); + equal(events.length, 2); + equal(events[0].extra.flowID, events[1].extra.flowID); + equal(events[0].extra.reason, "testing"); + equal(events[1].extra.reason, "testing"); + // Wipe remote clients to ensure deduping doesn't prevent us from adding the command. + for (let client of Object.values(engine._store._remoteClients)) { + client.commands = []; + } + + // and when both are specified. + events.length = 0; + await engine.sendCommand("wipeEngine", ["tabs"], null, { + reason: "testing", + flowID, + }); + await syncClientsEngine(server); + equal(events.length, 2); + equal(events[0].extra.flowID, flowID); + equal(events[1].extra.flowID, flowID); + equal(events[0].extra.reason, "testing"); + equal(events[1].extra.reason, "testing"); + // Wipe remote clients to ensure deduping doesn't prevent us from adding the command. + for (let client of Object.values(engine._store._remoteClients)) { + client.commands = []; + } + } finally { + Service.recordTelemetryEvent = origRecordTelemetryEvent; + cleanup(); + await promiseStopServer(server); + } +}); + +add_task(async function test_duplicate_commands_telemetry() { + let events = []; + let origRecordTelemetryEvent = Service.recordTelemetryEvent; + Service.recordTelemetryEvent = (object, method, value, extra) => { + events.push({ object, method, value, extra }); + }; + + let server = await serverForFoo(engine); + try { + await SyncTestingInfrastructure(server); + let collection = server.getCollection("foo", "clients"); + + let remoteId = Utils.makeGUID(); + let remoteId2 = Utils.makeGUID(); + + _("Create remote client record 1"); + collection.insertRecord({ + id: remoteId, + name: "Remote client", + type: "desktop", + commands: [], + version: "48", + protocols: ["1.5"], + }); + + _("Create remote client record 2"); + collection.insertRecord({ + id: remoteId2, + name: "Remote client 2", + type: "mobile", + commands: [], + version: "48", + protocols: ["1.5"], + }); + + await syncClientsEngine(server); + // Make sure deduping works before syncing + await engine.sendCommand("wipeEngine", ["history"], remoteId); + await engine.sendCommand("wipeEngine", ["history"], remoteId); + equal(events.length, 1); + await syncClientsEngine(server); + // And after syncing. + await engine.sendCommand("wipeEngine", ["history"], remoteId); + equal(events.length, 1); + // Ensure we aren't deduping commands to different clients + await engine.sendCommand("wipeEngine", ["history"], remoteId2); + equal(events.length, 2); + } finally { + Service.recordTelemetryEvent = origRecordTelemetryEvent; + cleanup(); + await promiseStopServer(server); + } +}); + +add_task(async function test_other_clients_notified_on_first_sync() { + _( + "Ensure that other clients are notified when we upload our client record for the first time." + ); + + await engine.resetLastSync(); + await engine._store.wipe(); + await generateNewKeys(Service.collectionKeys); + + let server = await serverForFoo(engine); + await SyncTestingInfrastructure(server); + + const fxAccounts = engine.fxAccounts; + let calls = 0; + engine.fxAccounts = { + device: { + getLocalId() { + return fxAccounts.device.getLocalId(); + }, + getLocalName() { + return fxAccounts.device.getLocalName(); + }, + getLocalType() { + return fxAccounts.device.getLocalType(); + }, + }, + notifyDevices() { + calls++; + return Promise.resolve(true); + }, + _internal: { + now() { + return Date.now(); + }, + }, + }; + + try { + engine.lastRecordUpload = 0; + _("First sync, should notify other clients"); + await syncClientsEngine(server); + equal(calls, 1); + + _("Second sync, should not notify other clients"); + await syncClientsEngine(server); + equal(calls, 1); + } finally { + engine.fxAccounts = fxAccounts; + cleanup(); + await promiseStopServer(server); + } +}); + +add_task( + async function device_disconnected_notification_updates_known_stale_clients() { + const spyUpdate = sinon.spy(engine, "updateKnownStaleClients"); + + Services.obs.notifyObservers( + null, + "fxaccounts:device_disconnected", + JSON.stringify({ isLocalDevice: false }) + ); + ok(spyUpdate.calledOnce, "updateKnownStaleClients should be called"); + spyUpdate.resetHistory(); + + Services.obs.notifyObservers( + null, + "fxaccounts:device_disconnected", + JSON.stringify({ isLocalDevice: true }) + ); + ok(spyUpdate.notCalled, "updateKnownStaleClients should not be called"); + + spyUpdate.restore(); + } +); + +add_task(async function update_known_stale_clients() { + const makeFakeClient = id => ({ id, fxaDeviceId: `fxa-${id}` }); + const clients = [ + makeFakeClient("one"), + makeFakeClient("two"), + makeFakeClient("three"), + ]; + const stubRemoteClients = sinon + .stub(engine._store, "_remoteClients") + .get(() => { + return clients; + }); + const stubFetchFxADevices = sinon + .stub(engine, "_fetchFxADevices") + .callsFake(() => { + engine._knownStaleFxADeviceIds = ["fxa-one", "fxa-two"]; + }); + + engine._knownStaleFxADeviceIds = null; + await engine.updateKnownStaleClients(); + ok(clients[0].stale); + ok(clients[1].stale); + ok(!clients[2].stale); + + stubRemoteClients.restore(); + stubFetchFxADevices.restore(); +}); + +add_task(async function test_create_record_command_limit() { + await engine._store.wipe(); + await generateNewKeys(Service.collectionKeys); + + let server = await serverForFoo(engine); + await SyncTestingInfrastructure(server); + + const fakeLimit = 4 * 1024; + + let maxSizeStub = sinon + .stub(Service, "getMemcacheMaxRecordPayloadSize") + .callsFake(() => fakeLimit); + + let user = server.user("foo"); + let remoteId = Utils.makeGUID(); + + _("Create remote client record"); + user.collection("clients").insertRecord({ + id: remoteId, + name: "Remote client", + type: "desktop", + commands: [], + version: "57", + protocols: ["1.5"], + }); + + try { + _("Initial sync."); + await syncClientsEngine(server); + + _("Send a fairly sane number of commands."); + + for (let i = 0; i < 5; ++i) { + await engine.sendCommand("wipeEngine", [`history: ${i}`], remoteId); + } + + await syncClientsEngine(server); + + _("Make sure they all fit and weren't dropped."); + let parsedServerRecord = user.collection("clients").cleartext(remoteId); + + equal(parsedServerRecord.commands.length, 5); + + await engine.sendCommand("wipeEngine", ["history"], remoteId); + + _("Send a not-sane number of commands."); + // Much higher than the maximum number of commands we could actually fit. + for (let i = 0; i < 500; ++i) { + await engine.sendCommand("wipeEngine", [`tabs: ${i}`], remoteId); + } + + await syncClientsEngine(server); + + _("Ensure we didn't overflow the server limit."); + let wbo = user.collection("clients").wbo(remoteId); + less(wbo.payload.length, fakeLimit); + + _( + "And that the data we uploaded is both sane json and containing some commands." + ); + let remoteCommands = wbo.getCleartext().commands; + greater(remoteCommands.length, 2); + let firstCommand = remoteCommands[0]; + _( + "The first command should still be present, since it had a high priority" + ); + equal(firstCommand.command, "wipeEngine"); + _("And the last command in the list should be the last command we sent."); + let lastCommand = remoteCommands[remoteCommands.length - 1]; + equal(lastCommand.command, "wipeEngine"); + deepEqual(lastCommand.args, ["tabs: 499"]); + } finally { + maxSizeStub.restore(); + await cleanup(); + try { + let collection = server.getCollection("foo", "clients"); + collection.remove(remoteId); + } finally { + await promiseStopServer(server); + } + } +}); |