diff options
Diffstat (limited to 'services/sync/tests/unit')
132 files changed, 30972 insertions, 0 deletions
diff --git a/services/sync/tests/unit/addon1-search.xml b/services/sync/tests/unit/addon1-search.xml new file mode 100644 index 0000000000..1211d0c97b --- /dev/null +++ b/services/sync/tests/unit/addon1-search.xml @@ -0,0 +1,27 @@ +<?xml version="1.0" encoding="UTF-8"?> +<searchresults total_results="1"> + <addon id="5617"> + <name>Non-Restartless Test Extension</name> + <type id="1">Extension</type> + <guid>addon1@tests.mozilla.org</guid> + <slug>addon11</slug> + <version>1.0</version> + + <compatible_applications><application> + <name>Firefox</name> + <application_id>1</application_id> + <min_version>3.6</min_version> + <max_version>*</max_version> + <appID>xpcshell@tests.mozilla.org</appID> + </application></compatible_applications> + <all_compatible_os><os>ALL</os></all_compatible_os> + + <install os="ALL" size="485">http://127.0.0.1:8888/addon1.xpi</install> + <created epoch="1252903662"> + 2009-09-14T04:47:42Z + </created> + <last_updated epoch="1315255329"> + 2011-09-05T20:42:09Z + </last_updated> + </addon> +</searchresults> diff --git a/services/sync/tests/unit/bootstrap1-search.xml b/services/sync/tests/unit/bootstrap1-search.xml new file mode 100644 index 0000000000..b4538fba0d --- /dev/null +++ b/services/sync/tests/unit/bootstrap1-search.xml @@ -0,0 +1,27 @@ +<?xml version="1.0" encoding="UTF-8"?> +<searchresults total_results="1"> + <addon id="5617"> + <name>Restartless Test Extension</name> + <type id="1">Extension</type> + <guid>bootstrap1@tests.mozilla.org</guid> + <slug>bootstrap1</slug> + <version>1.0</version> + + <compatible_applications><application> + <name>Firefox</name> + <application_id>1</application_id> + <min_version>3.6</min_version> + <max_version>*</max_version> + <appID>xpcshell@tests.mozilla.org</appID> + </application></compatible_applications> + <all_compatible_os><os>ALL</os></all_compatible_os> + + <install os="ALL" size="485">http://127.0.0.1:8888/bootstrap1.xpi</install> + <created epoch="1252903662"> + 2009-09-14T04:47:42Z + </created> + <last_updated epoch="1315255329"> + 2011-09-05T20:42:09Z + </last_updated> + </addon> +</searchresults> diff --git a/services/sync/tests/unit/fake_login_manager.js b/services/sync/tests/unit/fake_login_manager.js new file mode 100644 index 0000000000..6f3148c45a --- /dev/null +++ b/services/sync/tests/unit/fake_login_manager.js @@ -0,0 +1,38 @@ +Cu.import("resource://services-sync/util.js"); + +// ---------------------------------------- +// Fake Sample Data +// ---------------------------------------- + +var fakeSampleLogins = [ + // Fake nsILoginInfo object. + {hostname: "www.boogle.com", + formSubmitURL: "http://www.boogle.com/search", + httpRealm: "", + username: "", + password: "", + usernameField: "test_person", + passwordField: "test_password"} +]; + +// ---------------------------------------- +// Fake Login Manager +// ---------------------------------------- + +function FakeLoginManager(fakeLogins) { + this.fakeLogins = fakeLogins; + + let self = this; + + // Use a fake nsILoginManager object. + delete Services.logins; + Services.logins = { + removeAllLogins: function() { self.fakeLogins = []; }, + getAllLogins: function() { return self.fakeLogins; }, + addLogin: function(login) { + getTestLogger().info("nsILoginManager.addLogin() called " + + "with hostname '" + login.hostname + "'."); + self.fakeLogins.push(login); + } + }; +} diff --git a/services/sync/tests/unit/head_appinfo.js b/services/sync/tests/unit/head_appinfo.js new file mode 100644 index 0000000000..d2a680df53 --- /dev/null +++ b/services/sync/tests/unit/head_appinfo.js @@ -0,0 +1,57 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +var {classes: Cc, interfaces: Ci, results: Cr, utils: Cu} = Components; +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); + +var gSyncProfile; + +gSyncProfile = do_get_profile(); + +// Init FormHistoryStartup and pretend we opened a profile. +var fhs = Cc["@mozilla.org/satchel/form-history-startup;1"] + .getService(Ci.nsIObserver); +fhs.observe(null, "profile-after-change", null); + +// An app is going to have some prefs set which xpcshell tests don't. +Services.prefs.setCharPref("identity.sync.tokenserver.uri", "http://token-server"); + +// Set the validation prefs to attempt validation every time to avoid non-determinism. +Services.prefs.setIntPref("services.sync.validation.interval", 0); +Services.prefs.setIntPref("services.sync.validation.percentageChance", 100); +Services.prefs.setIntPref("services.sync.validation.maxRecords", -1); +Services.prefs.setBoolPref("services.sync.validation.enabled", true); + +// Make sure to provide the right OS so crypto loads the right binaries +function getOS() { + switch (mozinfo.os) { + case "win": + return "WINNT"; + case "mac": + return "Darwin"; + default: + return "Linux"; + } +} + +Cu.import("resource://testing-common/AppInfo.jsm", this); +updateAppInfo({ + name: "XPCShell", + ID: "xpcshell@tests.mozilla.org", + version: "1", + platformVersion: "", + OS: getOS(), +}); + +// Register resource aliases. Normally done in SyncComponents.manifest. +function addResourceAlias() { + const resProt = Services.io.getProtocolHandler("resource") + .QueryInterface(Ci.nsIResProtocolHandler); + for (let s of ["common", "sync", "crypto"]) { + let uri = Services.io.newURI("resource://gre/modules/services-" + s + "/", null, + null); + resProt.setSubstitution("services-" + s, uri); + } +} +addResourceAlias(); diff --git a/services/sync/tests/unit/head_errorhandler_common.js b/services/sync/tests/unit/head_errorhandler_common.js new file mode 100644 index 0000000000..f4af60d9de --- /dev/null +++ b/services/sync/tests/unit/head_errorhandler_common.js @@ -0,0 +1,112 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +Cu.import("resource://services-sync/engines.js"); + +// Common code for test_errorhandler_{1,2}.js -- pulled out to make it less +// monolithic and take less time to execute. +const EHTestsCommon = { + + service_unavailable(request, response) { + let body = "Service Unavailable"; + response.setStatusLine(request.httpVersion, 503, "Service Unavailable"); + response.setHeader("Retry-After", "42"); + response.bodyOutputStream.write(body, body.length); + }, + + sync_httpd_setup() { + let global = new ServerWBO("global", { + syncID: Service.syncID, + storageVersion: STORAGE_VERSION, + engines: {clients: {version: Service.clientsEngine.version, + syncID: Service.clientsEngine.syncID}, + catapult: {version: Service.engineManager.get("catapult").version, + syncID: Service.engineManager.get("catapult").syncID}} + }); + let clientsColl = new ServerCollection({}, true); + + // Tracking info/collections. + let collectionsHelper = track_collections_helper(); + let upd = collectionsHelper.with_updated_collection; + + let handler_401 = httpd_handler(401, "Unauthorized"); + return httpd_setup({ + // Normal server behaviour. + "/1.1/johndoe/storage/meta/global": upd("meta", global.handler()), + "/1.1/johndoe/info/collections": collectionsHelper.handler, + "/1.1/johndoe/storage/crypto/keys": + upd("crypto", (new ServerWBO("keys")).handler()), + "/1.1/johndoe/storage/clients": upd("clients", clientsColl.handler()), + + // Credentials are wrong or node reallocated. + "/1.1/janedoe/storage/meta/global": handler_401, + "/1.1/janedoe/info/collections": handler_401, + + // Maintenance or overloaded (503 + Retry-After) at info/collections. + "/maintenance/1.1/broken.info/info/collections": EHTestsCommon.service_unavailable, + + // Maintenance or overloaded (503 + Retry-After) at meta/global. + "/maintenance/1.1/broken.meta/storage/meta/global": EHTestsCommon.service_unavailable, + "/maintenance/1.1/broken.meta/info/collections": collectionsHelper.handler, + + // Maintenance or overloaded (503 + Retry-After) at crypto/keys. + "/maintenance/1.1/broken.keys/storage/meta/global": upd("meta", global.handler()), + "/maintenance/1.1/broken.keys/info/collections": collectionsHelper.handler, + "/maintenance/1.1/broken.keys/storage/crypto/keys": EHTestsCommon.service_unavailable, + + // Maintenance or overloaded (503 + Retry-After) at wiping collection. + "/maintenance/1.1/broken.wipe/info/collections": collectionsHelper.handler, + "/maintenance/1.1/broken.wipe/storage/meta/global": upd("meta", global.handler()), + "/maintenance/1.1/broken.wipe/storage/crypto/keys": + upd("crypto", (new ServerWBO("keys")).handler()), + "/maintenance/1.1/broken.wipe/storage": EHTestsCommon.service_unavailable, + "/maintenance/1.1/broken.wipe/storage/clients": upd("clients", clientsColl.handler()), + "/maintenance/1.1/broken.wipe/storage/catapult": EHTestsCommon.service_unavailable + }); + }, + + CatapultEngine: (function() { + function CatapultEngine() { + SyncEngine.call(this, "Catapult", Service); + } + CatapultEngine.prototype = { + __proto__: SyncEngine.prototype, + exception: null, // tests fill this in + _sync: function _sync() { + if (this.exception) { + throw this.exception; + } + } + }; + + return CatapultEngine; + }()), + + + generateCredentialsChangedFailure() { + // Make sync fail due to changed credentials. We simply re-encrypt + // the keys with a different Sync Key, without changing the local one. + let newSyncKeyBundle = new SyncKeyBundle("johndoe", "23456234562345623456234562"); + let keys = Service.collectionKeys.asWBO(); + keys.encrypt(newSyncKeyBundle); + keys.upload(Service.resource(Service.cryptoKeysURL)); + }, + + setUp(server) { + return configureIdentity({ username: "johndoe" }).then( + () => { + Service.serverURL = server.baseURI + "/"; + Service.clusterURL = server.baseURI + "/"; + } + ).then( + () => EHTestsCommon.generateAndUploadKeys() + ); + }, + + generateAndUploadKeys() { + generateNewKeys(Service.collectionKeys); + let serverKeys = Service.collectionKeys.asWBO("crypto", "keys"); + serverKeys.encrypt(Service.identity.syncKeyBundle); + return serverKeys.upload(Service.resource(Service.cryptoKeysURL)).success; + } +}; diff --git a/services/sync/tests/unit/head_helpers.js b/services/sync/tests/unit/head_helpers.js new file mode 100644 index 0000000000..3c59e1de55 --- /dev/null +++ b/services/sync/tests/unit/head_helpers.js @@ -0,0 +1,446 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +Cu.import("resource://services-common/async.js"); +Cu.import("resource://testing-common/services/common/utils.js"); +Cu.import("resource://testing-common/PlacesTestUtils.jsm"); +Cu.import("resource://services-sync/util.js"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); + +XPCOMUtils.defineLazyGetter(this, 'SyncPingSchema', function() { + let ns = {}; + Cu.import("resource://gre/modules/FileUtils.jsm", ns); + let stream = Cc["@mozilla.org/network/file-input-stream;1"] + .createInstance(Ci.nsIFileInputStream); + let jsonReader = Cc["@mozilla.org/dom/json;1"] + .createInstance(Components.interfaces.nsIJSON); + let schema; + try { + let schemaFile = do_get_file("sync_ping_schema.json"); + stream.init(schemaFile, ns.FileUtils.MODE_RDONLY, ns.FileUtils.PERMS_FILE, 0); + schema = jsonReader.decodeFromStream(stream, stream.available()); + } finally { + stream.close(); + } + + // Allow tests to make whatever engines they want, this shouldn't cause + // validation failure. + schema.definitions.engine.properties.name = { type: "string" }; + return schema; +}); + +XPCOMUtils.defineLazyGetter(this, 'SyncPingValidator', function() { + let ns = {}; + Cu.import("resource://testing-common/ajv-4.1.1.js", ns); + let ajv = new ns.Ajv({ async: "co*" }); + return ajv.compile(SyncPingSchema); +}); + +var provider = { + getFile: function(prop, persistent) { + persistent.value = true; + switch (prop) { + case "ExtPrefDL": + return [Services.dirsvc.get("CurProcD", Ci.nsIFile)]; + default: + throw Cr.NS_ERROR_FAILURE; + } + }, + QueryInterface: XPCOMUtils.generateQI([Ci.nsIDirectoryServiceProvider]) +}; +Services.dirsvc.QueryInterface(Ci.nsIDirectoryService).registerProvider(provider); + +// This is needed for loadAddonTestFunctions(). +var gGlobalScope = this; + +function ExtensionsTestPath(path) { + if (path[0] != "/") { + throw Error("Path must begin with '/': " + path); + } + + return "../../../../toolkit/mozapps/extensions/test/xpcshell" + path; +} + +/** + * Loads the AddonManager test functions by importing its test file. + * + * This should be called in the global scope of any test file needing to + * interface with the AddonManager. It should only be called once, or the + * universe will end. + */ +function loadAddonTestFunctions() { + const path = ExtensionsTestPath("/head_addons.js"); + let file = do_get_file(path); + let uri = Services.io.newFileURI(file); + Services.scriptloader.loadSubScript(uri.spec, gGlobalScope); + createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1.9.2"); +} + +function webExtensionsTestPath(path) { + if (path[0] != "/") { + throw Error("Path must begin with '/': " + path); + } + + return "../../../../toolkit/components/extensions/test/xpcshell" + path; +} + +/** + * Loads the WebExtension test functions by importing its test file. + */ +function loadWebExtensionTestFunctions() { + const path = webExtensionsTestPath("/head_sync.js"); + let file = do_get_file(path); + let uri = Services.io.newFileURI(file); + Services.scriptloader.loadSubScript(uri.spec, gGlobalScope); +} + +function getAddonInstall(name) { + let f = do_get_file(ExtensionsTestPath("/addons/" + name + ".xpi")); + let cb = Async.makeSyncCallback(); + AddonManager.getInstallForFile(f, cb); + + return Async.waitForSyncCallback(cb); +} + +/** + * Obtains an addon from the add-on manager by id. + * + * This is merely a synchronous wrapper. + * + * @param id + * ID of add-on to fetch + * @return addon object on success or undefined or null on failure + */ +function getAddonFromAddonManagerByID(id) { + let cb = Async.makeSyncCallback(); + AddonManager.getAddonByID(id, cb); + return Async.waitForSyncCallback(cb); +} + +/** + * Installs an add-on synchronously from an addonInstall + * + * @param install addonInstall instance to install + */ +function installAddonFromInstall(install) { + let cb = Async.makeSyncCallback(); + let listener = {onInstallEnded: cb}; + AddonManager.addInstallListener(listener); + install.install(); + Async.waitForSyncCallback(cb); + AddonManager.removeAddonListener(listener); + + do_check_neq(null, install.addon); + do_check_neq(null, install.addon.syncGUID); + + return install.addon; +} + +/** + * Convenience function to install an add-on from the extensions unit tests. + * + * @param name + * String name of add-on to install. e.g. test_install1 + * @return addon object that was installed + */ +function installAddon(name) { + let install = getAddonInstall(name); + do_check_neq(null, install); + return installAddonFromInstall(install); +} + +/** + * Convenience function to uninstall an add-on synchronously. + * + * @param addon + * Addon instance to uninstall + */ +function uninstallAddon(addon) { + let cb = Async.makeSyncCallback(); + let listener = {onUninstalled: function(uninstalled) { + if (uninstalled.id == addon.id) { + AddonManager.removeAddonListener(listener); + cb(uninstalled); + } + }}; + + AddonManager.addAddonListener(listener); + addon.uninstall(); + Async.waitForSyncCallback(cb); +} + +function generateNewKeys(collectionKeys, collections=null) { + let wbo = collectionKeys.generateNewKeysWBO(collections); + let modified = new_timestamp(); + collectionKeys.setContents(wbo.cleartext, modified); +} + +// Helpers for testing open tabs. +// These reflect part of the internal structure of TabEngine, +// and stub part of Service.wm. + +function mockShouldSkipWindow (win) { + return win.closed || + win.mockIsPrivate; +} + +function mockGetTabState (tab) { + return tab; +} + +function mockGetWindowEnumerator(url, numWindows, numTabs, indexes, moreURLs) { + let elements = []; + + function url2entry(url) { + return { + url: ((typeof url == "function") ? url() : url), + title: "title" + }; + } + + for (let w = 0; w < numWindows; ++w) { + let tabs = []; + let win = { + closed: false, + mockIsPrivate: false, + gBrowser: { + tabs: tabs, + }, + }; + elements.push(win); + + for (let t = 0; t < numTabs; ++t) { + tabs.push(TestingUtils.deepCopy({ + index: indexes ? indexes() : 1, + entries: (moreURLs ? [url].concat(moreURLs()) : [url]).map(url2entry), + attributes: { + image: "image" + }, + lastAccessed: 1499 + })); + } + } + + // Always include a closed window and a private window. + elements.push({ + closed: true, + mockIsPrivate: false, + gBrowser: { + tabs: [], + }, + }); + + elements.push({ + closed: false, + mockIsPrivate: true, + gBrowser: { + tabs: [], + }, + }); + + return { + hasMoreElements: function () { + return elements.length; + }, + getNext: function () { + return elements.shift(); + }, + }; +} + +// Helper that allows checking array equality. +function do_check_array_eq(a1, a2) { + do_check_eq(a1.length, a2.length); + for (let i = 0; i < a1.length; ++i) { + do_check_eq(a1[i], a2[i]); + } +} + +// Helper function to get the sync telemetry and add the typically used test +// engine names to its list of allowed engines. +function get_sync_test_telemetry() { + let ns = {}; + Cu.import("resource://services-sync/telemetry.js", ns); + let testEngines = ["rotary", "steam", "sterling", "catapult"]; + for (let engineName of testEngines) { + ns.SyncTelemetry.allowedEngines.add(engineName); + } + ns.SyncTelemetry.submissionInterval = -1; + return ns.SyncTelemetry; +} + +function assert_valid_ping(record) { + // This is called as the test harness tears down due to shutdown. This + // will typically have no recorded syncs, and the validator complains about + // it. So ignore such records (but only ignore when *both* shutdown and + // no Syncs - either of them not being true might be an actual problem) + if (record && (record.why != "shutdown" || record.syncs.length != 0)) { + if (!SyncPingValidator(record)) { + deepEqual([], SyncPingValidator.errors, "Sync telemetry ping validation failed"); + } + equal(record.version, 1); + record.syncs.forEach(p => { + lessOrEqual(p.when, Date.now()); + if (p.devices) { + ok(!p.devices.some(device => device.id == p.deviceID)); + equal(new Set(p.devices.map(device => device.id)).size, + p.devices.length, "Duplicate device ids in ping devices list"); + } + }); + } +} + +// Asserts that `ping` is a ping that doesn't contain any failure information +function assert_success_ping(ping) { + ok(!!ping); + assert_valid_ping(ping); + ping.syncs.forEach(record => { + ok(!record.failureReason); + equal(undefined, record.status); + greater(record.engines.length, 0); + for (let e of record.engines) { + ok(!e.failureReason); + equal(undefined, e.status); + if (e.validation) { + equal(undefined, e.validation.problems); + equal(undefined, e.validation.failureReason); + } + if (e.outgoing) { + for (let o of e.outgoing) { + equal(undefined, o.failed); + notEqual(undefined, o.sent); + } + } + if (e.incoming) { + equal(undefined, e.incoming.failed); + equal(undefined, e.incoming.newFailed); + notEqual(undefined, e.incoming.applied || e.incoming.reconciled); + } + } + }); +} + +// Hooks into telemetry to validate all pings after calling. +function validate_all_future_pings() { + let telem = get_sync_test_telemetry(); + telem.submit = assert_valid_ping; +} + +function wait_for_ping(callback, allowErrorPings, getFullPing = false) { + return new Promise(resolve => { + let telem = get_sync_test_telemetry(); + let oldSubmit = telem.submit; + telem.submit = function(record) { + telem.submit = oldSubmit; + if (allowErrorPings) { + assert_valid_ping(record); + } else { + assert_success_ping(record); + } + if (getFullPing) { + resolve(record); + } else { + equal(record.syncs.length, 1); + resolve(record.syncs[0]); + } + }; + callback(); + }); +} + +// Short helper for wait_for_ping +function sync_and_validate_telem(allowErrorPings, getFullPing = false) { + return wait_for_ping(() => Service.sync(), allowErrorPings, getFullPing); +} + +// Used for the (many) cases where we do a 'partial' sync, where only a single +// engine is actually synced, but we still want to ensure we're generating a +// valid ping. Returns a promise that resolves to the ping, or rejects with the +// thrown error after calling an optional callback. +function sync_engine_and_validate_telem(engine, allowErrorPings, onError) { + return new Promise((resolve, reject) => { + let telem = get_sync_test_telemetry(); + let caughtError = null; + // Clear out status, so failures from previous syncs won't show up in the + // telemetry ping. + let ns = {}; + Cu.import("resource://services-sync/status.js", ns); + ns.Status._engines = {}; + ns.Status.partial = false; + // Ideally we'd clear these out like we do with engines, (probably via + // Status.resetSync()), but this causes *numerous* tests to fail, so we just + // assume that if no failureReason or engine failures are set, and the + // status properties are the same as they were initially, that it's just + // a leftover. + // This is only an issue since we're triggering the sync of just one engine, + // without doing any other parts of the sync. + let initialServiceStatus = ns.Status._service; + let initialSyncStatus = ns.Status._sync; + + let oldSubmit = telem.submit; + telem.submit = function(ping) { + telem.submit = oldSubmit; + ping.syncs.forEach(record => { + if (record && record.status) { + // did we see anything to lead us to believe that something bad actually happened + let realProblem = record.failureReason || record.engines.some(e => { + if (e.failureReason || e.status) { + return true; + } + if (e.outgoing && e.outgoing.some(o => o.failed > 0)) { + return true; + } + return e.incoming && e.incoming.failed; + }); + if (!realProblem) { + // no, so if the status is the same as it was initially, just assume + // that its leftover and that we can ignore it. + if (record.status.sync && record.status.sync == initialSyncStatus) { + delete record.status.sync; + } + if (record.status.service && record.status.service == initialServiceStatus) { + delete record.status.service; + } + if (!record.status.sync && !record.status.service) { + delete record.status; + } + } + } + }); + if (allowErrorPings) { + assert_valid_ping(ping); + } else { + assert_success_ping(ping); + } + equal(ping.syncs.length, 1); + if (caughtError) { + if (onError) { + onError(ping.syncs[0]); + } + reject(caughtError); + } else { + resolve(ping.syncs[0]); + } + } + Svc.Obs.notify("weave:service:sync:start"); + try { + engine.sync(); + } catch (e) { + caughtError = e; + } + if (caughtError) { + Svc.Obs.notify("weave:service:sync:error", caughtError); + } else { + Svc.Obs.notify("weave:service:sync:finish"); + } + }); +} + +// Avoid an issue where `client.name2` containing unicode characters causes +// a number of tests to fail, due to them assuming that we do not need to utf-8 +// encode or decode data sent through the mocked server (see bug 1268912). +Utils.getDefaultDeviceName = function() { + return "Test device name"; +}; + + diff --git a/services/sync/tests/unit/head_http_server.js b/services/sync/tests/unit/head_http_server.js new file mode 100644 index 0000000000..26f62310c3 --- /dev/null +++ b/services/sync/tests/unit/head_http_server.js @@ -0,0 +1,1044 @@ +var Cm = Components.manager; + +// Shared logging for all HTTP server functions. +Cu.import("resource://gre/modules/Log.jsm"); +const SYNC_HTTP_LOGGER = "Sync.Test.Server"; +const SYNC_API_VERSION = "1.1"; + +// Use the same method that record.js does, which mirrors the server. +// The server returns timestamps with 1/100 sec granularity. Note that this is +// subject to change: see Bug 650435. +function new_timestamp() { + return Math.round(Date.now() / 10) / 100; +} + +function return_timestamp(request, response, timestamp) { + if (!timestamp) { + timestamp = new_timestamp(); + } + let body = "" + timestamp; + response.setHeader("X-Weave-Timestamp", body); + response.setStatusLine(request.httpVersion, 200, "OK"); + response.bodyOutputStream.write(body, body.length); + return timestamp; +} + +function basic_auth_header(user, password) { + return "Basic " + btoa(user + ":" + Utils.encodeUTF8(password)); +} + +function basic_auth_matches(req, user, password) { + if (!req.hasHeader("Authorization")) { + return false; + } + + let expected = basic_auth_header(user, Utils.encodeUTF8(password)); + return req.getHeader("Authorization") == expected; +} + +function httpd_basic_auth_handler(body, metadata, response) { + if (basic_auth_matches(metadata, "guest", "guest")) { + response.setStatusLine(metadata.httpVersion, 200, "OK, authorized"); + response.setHeader("WWW-Authenticate", 'Basic realm="secret"', false); + } else { + body = "This path exists and is protected - failed"; + response.setStatusLine(metadata.httpVersion, 401, "Unauthorized"); + response.setHeader("WWW-Authenticate", 'Basic realm="secret"', false); + } + response.bodyOutputStream.write(body, body.length); +} + +/* + * Represent a WBO on the server + */ +function ServerWBO(id, initialPayload, modified) { + if (!id) { + throw "No ID for ServerWBO!"; + } + this.id = id; + if (!initialPayload) { + return; + } + + if (typeof initialPayload == "object") { + initialPayload = JSON.stringify(initialPayload); + } + this.payload = initialPayload; + this.modified = modified || new_timestamp(); +} +ServerWBO.prototype = { + + get data() { + return JSON.parse(this.payload); + }, + + get: function() { + return JSON.stringify(this, ["id", "modified", "payload"]); + }, + + put: function(input) { + input = JSON.parse(input); + this.payload = input.payload; + this.modified = new_timestamp(); + }, + + delete: function() { + delete this.payload; + delete this.modified; + }, + + // This handler sets `newModified` on the response body if the collection + // timestamp has changed. This allows wrapper handlers to extract information + // that otherwise would exist only in the body stream. + handler: function() { + let self = this; + + return function(request, response) { + var statusCode = 200; + var status = "OK"; + var body; + + switch(request.method) { + case "GET": + if (self.payload) { + body = self.get(); + } else { + statusCode = 404; + status = "Not Found"; + body = "Not Found"; + } + break; + + case "PUT": + self.put(readBytesFromInputStream(request.bodyInputStream)); + body = JSON.stringify(self.modified); + response.setHeader("Content-Type", "application/json"); + response.newModified = self.modified; + break; + + case "DELETE": + self.delete(); + let ts = new_timestamp(); + body = JSON.stringify(ts); + response.setHeader("Content-Type", "application/json"); + response.newModified = ts; + break; + } + response.setHeader("X-Weave-Timestamp", "" + new_timestamp(), false); + response.setStatusLine(request.httpVersion, statusCode, status); + response.bodyOutputStream.write(body, body.length); + }; + } + +}; + + +/** + * Represent a collection on the server. The '_wbos' attribute is a + * mapping of id -> ServerWBO objects. + * + * Note that if you want these records to be accessible individually, + * you need to register their handlers with the server separately, or use a + * containing HTTP server that will do so on your behalf. + * + * @param wbos + * An object mapping WBO IDs to ServerWBOs. + * @param acceptNew + * If true, POSTs to this collection URI will result in new WBOs being + * created and wired in on the fly. + * @param timestamp + * An optional timestamp value to initialize the modified time of the + * collection. This should be in the format returned by new_timestamp(). + * + * @return the new ServerCollection instance. + * + */ +function ServerCollection(wbos, acceptNew, timestamp) { + this._wbos = wbos || {}; + this.acceptNew = acceptNew || false; + + /* + * Track modified timestamp. + * We can't just use the timestamps of contained WBOs: an empty collection + * has a modified time. + */ + this.timestamp = timestamp || new_timestamp(); + this._log = Log.repository.getLogger(SYNC_HTTP_LOGGER); +} +ServerCollection.prototype = { + + /** + * Convenience accessor for our WBO keys. + * Excludes deleted items, of course. + * + * @param filter + * A predicate function (applied to the ID and WBO) which dictates + * whether to include the WBO's ID in the output. + * + * @return an array of IDs. + */ + keys: function keys(filter) { + let ids = []; + for (let [id, wbo] of Object.entries(this._wbos)) { + if (wbo.payload && (!filter || filter(id, wbo))) { + ids.push(id); + } + } + return ids; + }, + + /** + * Convenience method to get an array of WBOs. + * Optionally provide a filter function. + * + * @param filter + * A predicate function, applied to the WBO, which dictates whether to + * include the WBO in the output. + * + * @return an array of ServerWBOs. + */ + wbos: function wbos(filter) { + let os = []; + for (let [id, wbo] of Object.entries(this._wbos)) { + if (wbo.payload) { + os.push(wbo); + } + } + + if (filter) { + return os.filter(filter); + } + return os; + }, + + /** + * Convenience method to get an array of parsed ciphertexts. + * + * @return an array of the payloads of each stored WBO. + */ + payloads: function () { + return this.wbos().map(function (wbo) { + return JSON.parse(JSON.parse(wbo.payload).ciphertext); + }); + }, + + // Just for syntactic elegance. + wbo: function wbo(id) { + return this._wbos[id]; + }, + + payload: function payload(id) { + return this.wbo(id).payload; + }, + + /** + * Insert the provided WBO under its ID. + * + * @return the provided WBO. + */ + insertWBO: function insertWBO(wbo) { + return this._wbos[wbo.id] = wbo; + }, + + /** + * Insert the provided payload as part of a new ServerWBO with the provided + * ID. + * + * @param id + * The GUID for the WBO. + * @param payload + * The payload, as provided to the ServerWBO constructor. + * @param modified + * An optional modified time for the ServerWBO. + * + * @return the inserted WBO. + */ + insert: function insert(id, payload, modified) { + return this.insertWBO(new ServerWBO(id, payload, modified)); + }, + + /** + * Removes an object entirely from the collection. + * + * @param id + * (string) ID to remove. + */ + remove: function remove(id) { + delete this._wbos[id]; + }, + + _inResultSet: function(wbo, options) { + return wbo.payload + && (!options.ids || (options.ids.indexOf(wbo.id) != -1)) + && (!options.newer || (wbo.modified > options.newer)); + }, + + count: function(options) { + options = options || {}; + let c = 0; + for (let [id, wbo] of Object.entries(this._wbos)) { + if (wbo.modified && this._inResultSet(wbo, options)) { + c++; + } + } + return c; + }, + + get: function(options) { + let result; + if (options.full) { + let data = []; + for (let [id, wbo] of Object.entries(this._wbos)) { + // Drop deleted. + if (wbo.modified && this._inResultSet(wbo, options)) { + data.push(wbo.get()); + } + } + let start = options.offset || 0; + if (options.limit) { + let numItemsPastOffset = data.length - start; + data = data.slice(start, start + options.limit); + // use options as a backchannel to set x-weave-next-offset + if (numItemsPastOffset > options.limit) { + options.nextOffset = start + options.limit; + } + } else if (start) { + data = data.slice(start); + } + // Our implementation of application/newlines. + result = data.join("\n") + "\n"; + + // Use options as a backchannel to report count. + options.recordCount = data.length; + } else { + let data = []; + for (let [id, wbo] of Object.entries(this._wbos)) { + if (this._inResultSet(wbo, options)) { + data.push(id); + } + } + let start = options.offset || 0; + if (options.limit) { + data = data.slice(start, start + options.limit); + options.nextOffset = start + options.limit; + } else if (start) { + data = data.slice(start); + } + result = JSON.stringify(data); + options.recordCount = data.length; + } + return result; + }, + + post: function(input) { + input = JSON.parse(input); + let success = []; + let failed = {}; + + // This will count records where we have an existing ServerWBO + // registered with us as successful and all other records as failed. + for (let key in input) { + let record = input[key]; + let wbo = this.wbo(record.id); + if (!wbo && this.acceptNew) { + this._log.debug("Creating WBO " + JSON.stringify(record.id) + + " on the fly."); + wbo = new ServerWBO(record.id); + this.insertWBO(wbo); + } + if (wbo) { + wbo.payload = record.payload; + wbo.modified = new_timestamp(); + success.push(record.id); + } else { + failed[record.id] = "no wbo configured"; + } + } + return {modified: new_timestamp(), + success: success, + failed: failed}; + }, + + delete: function(options) { + let deleted = []; + for (let [id, wbo] of Object.entries(this._wbos)) { + if (this._inResultSet(wbo, options)) { + this._log.debug("Deleting " + JSON.stringify(wbo)); + deleted.push(wbo.id); + wbo.delete(); + } + } + return deleted; + }, + + // This handler sets `newModified` on the response body if the collection + // timestamp has changed. + handler: function() { + let self = this; + + return function(request, response) { + var statusCode = 200; + var status = "OK"; + var body; + + // Parse queryString + let options = {}; + for (let chunk of request.queryString.split("&")) { + if (!chunk) { + continue; + } + chunk = chunk.split("="); + if (chunk.length == 1) { + options[chunk[0]] = ""; + } else { + options[chunk[0]] = chunk[1]; + } + } + if (options.ids) { + options.ids = options.ids.split(","); + } + if (options.newer) { + options.newer = parseFloat(options.newer); + } + if (options.limit) { + options.limit = parseInt(options.limit, 10); + } + if (options.offset) { + options.offset = parseInt(options.offset, 10); + } + + switch(request.method) { + case "GET": + body = self.get(options, request); + // see http://moz-services-docs.readthedocs.io/en/latest/storage/apis-1.5.html + // for description of these headers. + let { recordCount: records, nextOffset } = options; + + self._log.info("Records: " + records + ", nextOffset: " + nextOffset); + if (records != null) { + response.setHeader("X-Weave-Records", "" + records); + } + if (nextOffset) { + response.setHeader("X-Weave-Next-Offset", "" + nextOffset); + } + response.setHeader("X-Last-Modified", "" + this.timestamp); + break; + + case "POST": + let res = self.post(readBytesFromInputStream(request.bodyInputStream), request); + body = JSON.stringify(res); + response.newModified = res.modified; + break; + + case "DELETE": + self._log.debug("Invoking ServerCollection.DELETE."); + let deleted = self.delete(options, request); + let ts = new_timestamp(); + body = JSON.stringify(ts); + response.newModified = ts; + response.deleted = deleted; + break; + } + response.setHeader("X-Weave-Timestamp", + "" + new_timestamp(), + false); + response.setStatusLine(request.httpVersion, statusCode, status); + response.bodyOutputStream.write(body, body.length); + + // Update the collection timestamp to the appropriate modified time. + // This is either a value set by the handler, or the current time. + if (request.method != "GET") { + this.timestamp = (response.newModified >= 0) ? + response.newModified : + new_timestamp(); + } + }; + } + +}; + +/* + * Test setup helpers. + */ +function sync_httpd_setup(handlers) { + handlers["/1.1/foo/storage/meta/global"] + = (new ServerWBO("global", {})).handler(); + return httpd_setup(handlers); +} + +/* + * Track collection modified times. Return closures. + */ +function track_collections_helper() { + + /* + * Our tracking object. + */ + let collections = {}; + + /* + * Update the timestamp of a collection. + */ + function update_collection(coll, ts) { + _("Updating collection " + coll + " to " + ts); + let timestamp = ts || new_timestamp(); + collections[coll] = timestamp; + } + + /* + * Invoke a handler, updating the collection's modified timestamp unless + * it's a GET request. + */ + function with_updated_collection(coll, f) { + return function(request, response) { + f.call(this, request, response); + + // Update the collection timestamp to the appropriate modified time. + // This is either a value set by the handler, or the current time. + if (request.method != "GET") { + update_collection(coll, response.newModified) + } + }; + } + + /* + * Return the info/collections object. + */ + function info_collections(request, response) { + let body = "Error."; + switch(request.method) { + case "GET": + body = JSON.stringify(collections); + break; + default: + throw "Non-GET on info_collections."; + } + + response.setHeader("Content-Type", "application/json"); + response.setHeader("X-Weave-Timestamp", + "" + new_timestamp(), + false); + response.setStatusLine(request.httpVersion, 200, "OK"); + response.bodyOutputStream.write(body, body.length); + } + + return {"collections": collections, + "handler": info_collections, + "with_updated_collection": with_updated_collection, + "update_collection": update_collection}; +} + +//===========================================================================// +// httpd.js-based Sync server. // +//===========================================================================// + +/** + * In general, the preferred way of using SyncServer is to directly introspect + * it. Callbacks are available for operations which are hard to verify through + * introspection, such as deletions. + * + * One of the goals of this server is to provide enough hooks for test code to + * find out what it needs without monkeypatching. Use this object as your + * prototype, and override as appropriate. + */ +var SyncServerCallback = { + onCollectionDeleted: function onCollectionDeleted(user, collection) {}, + onItemDeleted: function onItemDeleted(user, collection, wboID) {}, + + /** + * Called at the top of every request. + * + * Allows the test to inspect the request. Hooks should be careful not to + * modify or change state of the request or they may impact future processing. + * The response is also passed so the callback can set headers etc - but care + * must be taken to not screw with the response body or headers that may + * conflict with normal operation of this server. + */ + onRequest: function onRequest(request, response) {}, +}; + +/** + * Construct a new test Sync server. Takes a callback object (e.g., + * SyncServerCallback) as input. + */ +function SyncServer(callback) { + this.callback = callback || {__proto__: SyncServerCallback}; + this.server = new HttpServer(); + this.started = false; + this.users = {}; + this._log = Log.repository.getLogger(SYNC_HTTP_LOGGER); + + // Install our own default handler. This allows us to mess around with the + // whole URL space. + let handler = this.server._handler; + handler._handleDefault = this.handleDefault.bind(this, handler); +} +SyncServer.prototype = { + server: null, // HttpServer. + users: null, // Map of username => {collections, password}. + + /** + * Start the SyncServer's underlying HTTP server. + * + * @param port + * The numeric port on which to start. -1 implies the default, a + * randomly chosen port. + * @param cb + * A callback function (of no arguments) which is invoked after + * startup. + */ + start: function start(port = -1, cb) { + if (this.started) { + this._log.warn("Warning: server already started on " + this.port); + return; + } + try { + this.server.start(port); + let i = this.server.identity; + this.port = i.primaryPort; + this.baseURI = i.primaryScheme + "://" + i.primaryHost + ":" + + i.primaryPort + "/"; + this.started = true; + if (cb) { + cb(); + } + } catch (ex) { + _("=========================================="); + _("Got exception starting Sync HTTP server."); + _("Error: " + Log.exceptionStr(ex)); + _("Is there a process already listening on port " + port + "?"); + _("=========================================="); + do_throw(ex); + } + + }, + + /** + * Stop the SyncServer's HTTP server. + * + * @param cb + * A callback function. Invoked after the server has been stopped. + * + */ + stop: function stop(cb) { + if (!this.started) { + this._log.warn("SyncServer: Warning: server not running. Can't stop me now!"); + return; + } + + this.server.stop(cb); + this.started = false; + }, + + /** + * Return a server timestamp for a record. + * The server returns timestamps with 1/100 sec granularity. Note that this is + * subject to change: see Bug 650435. + */ + timestamp: function timestamp() { + return new_timestamp(); + }, + + /** + * Create a new user, complete with an empty set of collections. + * + * @param username + * The username to use. An Error will be thrown if a user by that name + * already exists. + * @param password + * A password string. + * + * @return a user object, as would be returned by server.user(username). + */ + registerUser: function registerUser(username, password) { + if (username in this.users) { + throw new Error("User already exists."); + } + this.users[username] = { + password: password, + collections: {} + }; + return this.user(username); + }, + + userExists: function userExists(username) { + return username in this.users; + }, + + getCollection: function getCollection(username, collection) { + return this.users[username].collections[collection]; + }, + + _insertCollection: function _insertCollection(collections, collection, wbos) { + let coll = new ServerCollection(wbos, true); + coll.collectionHandler = coll.handler(); + collections[collection] = coll; + return coll; + }, + + createCollection: function createCollection(username, collection, wbos) { + if (!(username in this.users)) { + throw new Error("Unknown user."); + } + let collections = this.users[username].collections; + if (collection in collections) { + throw new Error("Collection already exists."); + } + return this._insertCollection(collections, collection, wbos); + }, + + /** + * Accept a map like the following: + * { + * meta: {global: {version: 1, ...}}, + * crypto: {"keys": {}, foo: {bar: 2}}, + * bookmarks: {} + * } + * to cause collections and WBOs to be created. + * If a collection already exists, no error is raised. + * If a WBO already exists, it will be updated to the new contents. + */ + createContents: function createContents(username, collections) { + if (!(username in this.users)) { + throw new Error("Unknown user."); + } + let userCollections = this.users[username].collections; + for (let [id, contents] of Object.entries(collections)) { + let coll = userCollections[id] || + this._insertCollection(userCollections, id); + for (let [wboID, payload] of Object.entries(contents)) { + coll.insert(wboID, payload); + } + } + }, + + /** + * Insert a WBO in an existing collection. + */ + insertWBO: function insertWBO(username, collection, wbo) { + if (!(username in this.users)) { + throw new Error("Unknown user."); + } + let userCollections = this.users[username].collections; + if (!(collection in userCollections)) { + throw new Error("Unknown collection."); + } + userCollections[collection].insertWBO(wbo); + return wbo; + }, + + /** + * Delete all of the collections for the named user. + * + * @param username + * The name of the affected user. + * + * @return a timestamp. + */ + deleteCollections: function deleteCollections(username) { + if (!(username in this.users)) { + throw new Error("Unknown user."); + } + let userCollections = this.users[username].collections; + for (let name in userCollections) { + let coll = userCollections[name]; + this._log.trace("Bulk deleting " + name + " for " + username + "..."); + coll.delete({}); + } + this.users[username].collections = {}; + return this.timestamp(); + }, + + /** + * Simple accessor to allow collective binding and abbreviation of a bunch of + * methods. Yay! + * Use like this: + * + * let u = server.user("john"); + * u.collection("bookmarks").wbo("abcdefg").payload; // Etc. + * + * @return a proxy for the user data stored in this server. + */ + user: function user(username) { + let collection = this.getCollection.bind(this, username); + let createCollection = this.createCollection.bind(this, username); + let createContents = this.createContents.bind(this, username); + let modified = function (collectionName) { + return collection(collectionName).timestamp; + } + let deleteCollections = this.deleteCollections.bind(this, username); + return { + collection: collection, + createCollection: createCollection, + createContents: createContents, + deleteCollections: deleteCollections, + modified: modified + }; + }, + + /* + * Regular expressions for splitting up Sync request paths. + * Sync URLs are of the form: + * /$apipath/$version/$user/$further + * where $further is usually: + * storage/$collection/$wbo + * or + * storage/$collection + * or + * info/$op + * We assume for the sake of simplicity that $apipath is empty. + * + * N.B., we don't follow any kind of username spec here, because as far as I + * can tell there isn't one. See Bug 689671. Instead we follow the Python + * server code. + * + * Path: [all, version, username, first, rest] + * Storage: [all, collection?, id?] + */ + pathRE: /^\/([0-9]+(?:\.[0-9]+)?)\/([-._a-zA-Z0-9]+)(?:\/([^\/]+)(?:\/(.+))?)?$/, + storageRE: /^([-_a-zA-Z0-9]+)(?:\/([-_a-zA-Z0-9]+)\/?)?$/, + + defaultHeaders: {}, + + /** + * HTTP response utility. + */ + respond: function respond(req, resp, code, status, body, headers) { + resp.setStatusLine(req.httpVersion, code, status); + if (!headers) + headers = this.defaultHeaders; + for (let header in headers) { + let value = headers[header]; + resp.setHeader(header, value); + } + resp.setHeader("X-Weave-Timestamp", "" + this.timestamp(), false); + resp.bodyOutputStream.write(body, body.length); + }, + + /** + * This is invoked by the HttpServer. `this` is bound to the SyncServer; + * `handler` is the HttpServer's handler. + * + * TODO: need to use the correct Sync API response codes and errors here. + * TODO: Basic Auth. + * TODO: check username in path against username in BasicAuth. + */ + handleDefault: function handleDefault(handler, req, resp) { + try { + this._handleDefault(handler, req, resp); + } catch (e) { + if (e instanceof HttpError) { + this.respond(req, resp, e.code, e.description, "", {}); + } else { + throw e; + } + } + }, + + _handleDefault: function _handleDefault(handler, req, resp) { + this._log.debug("SyncServer: Handling request: " + req.method + " " + req.path); + + if (this.callback.onRequest) { + this.callback.onRequest(req, resp); + } + + let parts = this.pathRE.exec(req.path); + if (!parts) { + this._log.debug("SyncServer: Unexpected request: bad URL " + req.path); + throw HTTP_404; + } + + let [all, version, username, first, rest] = parts; + // Doing a float compare of the version allows for us to pretend there was + // a node-reassignment - eg, we could re-assign from "1.1/user/" to + // "1.10/user" - this server will then still accept requests with the new + // URL while any code in sync itself which compares URLs will see a + // different URL. + if (parseFloat(version) != parseFloat(SYNC_API_VERSION)) { + this._log.debug("SyncServer: Unknown version."); + throw HTTP_404; + } + + if (!this.userExists(username)) { + this._log.debug("SyncServer: Unknown user."); + throw HTTP_401; + } + + // Hand off to the appropriate handler for this path component. + if (first in this.toplevelHandlers) { + let handler = this.toplevelHandlers[first]; + return handler.call(this, handler, req, resp, version, username, rest); + } + this._log.debug("SyncServer: Unknown top-level " + first); + throw HTTP_404; + }, + + /** + * Compute the object that is returned for an info/collections request. + */ + infoCollections: function infoCollections(username) { + let responseObject = {}; + let colls = this.users[username].collections; + for (let coll in colls) { + responseObject[coll] = colls[coll].timestamp; + } + this._log.trace("SyncServer: info/collections returning " + + JSON.stringify(responseObject)); + return responseObject; + }, + + /** + * Collection of the handler methods we use for top-level path components. + */ + toplevelHandlers: { + "storage": function handleStorage(handler, req, resp, version, username, rest) { + let respond = this.respond.bind(this, req, resp); + if (!rest || !rest.length) { + this._log.debug("SyncServer: top-level storage " + + req.method + " request."); + + // TODO: verify if this is spec-compliant. + if (req.method != "DELETE") { + respond(405, "Method Not Allowed", "[]", {"Allow": "DELETE"}); + return undefined; + } + + // Delete all collections and track the timestamp for the response. + let timestamp = this.user(username).deleteCollections(); + + // Return timestamp and OK for deletion. + respond(200, "OK", JSON.stringify(timestamp)); + return undefined; + } + + let match = this.storageRE.exec(rest); + if (!match) { + this._log.warn("SyncServer: Unknown storage operation " + rest); + throw HTTP_404; + } + let [all, collection, wboID] = match; + let coll = this.getCollection(username, collection); + switch (req.method) { + case "GET": + if (!coll) { + if (wboID) { + respond(404, "Not found", "Not found"); + return undefined; + } + // *cries inside*: Bug 687299. + respond(200, "OK", "[]"); + return undefined; + } + if (!wboID) { + return coll.collectionHandler(req, resp); + } + let wbo = coll.wbo(wboID); + if (!wbo) { + respond(404, "Not found", "Not found"); + return undefined; + } + return wbo.handler()(req, resp); + + // TODO: implement handling of X-If-Unmodified-Since for write verbs. + case "DELETE": + if (!coll) { + respond(200, "OK", "{}"); + return undefined; + } + if (wboID) { + let wbo = coll.wbo(wboID); + if (wbo) { + wbo.delete(); + this.callback.onItemDeleted(username, collection, wboID); + } + respond(200, "OK", "{}"); + return undefined; + } + coll.collectionHandler(req, resp); + + // Spot if this is a DELETE for some IDs, and don't blow away the + // whole collection! + // + // We already handled deleting the WBOs by invoking the deleted + // collection's handler. However, in the case of + // + // DELETE storage/foobar + // + // we also need to remove foobar from the collections map. This + // clause tries to differentiate the above request from + // + // DELETE storage/foobar?ids=foo,baz + // + // and do the right thing. + // TODO: less hacky method. + if (-1 == req.queryString.indexOf("ids=")) { + // When you delete the entire collection, we drop it. + this._log.debug("Deleting entire collection."); + delete this.users[username].collections[collection]; + this.callback.onCollectionDeleted(username, collection); + } + + // Notify of item deletion. + let deleted = resp.deleted || []; + for (let i = 0; i < deleted.length; ++i) { + this.callback.onItemDeleted(username, collection, deleted[i]); + } + return undefined; + case "POST": + case "PUT": + if (!coll) { + coll = this.createCollection(username, collection); + } + if (wboID) { + let wbo = coll.wbo(wboID); + if (!wbo) { + this._log.trace("SyncServer: creating WBO " + collection + "/" + wboID); + wbo = coll.insert(wboID); + } + // Rather than instantiate each WBO's handler function, do it once + // per request. They get hit far less often than do collections. + wbo.handler()(req, resp); + coll.timestamp = resp.newModified; + return resp; + } + return coll.collectionHandler(req, resp); + default: + throw "Request method " + req.method + " not implemented."; + } + }, + + "info": function handleInfo(handler, req, resp, version, username, rest) { + switch (rest) { + case "collections": + let body = JSON.stringify(this.infoCollections(username)); + this.respond(req, resp, 200, "OK", body, { + "Content-Type": "application/json" + }); + return; + case "collection_usage": + case "collection_counts": + case "quota": + // TODO: implement additional info methods. + this.respond(req, resp, 200, "OK", "TODO"); + return; + default: + // TODO + this._log.warn("SyncServer: Unknown info operation " + rest); + throw HTTP_404; + } + } + } +}; + +/** + * Test helper. + */ +function serverForUsers(users, contents, callback) { + let server = new SyncServer(callback); + for (let [user, pass] of Object.entries(users)) { + server.registerUser(user, pass); + server.createContents(user, contents); + } + server.start(); + return server; +} diff --git a/services/sync/tests/unit/missing-sourceuri.xml b/services/sync/tests/unit/missing-sourceuri.xml new file mode 100644 index 0000000000..dbc83e17fe --- /dev/null +++ b/services/sync/tests/unit/missing-sourceuri.xml @@ -0,0 +1,27 @@ +<?xml version="1.0" encoding="UTF-8"?> +<searchresults total_results="1"> + <addon id="5617"> + <name>Restartless Test Extension</name> + <type id="1">Extension</type> + <guid>missing-sourceuri@tests.mozilla.org</guid> + <slug>missing-sourceuri</slug> + <version>1.0</version> + + <compatible_applications><application> + <name>Firefox</name> + <application_id>1</application_id> + <min_version>3.6</min_version> + <max_version>*</max_version> + <appID>{3e3ba16c-1675-4e88-b9c8-afef81b3d2ef}</appID> + </application></compatible_applications> + <all_compatible_os><os>ALL</os></all_compatible_os> + + <install os="ALL" size="485"></install> + <created epoch="1252903662"> + 2009-09-14T04:47:42Z + </created> + <last_updated epoch="1315255329"> + 2011-09-05T20:42:09Z + </last_updated> +</addon> +</searchresults> diff --git a/services/sync/tests/unit/missing-xpi-search.xml b/services/sync/tests/unit/missing-xpi-search.xml new file mode 100644 index 0000000000..9b547cdb36 --- /dev/null +++ b/services/sync/tests/unit/missing-xpi-search.xml @@ -0,0 +1,27 @@ +<?xml version="1.0" encoding="UTF-8"?> +<searchresults total_results="1"> + <addon id="5617"> + <name>Restartless Test Extension</name> + <type id="1">Extension</type> + <guid>missing-xpi@tests.mozilla.org</guid> + <slug>missing-xpi</slug> + <version>1.0</version> + + <compatible_applications><application> + <name>Firefox</name> + <application_id>1</application_id> + <min_version>3.6</min_version> + <max_version>*</max_version> + <appID>{3e3ba16c-1675-4e88-b9c8-afef81b3d2ef}</appID> + </application></compatible_applications> + <all_compatible_os><os>ALL</os></all_compatible_os> + + <install os="ALL" size="485">http://127.0.0.1:8888/THIS_DOES_NOT_EXIST.xpi</install> + <created epoch="1252903662"> + 2009-09-14T04:47:42Z + </created> + <last_updated epoch="1315255329"> + 2011-09-05T20:42:09Z + </last_updated> + </addon> +</searchresults> diff --git a/services/sync/tests/unit/places_v10_from_v11.sqlite b/services/sync/tests/unit/places_v10_from_v11.sqlite Binary files differnew file mode 100644 index 0000000000..e3f9ef4463 --- /dev/null +++ b/services/sync/tests/unit/places_v10_from_v11.sqlite diff --git a/services/sync/tests/unit/prefs_test_prefs_store.js b/services/sync/tests/unit/prefs_test_prefs_store.js new file mode 100644 index 0000000000..109757a35c --- /dev/null +++ b/services/sync/tests/unit/prefs_test_prefs_store.js @@ -0,0 +1,25 @@ +// This is a "preferences" file used by test_prefs_store.js + +// The prefs that control what should be synced. +// Most of these are "default" prefs, so the value itself will not sync. +pref("services.sync.prefs.sync.testing.int", true); +pref("services.sync.prefs.sync.testing.string", true); +pref("services.sync.prefs.sync.testing.bool", true); +pref("services.sync.prefs.sync.testing.dont.change", true); +// this one is a user pref, so it *will* sync. +user_pref("services.sync.prefs.sync.testing.turned.off", false); +pref("services.sync.prefs.sync.testing.nonexistent", true); +pref("services.sync.prefs.sync.testing.default", true); + +// The preference values - these are all user_prefs, otherwise their value +// will not be synced. +user_pref("testing.int", 123); +user_pref("testing.string", "ohai"); +user_pref("testing.bool", true); +user_pref("testing.dont.change", "Please don't change me."); +user_pref("testing.turned.off", "I won't get synced."); +user_pref("testing.not.turned.on", "I won't get synced either!"); + +// A pref that exists but still has the default value - will be synced with +// null as the value. +pref("testing.default", "I'm the default value"); diff --git a/services/sync/tests/unit/rewrite-search.xml b/services/sync/tests/unit/rewrite-search.xml new file mode 100644 index 0000000000..15476b1abf --- /dev/null +++ b/services/sync/tests/unit/rewrite-search.xml @@ -0,0 +1,27 @@ +<?xml version="1.0" encoding="UTF-8"?> +<searchresults total_results="1"> + <addon id="5617"> + <name>Rewrite Test Extension</name> + <type id="1">Extension</type> + <guid>rewrite@tests.mozilla.org</guid> + <slug>rewrite</slug> + <version>1.0</version> + + <compatible_applications><application> + <name>Firefox</name> + <application_id>1</application_id> + <min_version>3.6</min_version> + <max_version>*</max_version> + <appID>xpcshell@tests.mozilla.org</appID> + </application></compatible_applications> + <all_compatible_os><os>ALL</os></all_compatible_os> + + <install os="ALL" size="485">http://127.0.0.1:8888/require.xpi?src=api</install> + <created epoch="1252903662"> + 2009-09-14T04:47:42Z + </created> + <last_updated epoch="1315255329"> + 2011-09-05T20:42:09Z + </last_updated> + </addon> +</searchresults> diff --git a/services/sync/tests/unit/sync_ping_schema.json b/services/sync/tests/unit/sync_ping_schema.json new file mode 100644 index 0000000000..56114fb93e --- /dev/null +++ b/services/sync/tests/unit/sync_ping_schema.json @@ -0,0 +1,198 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "description": "schema for Sync pings, documentation avaliable in toolkit/components/telemetry/docs/sync-ping.rst", + "type": "object", + "additionalProperties": false, + "required": ["version", "syncs", "why"], + "properties": { + "version": { "type": "integer", "minimum": 0 }, + "discarded": { "type": "integer", "minimum": 1 }, + "why": { "enum": ["shutdown", "schedule"] }, + "syncs": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/definitions/payload" } + } + }, + "definitions": { + "payload": { + "type": "object", + "additionalProperties": false, + "required": ["when", "uid", "took"], + "properties": { + "didLogin": { "type": "boolean" }, + "when": { "type": "integer" }, + "uid": { + "type": "string", + "pattern": "^[0-9a-f]{32}$" + }, + "devices": { + "type": "array", + "items": { "$ref": "#/definitions/device" } + }, + "deviceID": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "status": { + "type": "object", + "anyOf": [ + { "required": ["sync"] }, + { "required": ["service"] } + ], + "additionalProperties": false, + "properties": { + "sync": { "type": "string" }, + "service": { "type": "string" } + } + }, + "why": { "enum": ["startup", "schedule", "score", "user", "tabs"] }, + "took": { "type": "integer", "minimum": -1 }, + "failureReason": { "$ref": "#/definitions/error" }, + "engines": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/definitions/engine" } + } + } + }, + "device": { + "required": ["os", "id", "version"], + "additionalProperties": false, + "type": "object", + "properties": { + "id": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "os": { "type": "string" }, + "version": { "type": "string" } + } + }, + "engine": { + "required": ["name"], + "additionalProperties": false, + "properties": { + "failureReason": { "$ref": "#/definitions/error" }, + "name": { "enum": ["addons", "bookmarks", "clients", "forms", "history", "passwords", "prefs", "tabs"] }, + "took": { "type": "integer", "minimum": 1 }, + "status": { "type": "string" }, + "incoming": { + "type": "object", + "additionalProperties": false, + "anyOf": [ + {"required": ["applied"]}, + {"required": ["failed"]}, + {"required": ["newFailed"]}, + {"required": ["reconciled"]} + ], + "properties": { + "applied": { "type": "integer", "minimum": 1 }, + "failed": { "type": "integer", "minimum": 1 }, + "newFailed": { "type": "integer", "minimum": 1 }, + "reconciled": { "type": "integer", "minimum": 1 } + } + }, + "outgoing": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/definitions/outgoingBatch" } + }, + "validation": { + "type": "object", + "additionalProperties": false, + "anyOf": [ + { "required": ["checked"] }, + { "required": ["failureReason"] } + ], + "properties": { + "checked": { "type": "integer", "minimum": 0 }, + "failureReason": { "$ref": "#/definitions/error" }, + "took": { "type": "integer" }, + "version": { "type": "integer" }, + "problems": { + "type": "array", + "minItems": 1, + "$ref": "#/definitions/validationProblem" + } + } + } + } + }, + "outgoingBatch": { + "type": "object", + "additionalProperties": false, + "anyOf": [ + {"required": ["sent"]}, + {"required": ["failed"]} + ], + "properties": { + "sent": { "type": "integer", "minimum": 1 }, + "failed": { "type": "integer", "minimum": 1 } + } + }, + "error": { + "oneOf": [ + { "$ref": "#/definitions/httpError" }, + { "$ref": "#/definitions/nsError" }, + { "$ref": "#/definitions/shutdownError" }, + { "$ref": "#/definitions/authError" }, + { "$ref": "#/definitions/otherError" }, + { "$ref": "#/definitions/unexpectedError" }, + { "$ref": "#/definitions/sqlError" } + ] + }, + "httpError": { + "required": ["name", "code"], + "properties": { + "name": { "enum": ["httperror"] }, + "code": { "type": "integer" } + } + }, + "nsError": { + "required": ["name", "code"], + "properties": { + "name": { "enum": ["nserror"] }, + "code": { "type": "integer" } + } + }, + "shutdownError": { + "required": ["name"], + "properties": { + "name": { "enum": ["shutdownerror"] } + } + }, + "authError": { + "required": ["name"], + "properties": { + "name": { "enum": ["autherror"] }, + "from": { "enum": ["tokenserver", "fxaccounts", "hawkclient"] } + } + }, + "otherError": { + "required": ["name"], + "properties": { + "name": { "enum": ["othererror"] }, + "error": { "type": "string" } + } + }, + "unexpectedError": { + "required": ["name"], + "properties": { + "name": { "enum": ["unexpectederror"] }, + "error": { "type": "string" } + } + }, + "sqlError": { + "required": ["name"], + "properties": { + "name": { "enum": ["sqlerror"] }, + "code": { "type": "integer" } + } + }, + "validationProblem": { + "required": ["name", "count"], + "properties": { + "name": { "type": "string" }, + "count": { "type": "integer" } + } + } + } +}
\ No newline at end of file diff --git a/services/sync/tests/unit/systemaddon-search.xml b/services/sync/tests/unit/systemaddon-search.xml new file mode 100644 index 0000000000..d34e3937c3 --- /dev/null +++ b/services/sync/tests/unit/systemaddon-search.xml @@ -0,0 +1,27 @@ +<?xml version="1.0" encoding="UTF-8"?> +<searchresults total_results="1"> + <addon id="5618"> + <name>System Add-on Test</name> + <type id="1">Extension</type> + <guid>system1@tests.mozilla.org</guid> + <slug>addon11</slug> + <version>1.0</version> + + <compatible_applications><application> + <name>Firefox</name> + <application_id>1</application_id> + <min_version>3.6</min_version> + <max_version>*</max_version> + <appID>xpcshell@tests.mozilla.org</appID> + </application></compatible_applications> + <all_compatible_os><os>ALL</os></all_compatible_os> + + <install os="ALL" size="999">http://127.0.0.1:8888/system.xpi</install> + <created epoch="1252903662"> + 2009-09-14T04:47:42Z + </created> + <last_updated epoch="1315255329"> + 2011-09-05T20:42:09Z + </last_updated> + </addon> +</searchresults> diff --git a/services/sync/tests/unit/test_addon_utils.js b/services/sync/tests/unit/test_addon_utils.js new file mode 100644 index 0000000000..bbbd81d0d8 --- /dev/null +++ b/services/sync/tests/unit/test_addon_utils.js @@ -0,0 +1,141 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +Cu.import("resource://gre/modules/Log.jsm"); +Cu.import("resource://gre/modules/Preferences.jsm"); +Cu.import("resource://services-sync/addonutils.js"); +Cu.import("resource://services-sync/util.js"); + +const HTTP_PORT = 8888; +const SERVER_ADDRESS = "http://127.0.0.1:8888"; + +var prefs = new Preferences(); + +prefs.set("extensions.getAddons.get.url", + SERVER_ADDRESS + "/search/guid:%IDS%"); + +loadAddonTestFunctions(); +startupManager(); + +function createAndStartHTTPServer(port=HTTP_PORT) { + try { + let server = new HttpServer(); + + let bootstrap1XPI = ExtensionsTestPath("/addons/test_bootstrap1_1.xpi"); + + server.registerFile("/search/guid:missing-sourceuri%40tests.mozilla.org", + do_get_file("missing-sourceuri.xml")); + + server.registerFile("/search/guid:rewrite%40tests.mozilla.org", + do_get_file("rewrite-search.xml")); + + server.start(port); + + return server; + } catch (ex) { + _("Got exception starting HTTP server on port " + port); + _("Error: " + Log.exceptionStr(ex)); + do_throw(ex); + } +} + +function run_test() { + initTestLogging("Trace"); + + run_next_test(); +} + +add_test(function test_handle_empty_source_uri() { + _("Ensure that search results without a sourceURI are properly ignored."); + + let server = createAndStartHTTPServer(); + + const ID = "missing-sourceuri@tests.mozilla.org"; + + let cb = Async.makeSpinningCallback(); + AddonUtils.installAddons([{id: ID, requireSecureURI: false}], cb); + let result = cb.wait(); + + do_check_true("installedIDs" in result); + do_check_eq(0, result.installedIDs.length); + + do_check_true("skipped" in result); + do_check_true(result.skipped.includes(ID)); + + server.stop(run_next_test); +}); + +add_test(function test_ignore_untrusted_source_uris() { + _("Ensures that source URIs from insecure schemes are rejected."); + + let ioService = Cc["@mozilla.org/network/io-service;1"] + .getService(Ci.nsIIOService); + + const bad = ["http://example.com/foo.xpi", + "ftp://example.com/foo.xpi", + "silly://example.com/foo.xpi"]; + + const good = ["https://example.com/foo.xpi"]; + + for (let s of bad) { + let sourceURI = ioService.newURI(s, null, null); + let addon = {sourceURI: sourceURI, name: "bad", id: "bad"}; + + let canInstall = AddonUtils.canInstallAddon(addon); + do_check_false(canInstall, "Correctly rejected a bad URL"); + } + + for (let s of good) { + let sourceURI = ioService.newURI(s, null, null); + let addon = {sourceURI: sourceURI, name: "good", id: "good"}; + + let canInstall = AddonUtils.canInstallAddon(addon); + do_check_true(canInstall, "Correctly accepted a good URL"); + } + run_next_test(); +}); + +add_test(function test_source_uri_rewrite() { + _("Ensure that a 'src=api' query string is rewritten to 'src=sync'"); + + // This tests for conformance with bug 708134 so server-side metrics aren't + // skewed. + + // We resort to monkeypatching because of the API design. + let oldFunction = AddonUtils.__proto__.installAddonFromSearchResult; + + let installCalled = false; + AddonUtils.__proto__.installAddonFromSearchResult = + function testInstallAddon(addon, metadata, cb) { + + do_check_eq(SERVER_ADDRESS + "/require.xpi?src=sync", + addon.sourceURI.spec); + + installCalled = true; + + AddonUtils.getInstallFromSearchResult(addon, function (error, install) { + do_check_null(error); + do_check_eq(SERVER_ADDRESS + "/require.xpi?src=sync", + install.sourceURI.spec); + + cb(null, {id: addon.id, addon: addon, install: install}); + }, false); + }; + + let server = createAndStartHTTPServer(); + + let installCallback = Async.makeSpinningCallback(); + let installOptions = { + id: "rewrite@tests.mozilla.org", + requireSecureURI: false, + } + AddonUtils.installAddons([installOptions], installCallback); + + installCallback.wait(); + do_check_true(installCalled); + AddonUtils.__proto__.installAddonFromSearchResult = oldFunction; + + server.stop(run_next_test); +}); diff --git a/services/sync/tests/unit/test_addons_engine.js b/services/sync/tests/unit/test_addons_engine.js new file mode 100644 index 0000000000..64e4e32e8c --- /dev/null +++ b/services/sync/tests/unit/test_addons_engine.js @@ -0,0 +1,253 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +Cu.import("resource://gre/modules/AddonManager.jsm"); +Cu.import("resource://gre/modules/Preferences.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://services-common/async.js"); +Cu.import("resource://services-sync/addonsreconciler.js"); +Cu.import("resource://services-sync/engines/addons.js"); +Cu.import("resource://services-sync/service.js"); +Cu.import("resource://services-sync/util.js"); +Cu.import("resource://testing-common/services/sync/utils.js"); + +var prefs = new Preferences(); +prefs.set("extensions.getAddons.get.url", + "http://localhost:8888/search/guid:%IDS%"); +prefs.set("extensions.install.requireSecureOrigin", false); + +loadAddonTestFunctions(); +startupManager(); + +var engineManager = Service.engineManager; + +engineManager.register(AddonsEngine); +var engine = engineManager.get("addons"); +var reconciler = engine._reconciler; +var tracker = engine._tracker; + +function advance_test() { + reconciler._addons = {}; + reconciler._changes = []; + + let cb = Async.makeSpinningCallback(); + reconciler.saveState(null, cb); + cb.wait(); + + run_next_test(); +} + +// This is a basic sanity test for the unit test itself. If this breaks, the +// add-ons API likely changed upstream. +add_test(function test_addon_install() { + _("Ensure basic add-on APIs work as expected."); + + let install = getAddonInstall("test_bootstrap1_1"); + do_check_neq(install, null); + do_check_eq(install.type, "extension"); + do_check_eq(install.name, "Test Bootstrap 1"); + + advance_test(); +}); + +add_test(function test_find_dupe() { + _("Ensure the _findDupe() implementation is sane."); + + // This gets invoked at the top of sync, which is bypassed by this + // test, so we do it manually. + engine._refreshReconcilerState(); + + let addon = installAddon("test_bootstrap1_1"); + + let record = { + id: Utils.makeGUID(), + addonID: addon.id, + enabled: true, + applicationID: Services.appinfo.ID, + source: "amo" + }; + + let dupe = engine._findDupe(record); + do_check_eq(addon.syncGUID, dupe); + + record.id = addon.syncGUID; + dupe = engine._findDupe(record); + do_check_eq(null, dupe); + + uninstallAddon(addon); + advance_test(); +}); + +add_test(function test_get_changed_ids() { + _("Ensure getChangedIDs() has the appropriate behavior."); + + _("Ensure getChangedIDs() returns an empty object by default."); + let changes = engine.getChangedIDs(); + do_check_eq("object", typeof(changes)); + do_check_eq(0, Object.keys(changes).length); + + _("Ensure tracker changes are populated."); + let now = new Date(); + let changeTime = now.getTime() / 1000; + let guid1 = Utils.makeGUID(); + tracker.addChangedID(guid1, changeTime); + + changes = engine.getChangedIDs(); + do_check_eq("object", typeof(changes)); + do_check_eq(1, Object.keys(changes).length); + do_check_true(guid1 in changes); + do_check_eq(changeTime, changes[guid1]); + + tracker.clearChangedIDs(); + + _("Ensure reconciler changes are populated."); + let addon = installAddon("test_bootstrap1_1"); + tracker.clearChangedIDs(); // Just in case. + changes = engine.getChangedIDs(); + do_check_eq("object", typeof(changes)); + do_check_eq(1, Object.keys(changes).length); + do_check_true(addon.syncGUID in changes); + _("Change time: " + changeTime + ", addon change: " + changes[addon.syncGUID]); + do_check_true(changes[addon.syncGUID] >= changeTime); + + let oldTime = changes[addon.syncGUID]; + let guid2 = addon.syncGUID; + uninstallAddon(addon); + changes = engine.getChangedIDs(); + do_check_eq(1, Object.keys(changes).length); + do_check_true(guid2 in changes); + do_check_true(changes[guid2] > oldTime); + + _("Ensure non-syncable add-ons aren't picked up by reconciler changes."); + reconciler._addons = {}; + reconciler._changes = []; + let record = { + id: "DUMMY", + guid: Utils.makeGUID(), + enabled: true, + installed: true, + modified: new Date(), + type: "UNSUPPORTED", + scope: 0, + foreignInstall: false + }; + reconciler.addons["DUMMY"] = record; + reconciler._addChange(record.modified, CHANGE_INSTALLED, record); + + changes = engine.getChangedIDs(); + _(JSON.stringify(changes)); + do_check_eq(0, Object.keys(changes).length); + + advance_test(); +}); + +add_test(function test_disabled_install_semantics() { + _("Ensure that syncing a disabled add-on preserves proper state."); + + // This is essentially a test for bug 712542, which snuck into the original + // add-on sync drop. It ensures that when an add-on is installed that the + // disabled state and incoming syncGUID is preserved, even on the next sync. + const USER = "foo"; + const PASSWORD = "password"; + const PASSPHRASE = "abcdeabcdeabcdeabcdeabcdea"; + const ADDON_ID = "addon1@tests.mozilla.org"; + + let server = new SyncServer(); + server.start(); + new SyncTestingInfrastructure(server.server, USER, PASSWORD, PASSPHRASE); + + generateNewKeys(Service.collectionKeys); + + let contents = { + meta: {global: {engines: {addons: {version: engine.version, + syncID: engine.syncID}}}}, + crypto: {}, + addons: {} + }; + + server.registerUser(USER, "password"); + server.createContents(USER, contents); + + let amoServer = new HttpServer(); + amoServer.registerFile("/search/guid:addon1%40tests.mozilla.org", + do_get_file("addon1-search.xml")); + + let installXPI = ExtensionsTestPath("/addons/test_install1.xpi"); + amoServer.registerFile("/addon1.xpi", do_get_file(installXPI)); + amoServer.start(8888); + + // Insert an existing record into the server. + let id = Utils.makeGUID(); + let now = Date.now() / 1000; + + let record = encryptPayload({ + id: id, + applicationID: Services.appinfo.ID, + addonID: ADDON_ID, + enabled: false, + deleted: false, + source: "amo", + }); + let wbo = new ServerWBO(id, record, now - 2); + server.insertWBO(USER, "addons", wbo); + + _("Performing sync of add-ons engine."); + engine._sync(); + + // At this point the non-restartless extension should be staged for install. + + // Don't need this server any more. + let cb = Async.makeSpinningCallback(); + amoServer.stop(cb); + cb.wait(); + + // We ensure the reconciler has recorded the proper ID and enabled state. + let addon = reconciler.getAddonStateFromSyncGUID(id); + do_check_neq(null, addon); + do_check_eq(false, addon.enabled); + + // We fake an app restart and perform another sync, just to make sure things + // are sane. + restartManager(); + + engine._sync(); + + // The client should not upload a new record. The old record should be + // retained and unmodified. + let collection = server.getCollection(USER, "addons"); + do_check_eq(1, collection.count()); + + let payload = collection.payloads()[0]; + do_check_neq(null, collection.wbo(id)); + do_check_eq(ADDON_ID, payload.addonID); + do_check_false(payload.enabled); + + server.stop(advance_test); +}); + +add_test(function cleanup() { + // There's an xpcom-shutdown hook for this, but let's give this a shot. + reconciler.stopListening(); + run_next_test(); +}); + +function run_test() { + initTestLogging("Trace"); + Log.repository.getLogger("Sync.Engine.Addons").level = + Log.Level.Trace; + Log.repository.getLogger("Sync.Store.Addons").level = Log.Level.Trace; + Log.repository.getLogger("Sync.Tracker.Addons").level = + Log.Level.Trace; + Log.repository.getLogger("Sync.AddonsRepository").level = + Log.Level.Trace; + + reconciler.startListening(); + + // Don't flush to disk in the middle of an event listener! + // This causes test hangs on WinXP. + reconciler._shouldPersist = false; + + advance_test(); +} diff --git a/services/sync/tests/unit/test_addons_reconciler.js b/services/sync/tests/unit/test_addons_reconciler.js new file mode 100644 index 0000000000..d93bdfc038 --- /dev/null +++ b/services/sync/tests/unit/test_addons_reconciler.js @@ -0,0 +1,195 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +Cu.import("resource://gre/modules/AddonManager.jsm"); +Cu.import("resource://services-sync/addonsreconciler.js"); +Cu.import("resource://services-sync/engines/addons.js"); +Cu.import("resource://services-sync/service.js"); +Cu.import("resource://services-sync/util.js"); + +loadAddonTestFunctions(); +startupManager(); + +function run_test() { + initTestLogging("Trace"); + Log.repository.getLogger("Sync.AddonsReconciler").level = Log.Level.Trace; + Log.repository.getLogger("Sync.AddonsReconciler").level = + Log.Level.Trace; + + Svc.Prefs.set("engine.addons", true); + Service.engineManager.register(AddonsEngine); + + run_next_test(); +} + +add_test(function test_defaults() { + _("Ensure new objects have reasonable defaults."); + + let reconciler = new AddonsReconciler(); + + do_check_false(reconciler._listening); + do_check_eq("object", typeof(reconciler.addons)); + do_check_eq(0, Object.keys(reconciler.addons).length); + do_check_eq(0, reconciler._changes.length); + do_check_eq(0, reconciler._listeners.length); + + run_next_test(); +}); + +add_test(function test_load_state_empty_file() { + _("Ensure loading from a missing file results in defaults being set."); + + let reconciler = new AddonsReconciler(); + + reconciler.loadState(null, function(error, loaded) { + do_check_eq(null, error); + do_check_false(loaded); + + do_check_eq("object", typeof(reconciler.addons)); + do_check_eq(0, Object.keys(reconciler.addons).length); + do_check_eq(0, reconciler._changes.length); + + run_next_test(); + }); +}); + +add_test(function test_install_detection() { + _("Ensure that add-on installation results in appropriate side-effects."); + + let reconciler = new AddonsReconciler(); + reconciler.startListening(); + + let before = new Date(); + let addon = installAddon("test_bootstrap1_1"); + let after = new Date(); + + do_check_eq(1, Object.keys(reconciler.addons).length); + do_check_true(addon.id in reconciler.addons); + let record = reconciler.addons[addon.id]; + + const KEYS = ["id", "guid", "enabled", "installed", "modified", "type", + "scope", "foreignInstall"]; + for (let key of KEYS) { + do_check_true(key in record); + do_check_neq(null, record[key]); + } + + do_check_eq(addon.id, record.id); + do_check_eq(addon.syncGUID, record.guid); + do_check_true(record.enabled); + do_check_true(record.installed); + do_check_true(record.modified >= before && record.modified <= after); + do_check_eq("extension", record.type); + do_check_false(record.foreignInstall); + + do_check_eq(1, reconciler._changes.length); + let change = reconciler._changes[0]; + do_check_true(change[0] >= before && change[1] <= after); + do_check_eq(CHANGE_INSTALLED, change[1]); + do_check_eq(addon.id, change[2]); + + uninstallAddon(addon); + + run_next_test(); +}); + +add_test(function test_uninstall_detection() { + _("Ensure that add-on uninstallation results in appropriate side-effects."); + + let reconciler = new AddonsReconciler(); + reconciler.startListening(); + + reconciler._addons = {}; + reconciler._changes = []; + + let addon = installAddon("test_bootstrap1_1"); + let id = addon.id; + let guid = addon.syncGUID; + + reconciler._changes = []; + uninstallAddon(addon); + + do_check_eq(1, Object.keys(reconciler.addons).length); + do_check_true(id in reconciler.addons); + + let record = reconciler.addons[id]; + do_check_false(record.installed); + + do_check_eq(1, reconciler._changes.length); + let change = reconciler._changes[0]; + do_check_eq(CHANGE_UNINSTALLED, change[1]); + do_check_eq(id, change[2]); + + run_next_test(); +}); + +add_test(function test_load_state_future_version() { + _("Ensure loading a file from a future version results in no data loaded."); + + const FILENAME = "TEST_LOAD_STATE_FUTURE_VERSION"; + + let reconciler = new AddonsReconciler(); + + // First we populate our new file. + let state = {version: 100, addons: {foo: {}}, changes: [[1, 1, "foo"]]}; + let cb = Async.makeSyncCallback(); + + // jsonSave() expects an object with ._log, so we give it a reconciler + // instance. + Utils.jsonSave(FILENAME, reconciler, state, cb); + Async.waitForSyncCallback(cb); + + reconciler.loadState(FILENAME, function(error, loaded) { + do_check_eq(null, error); + do_check_false(loaded); + + do_check_eq("object", typeof(reconciler.addons)); + do_check_eq(1, Object.keys(reconciler.addons).length); + do_check_eq(1, reconciler._changes.length); + + run_next_test(); + }); +}); + +add_test(function test_prune_changes_before_date() { + _("Ensure that old changes are pruned properly."); + + let reconciler = new AddonsReconciler(); + reconciler._ensureStateLoaded(); + reconciler._changes = []; + + let now = new Date(); + const HOUR_MS = 1000 * 60 * 60; + + _("Ensure pruning an empty changes array works."); + reconciler.pruneChangesBeforeDate(now); + do_check_eq(0, reconciler._changes.length); + + let old = new Date(now.getTime() - HOUR_MS); + let young = new Date(now.getTime() - 1000); + reconciler._changes.push([old, CHANGE_INSTALLED, "foo"]); + reconciler._changes.push([young, CHANGE_INSTALLED, "bar"]); + do_check_eq(2, reconciler._changes.length); + + _("Ensure pruning with an old time won't delete anything."); + let threshold = new Date(old.getTime() - 1); + reconciler.pruneChangesBeforeDate(threshold); + do_check_eq(2, reconciler._changes.length); + + _("Ensure pruning a single item works."); + threshold = new Date(young.getTime() - 1000); + reconciler.pruneChangesBeforeDate(threshold); + do_check_eq(1, reconciler._changes.length); + do_check_neq(undefined, reconciler._changes[0]); + do_check_eq(young, reconciler._changes[0][0]); + do_check_eq("bar", reconciler._changes[0][2]); + + _("Ensure pruning all changes works."); + reconciler._changes.push([old, CHANGE_INSTALLED, "foo"]); + reconciler.pruneChangesBeforeDate(now); + do_check_eq(0, reconciler._changes.length); + + run_next_test(); +}); diff --git a/services/sync/tests/unit/test_addons_store.js b/services/sync/tests/unit/test_addons_store.js new file mode 100644 index 0000000000..b52cfab316 --- /dev/null +++ b/services/sync/tests/unit/test_addons_store.js @@ -0,0 +1,539 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +Cu.import("resource://gre/modules/Log.jsm"); +Cu.import("resource://gre/modules/Preferences.jsm"); +Cu.import("resource://services-sync/addonutils.js"); +Cu.import("resource://services-sync/engines/addons.js"); +Cu.import("resource://services-sync/service.js"); +Cu.import("resource://services-sync/util.js"); +Cu.import("resource://testing-common/services/sync/utils.js"); +Cu.import("resource://gre/modules/FileUtils.jsm"); + +const HTTP_PORT = 8888; + +var prefs = new Preferences(); + +prefs.set("extensions.getAddons.get.url", "http://localhost:8888/search/guid:%IDS%"); +prefs.set("extensions.install.requireSecureOrigin", false); + +const SYSTEM_ADDON_ID = "system1@tests.mozilla.org"; +let systemAddonFile; + +// The system add-on must be installed before AddonManager is started. +function loadSystemAddon() { + let addonFilename = SYSTEM_ADDON_ID + ".xpi"; + const distroDir = FileUtils.getDir("ProfD", ["sysfeatures", "app0"], true); + do_get_file(ExtensionsTestPath("/data/system_addons/system1_1.xpi")).copyTo(distroDir, addonFilename); + systemAddonFile = FileUtils.File(distroDir.path); + systemAddonFile.append(addonFilename); + systemAddonFile.lastModifiedTime = Date.now(); + // As we're not running in application, we need to setup the features directory + // used by system add-ons. + registerDirectory("XREAppFeat", distroDir); +} + +loadAddonTestFunctions(); +loadSystemAddon(); +startupManager(); + +Service.engineManager.register(AddonsEngine); +var engine = Service.engineManager.get("addons"); +var tracker = engine._tracker; +var store = engine._store; +var reconciler = engine._reconciler; + +/** + * Create a AddonsRec for this application with the fields specified. + * + * @param id Sync GUID of record + * @param addonId ID of add-on + * @param enabled Boolean whether record is enabled + * @param deleted Boolean whether record was deleted + */ +function createRecordForThisApp(id, addonId, enabled, deleted) { + return { + id: id, + addonID: addonId, + enabled: enabled, + deleted: !!deleted, + applicationID: Services.appinfo.ID, + source: "amo" + }; +} + +function createAndStartHTTPServer(port) { + try { + let server = new HttpServer(); + + let bootstrap1XPI = ExtensionsTestPath("/addons/test_bootstrap1_1.xpi"); + + server.registerFile("/search/guid:bootstrap1%40tests.mozilla.org", + do_get_file("bootstrap1-search.xml")); + server.registerFile("/bootstrap1.xpi", do_get_file(bootstrap1XPI)); + + server.registerFile("/search/guid:missing-xpi%40tests.mozilla.org", + do_get_file("missing-xpi-search.xml")); + + server.registerFile("/search/guid:system1%40tests.mozilla.org", + do_get_file("systemaddon-search.xml")); + server.registerFile("/system.xpi", systemAddonFile); + + server.start(port); + + return server; + } catch (ex) { + _("Got exception starting HTTP server on port " + port); + _("Error: " + Log.exceptionStr(ex)); + do_throw(ex); + } +} + +function run_test() { + initTestLogging("Trace"); + Log.repository.getLogger("Sync.Engine.Addons").level = Log.Level.Trace; + Log.repository.getLogger("Sync.Tracker.Addons").level = Log.Level.Trace; + Log.repository.getLogger("Sync.AddonsRepository").level = + Log.Level.Trace; + + reconciler.startListening(); + + // Don't flush to disk in the middle of an event listener! + // This causes test hangs on WinXP. + reconciler._shouldPersist = false; + + run_next_test(); +} + +add_test(function test_remove() { + _("Ensure removing add-ons from deleted records works."); + + let addon = installAddon("test_bootstrap1_1"); + let record = createRecordForThisApp(addon.syncGUID, addon.id, true, true); + + let failed = store.applyIncomingBatch([record]); + do_check_eq(0, failed.length); + + let newAddon = getAddonFromAddonManagerByID(addon.id); + do_check_eq(null, newAddon); + + run_next_test(); +}); + +add_test(function test_apply_enabled() { + _("Ensures that changes to the userEnabled flag apply."); + + let addon = installAddon("test_bootstrap1_1"); + do_check_true(addon.isActive); + do_check_false(addon.userDisabled); + + _("Ensure application of a disable record works as expected."); + let records = []; + records.push(createRecordForThisApp(addon.syncGUID, addon.id, false, false)); + let failed = store.applyIncomingBatch(records); + do_check_eq(0, failed.length); + addon = getAddonFromAddonManagerByID(addon.id); + do_check_true(addon.userDisabled); + records = []; + + _("Ensure enable record works as expected."); + records.push(createRecordForThisApp(addon.syncGUID, addon.id, true, false)); + failed = store.applyIncomingBatch(records); + do_check_eq(0, failed.length); + addon = getAddonFromAddonManagerByID(addon.id); + do_check_false(addon.userDisabled); + records = []; + + _("Ensure enabled state updates don't apply if the ignore pref is set."); + records.push(createRecordForThisApp(addon.syncGUID, addon.id, false, false)); + Svc.Prefs.set("addons.ignoreUserEnabledChanges", true); + failed = store.applyIncomingBatch(records); + do_check_eq(0, failed.length); + addon = getAddonFromAddonManagerByID(addon.id); + do_check_false(addon.userDisabled); + records = []; + + uninstallAddon(addon); + Svc.Prefs.reset("addons.ignoreUserEnabledChanges"); + run_next_test(); +}); + +add_test(function test_ignore_different_appid() { + _("Ensure that incoming records with a different application ID are ignored."); + + // We test by creating a record that should result in an update. + let addon = installAddon("test_bootstrap1_1"); + do_check_false(addon.userDisabled); + + let record = createRecordForThisApp(addon.syncGUID, addon.id, false, false); + record.applicationID = "FAKE_ID"; + + let failed = store.applyIncomingBatch([record]); + do_check_eq(0, failed.length); + + let newAddon = getAddonFromAddonManagerByID(addon.id); + do_check_false(addon.userDisabled); + + uninstallAddon(addon); + + run_next_test(); +}); + +add_test(function test_ignore_unknown_source() { + _("Ensure incoming records with unknown source are ignored."); + + let addon = installAddon("test_bootstrap1_1"); + + let record = createRecordForThisApp(addon.syncGUID, addon.id, false, false); + record.source = "DUMMY_SOURCE"; + + let failed = store.applyIncomingBatch([record]); + do_check_eq(0, failed.length); + + let newAddon = getAddonFromAddonManagerByID(addon.id); + do_check_false(addon.userDisabled); + + uninstallAddon(addon); + + run_next_test(); +}); + +add_test(function test_apply_uninstall() { + _("Ensures that uninstalling an add-on from a record works."); + + let addon = installAddon("test_bootstrap1_1"); + + let records = []; + records.push(createRecordForThisApp(addon.syncGUID, addon.id, true, true)); + let failed = store.applyIncomingBatch(records); + do_check_eq(0, failed.length); + + addon = getAddonFromAddonManagerByID(addon.id); + do_check_eq(null, addon); + + run_next_test(); +}); + +add_test(function test_addon_syncability() { + _("Ensure isAddonSyncable functions properly."); + + Svc.Prefs.set("addons.trustedSourceHostnames", + "addons.mozilla.org,other.example.com"); + + do_check_false(store.isAddonSyncable(null)); + + let addon = installAddon("test_bootstrap1_1"); + do_check_true(store.isAddonSyncable(addon)); + + let dummy = {}; + const KEYS = ["id", "syncGUID", "type", "scope", "foreignInstall", "isSyncable"]; + for (let k of KEYS) { + dummy[k] = addon[k]; + } + + do_check_true(store.isAddonSyncable(dummy)); + + dummy.type = "UNSUPPORTED"; + do_check_false(store.isAddonSyncable(dummy)); + dummy.type = addon.type; + + dummy.scope = 0; + do_check_false(store.isAddonSyncable(dummy)); + dummy.scope = addon.scope; + + dummy.isSyncable = false; + do_check_false(store.isAddonSyncable(dummy)); + dummy.isSyncable = addon.isSyncable; + + dummy.foreignInstall = true; + do_check_false(store.isAddonSyncable(dummy)); + dummy.foreignInstall = false; + + uninstallAddon(addon); + + do_check_false(store.isSourceURITrusted(null)); + + function createURI(s) { + let service = Components.classes["@mozilla.org/network/io-service;1"] + .getService(Components.interfaces.nsIIOService); + return service.newURI(s, null, null); + } + + let trusted = [ + "https://addons.mozilla.org/foo", + "https://other.example.com/foo" + ]; + + let untrusted = [ + "http://addons.mozilla.org/foo", // non-https + "ftps://addons.mozilla.org/foo", // non-https + "https://untrusted.example.com/foo", // non-trusted hostname` + ]; + + for (let uri of trusted) { + do_check_true(store.isSourceURITrusted(createURI(uri))); + } + + for (let uri of untrusted) { + do_check_false(store.isSourceURITrusted(createURI(uri))); + } + + Svc.Prefs.set("addons.trustedSourceHostnames", ""); + for (let uri of trusted) { + do_check_false(store.isSourceURITrusted(createURI(uri))); + } + + Svc.Prefs.set("addons.trustedSourceHostnames", "addons.mozilla.org"); + do_check_true(store.isSourceURITrusted(createURI("https://addons.mozilla.org/foo"))); + + Svc.Prefs.reset("addons.trustedSourceHostnames"); + + run_next_test(); +}); + +add_test(function test_ignore_hotfixes() { + _("Ensure that hotfix extensions are ignored."); + + // A hotfix extension is one that has the id the same as the + // extensions.hotfix.id pref. + let prefs = new Preferences("extensions."); + + let addon = installAddon("test_bootstrap1_1"); + do_check_true(store.isAddonSyncable(addon)); + + let dummy = {}; + const KEYS = ["id", "syncGUID", "type", "scope", "foreignInstall", "isSyncable"]; + for (let k of KEYS) { + dummy[k] = addon[k]; + } + + // Basic sanity check. + do_check_true(store.isAddonSyncable(dummy)); + + prefs.set("hotfix.id", dummy.id); + do_check_false(store.isAddonSyncable(dummy)); + + // Verify that int values don't throw off checking. + let prefSvc = Cc["@mozilla.org/preferences-service;1"] + .getService(Ci.nsIPrefService) + .getBranch("extensions."); + // Need to delete pref before changing type. + prefSvc.deleteBranch("hotfix.id"); + prefSvc.setIntPref("hotfix.id", 0xdeadbeef); + + do_check_true(store.isAddonSyncable(dummy)); + + uninstallAddon(addon); + + prefs.reset("hotfix.id"); + + run_next_test(); +}); + + +add_test(function test_get_all_ids() { + _("Ensures that getAllIDs() returns an appropriate set."); + + _("Installing two addons."); + let addon1 = installAddon("test_install1"); + let addon2 = installAddon("test_bootstrap1_1"); + + _("Ensure they're syncable."); + do_check_true(store.isAddonSyncable(addon1)); + do_check_true(store.isAddonSyncable(addon2)); + + let ids = store.getAllIDs(); + + do_check_eq("object", typeof(ids)); + do_check_eq(2, Object.keys(ids).length); + do_check_true(addon1.syncGUID in ids); + do_check_true(addon2.syncGUID in ids); + + addon1.install.cancel(); + uninstallAddon(addon2); + + run_next_test(); +}); + +add_test(function test_change_item_id() { + _("Ensures that changeItemID() works properly."); + + let addon = installAddon("test_bootstrap1_1"); + + let oldID = addon.syncGUID; + let newID = Utils.makeGUID(); + + store.changeItemID(oldID, newID); + + let newAddon = getAddonFromAddonManagerByID(addon.id); + do_check_neq(null, newAddon); + do_check_eq(newID, newAddon.syncGUID); + + uninstallAddon(newAddon); + + run_next_test(); +}); + +add_test(function test_create() { + _("Ensure creating/installing an add-on from a record works."); + + let server = createAndStartHTTPServer(HTTP_PORT); + + let addon = installAddon("test_bootstrap1_1"); + let id = addon.id; + uninstallAddon(addon); + + let guid = Utils.makeGUID(); + let record = createRecordForThisApp(guid, id, true, false); + + let failed = store.applyIncomingBatch([record]); + do_check_eq(0, failed.length); + + let newAddon = getAddonFromAddonManagerByID(id); + do_check_neq(null, newAddon); + do_check_eq(guid, newAddon.syncGUID); + do_check_false(newAddon.userDisabled); + + uninstallAddon(newAddon); + + server.stop(run_next_test); +}); + +add_test(function test_create_missing_search() { + _("Ensures that failed add-on searches are handled gracefully."); + + let server = createAndStartHTTPServer(HTTP_PORT); + + // The handler for this ID is not installed, so a search should 404. + const id = "missing@tests.mozilla.org"; + let guid = Utils.makeGUID(); + let record = createRecordForThisApp(guid, id, true, false); + + let failed = store.applyIncomingBatch([record]); + do_check_eq(1, failed.length); + do_check_eq(guid, failed[0]); + + let addon = getAddonFromAddonManagerByID(id); + do_check_eq(null, addon); + + server.stop(run_next_test); +}); + +add_test(function test_create_bad_install() { + _("Ensures that add-ons without a valid install are handled gracefully."); + + let server = createAndStartHTTPServer(HTTP_PORT); + + // The handler returns a search result but the XPI will 404. + const id = "missing-xpi@tests.mozilla.org"; + let guid = Utils.makeGUID(); + let record = createRecordForThisApp(guid, id, true, false); + + let failed = store.applyIncomingBatch([record]); + // This addon had no source URI so was skipped - but it's not treated as + // failure. + // XXX - this test isn't testing what we thought it was. Previously the addon + // was not being installed due to requireSecureURL checking *before* we'd + // attempted to get the XPI. + // With requireSecureURL disabled we do see a download failure, but the addon + // *does* get added to |failed|. + // FTR: onDownloadFailed() is called with ERROR_NETWORK_FAILURE, so it's going + // to be tricky to distinguish a 404 from other transient network errors + // where we do want the addon to end up in |failed|. + // This is being tracked in bug 1284778. + //do_check_eq(0, failed.length); + + let addon = getAddonFromAddonManagerByID(id); + do_check_eq(null, addon); + + server.stop(run_next_test); +}); + +add_test(function test_ignore_system() { + _("Ensure we ignore system addons"); + // Our system addon should not appear in getAllIDs + engine._refreshReconcilerState(); + let num = 0; + for (let guid in store.getAllIDs()) { + num += 1; + let addon = reconciler.getAddonStateFromSyncGUID(guid); + do_check_neq(addon.id, SYSTEM_ADDON_ID); + } + do_check_true(num > 1, "should have seen at least one.") + run_next_test(); +}); + +add_test(function test_incoming_system() { + _("Ensure we handle incoming records that refer to a system addon"); + // eg, loop initially had a normal addon but it was then "promoted" to be a + // system addon but wanted to keep the same ID. The server record exists due + // to this. + + // before we start, ensure the system addon isn't disabled. + do_check_false(getAddonFromAddonManagerByID(SYSTEM_ADDON_ID).userDisabled); + + // Now simulate an incoming record with the same ID as the system addon, + // but flagged as disabled - it should not be applied. + let server = createAndStartHTTPServer(HTTP_PORT); + // We make the incoming record flag the system addon as disabled - it should + // be ignored. + let guid = Utils.makeGUID(); + let record = createRecordForThisApp(guid, SYSTEM_ADDON_ID, false, false); + + let failed = store.applyIncomingBatch([record]); + do_check_eq(0, failed.length); + + // The system addon should still not be userDisabled. + do_check_false(getAddonFromAddonManagerByID(SYSTEM_ADDON_ID).userDisabled); + + server.stop(run_next_test); +}); + +add_test(function test_wipe() { + _("Ensures that wiping causes add-ons to be uninstalled."); + + let addon1 = installAddon("test_bootstrap1_1"); + + store.wipe(); + + let addon = getAddonFromAddonManagerByID(addon1.id); + do_check_eq(null, addon); + + run_next_test(); +}); + +add_test(function test_wipe_and_install() { + _("Ensure wipe followed by install works."); + + // This tests the reset sync flow where remote data is replaced by local. The + // receiving client will see a wipe followed by a record which should undo + // the wipe. + let installed = installAddon("test_bootstrap1_1"); + + let record = createRecordForThisApp(installed.syncGUID, installed.id, true, + false); + + store.wipe(); + + let deleted = getAddonFromAddonManagerByID(installed.id); + do_check_null(deleted); + + // Re-applying the record can require re-fetching the XPI. + let server = createAndStartHTTPServer(HTTP_PORT); + + store.applyIncoming(record); + + let fetched = getAddonFromAddonManagerByID(record.addonID); + do_check_true(!!fetched); + + server.stop(run_next_test); +}); + +add_test(function cleanup() { + // There's an xpcom-shutdown hook for this, but let's give this a shot. + reconciler.stopListening(); + run_next_test(); +}); + diff --git a/services/sync/tests/unit/test_addons_tracker.js b/services/sync/tests/unit/test_addons_tracker.js new file mode 100644 index 0000000000..01bf37ab9e --- /dev/null +++ b/services/sync/tests/unit/test_addons_tracker.js @@ -0,0 +1,177 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +Cu.import("resource://gre/modules/AddonManager.jsm"); +Cu.import("resource://services-sync/engines/addons.js"); +Cu.import("resource://services-sync/constants.js"); +Cu.import("resource://services-sync/service.js"); +Cu.import("resource://services-sync/util.js"); + +loadAddonTestFunctions(); +startupManager(); +Svc.Prefs.set("engine.addons", true); + +Service.engineManager.register(AddonsEngine); +var engine = Service.engineManager.get("addons"); +var reconciler = engine._reconciler; +var store = engine._store; +var tracker = engine._tracker; + +// Don't write out by default. +tracker.persistChangedIDs = false; + +const addon1ID = "addon1@tests.mozilla.org"; + +function cleanup_and_advance() { + Svc.Obs.notify("weave:engine:stop-tracking"); + tracker.stopTracking(); + + tracker.resetScore(); + tracker.clearChangedIDs(); + + reconciler._addons = {}; + reconciler._changes = []; + let cb = Async.makeSpinningCallback(); + reconciler.saveState(null, cb); + cb.wait(); + + run_next_test(); +} + +function run_test() { + initTestLogging("Trace"); + Log.repository.getLogger("Sync.Engine.Addons").level = Log.Level.Trace; + Log.repository.getLogger("Sync.AddonsReconciler").level = + Log.Level.Trace; + + cleanup_and_advance(); +} + +add_test(function test_empty() { + _("Verify the tracker is empty to start with."); + + do_check_eq(0, Object.keys(tracker.changedIDs).length); + do_check_eq(0, tracker.score); + + cleanup_and_advance(); +}); + +add_test(function test_not_tracking() { + _("Ensures the tracker doesn't do anything when it isn't tracking."); + + let addon = installAddon("test_bootstrap1_1"); + uninstallAddon(addon); + + do_check_eq(0, Object.keys(tracker.changedIDs).length); + do_check_eq(0, tracker.score); + + cleanup_and_advance(); +}); + +add_test(function test_track_install() { + _("Ensure that installing an add-on notifies tracker."); + + reconciler.startListening(); + + Svc.Obs.notify("weave:engine:start-tracking"); + + do_check_eq(0, tracker.score); + let addon = installAddon("test_bootstrap1_1"); + let changed = tracker.changedIDs; + + do_check_eq(1, Object.keys(changed).length); + do_check_true(addon.syncGUID in changed); + do_check_eq(SCORE_INCREMENT_XLARGE, tracker.score); + + uninstallAddon(addon); + cleanup_and_advance(); +}); + +add_test(function test_track_uninstall() { + _("Ensure that uninstalling an add-on notifies tracker."); + + reconciler.startListening(); + + let addon = installAddon("test_bootstrap1_1"); + let guid = addon.syncGUID; + do_check_eq(0, tracker.score); + + Svc.Obs.notify("weave:engine:start-tracking"); + + uninstallAddon(addon); + let changed = tracker.changedIDs; + do_check_eq(1, Object.keys(changed).length); + do_check_true(guid in changed); + do_check_eq(SCORE_INCREMENT_XLARGE, tracker.score); + + cleanup_and_advance(); +}); + +add_test(function test_track_user_disable() { + _("Ensure that tracker sees disabling of add-on"); + + reconciler.startListening(); + + let addon = installAddon("test_bootstrap1_1"); + do_check_false(addon.userDisabled); + do_check_false(addon.appDisabled); + do_check_true(addon.isActive); + + Svc.Obs.notify("weave:engine:start-tracking"); + do_check_eq(0, tracker.score); + + let cb = Async.makeSyncCallback(); + + let listener = { + onDisabled: function(disabled) { + _("onDisabled"); + if (disabled.id == addon.id) { + AddonManager.removeAddonListener(listener); + cb(); + } + }, + onDisabling: function(disabling) { + _("onDisabling add-on"); + } + }; + AddonManager.addAddonListener(listener); + + _("Disabling add-on"); + addon.userDisabled = true; + _("Disabling started..."); + Async.waitForSyncCallback(cb); + + let changed = tracker.changedIDs; + do_check_eq(1, Object.keys(changed).length); + do_check_true(addon.syncGUID in changed); + do_check_eq(SCORE_INCREMENT_XLARGE, tracker.score); + + uninstallAddon(addon); + cleanup_and_advance(); +}); + +add_test(function test_track_enable() { + _("Ensure that enabling a disabled add-on notifies tracker."); + + reconciler.startListening(); + + let addon = installAddon("test_bootstrap1_1"); + addon.userDisabled = true; + store._sleep(0); + + do_check_eq(0, tracker.score); + + Svc.Obs.notify("weave:engine:start-tracking"); + addon.userDisabled = false; + store._sleep(0); + + let changed = tracker.changedIDs; + do_check_eq(1, Object.keys(changed).length); + do_check_true(addon.syncGUID in changed); + do_check_eq(SCORE_INCREMENT_XLARGE, tracker.score); + + uninstallAddon(addon); + cleanup_and_advance(); +}); diff --git a/services/sync/tests/unit/test_bookmark_batch_fail.js b/services/sync/tests/unit/test_bookmark_batch_fail.js new file mode 100644 index 0000000000..cf52fefb70 --- /dev/null +++ b/services/sync/tests/unit/test_bookmark_batch_fail.js @@ -0,0 +1,23 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +_("Making sure a failing sync reports a useful error"); +Cu.import("resource://services-sync/engines/bookmarks.js"); +Cu.import("resource://services-sync/service.js"); + +function run_test() { + let engine = new BookmarksEngine(Service); + engine._syncStartup = function() { + throw "FAIL!"; + }; + + try { + _("Try calling the sync that should throw right away"); + engine._sync(); + do_throw("Should have failed sync!"); + } + catch(ex) { + _("Making sure what we threw ended up as the exception:", ex); + do_check_eq(ex, "FAIL!"); + } +} diff --git a/services/sync/tests/unit/test_bookmark_duping.js b/services/sync/tests/unit/test_bookmark_duping.js new file mode 100644 index 0000000000..1e6c6ed2e1 --- /dev/null +++ b/services/sync/tests/unit/test_bookmark_duping.js @@ -0,0 +1,644 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +Cu.import("resource://gre/modules/PlacesUtils.jsm"); +Cu.import("resource://services-common/async.js"); +Cu.import("resource://gre/modules/Log.jsm"); +Cu.import("resource://services-sync/engines.js"); +Cu.import("resource://services-sync/engines/bookmarks.js"); +Cu.import("resource://services-sync/service.js"); +Cu.import("resource://services-sync/util.js"); +Cu.import("resource://testing-common/services/sync/utils.js"); +Cu.import("resource://services-sync/bookmark_validator.js"); + + +initTestLogging("Trace"); + +const bms = PlacesUtils.bookmarks; + +Service.engineManager.register(BookmarksEngine); + +const engine = new BookmarksEngine(Service); +const store = engine._store; +store._log.level = Log.Level.Trace; +engine._log.level = Log.Level.Trace; + +function promiseOneObserver(topic) { + return new Promise((resolve, reject) => { + let observer = function(subject, topic, data) { + Services.obs.removeObserver(observer, topic); + resolve({ subject: subject, data: data }); + } + Services.obs.addObserver(observer, topic, false); + }); +} + +function setup() { + let server = serverForUsers({"foo": "password"}, { + meta: {global: {engines: {bookmarks: {version: engine.version, + syncID: engine.syncID}}}}, + bookmarks: {}, + }); + + generateNewKeys(Service.collectionKeys); + + new SyncTestingInfrastructure(server.server); + + let collection = server.user("foo").collection("bookmarks"); + + Svc.Obs.notify("weave:engine:start-tracking"); // We skip usual startup... + + return { server, collection }; +} + +function* cleanup(server) { + Svc.Obs.notify("weave:engine:stop-tracking"); + Services.prefs.setBoolPref("services.sync-testing.startOverKeepIdentity", true); + let promiseStartOver = promiseOneObserver("weave:service:start-over:finish"); + Service.startOver(); + yield promiseStartOver; + yield new Promise(resolve => server.stop(resolve)); + yield bms.eraseEverything(); +} + +function getFolderChildrenIDs(folderId) { + let index = 0; + let result = []; + while (true) { + let childId = bms.getIdForItemAt(folderId, index); + if (childId == -1) { + break; + } + result.push(childId); + index++; + } + return result; +} + +function createFolder(parentId, title) { + let id = bms.createFolder(parentId, title, 0); + let guid = store.GUIDForId(id); + return { id, guid }; +} + +function createBookmark(parentId, url, title, index = bms.DEFAULT_INDEX) { + let uri = Utils.makeURI(url); + let id = bms.insertBookmark(parentId, uri, index, title) + let guid = store.GUIDForId(id); + return { id, guid }; +} + +function getServerRecord(collection, id) { + let wbo = collection.get({ full: true, ids: [id] }); + // Whew - lots of json strings inside strings. + return JSON.parse(JSON.parse(JSON.parse(wbo).payload).ciphertext); +} + +function* promiseNoLocalItem(guid) { + // Check there's no item with the specified guid. + let got = yield bms.fetch({ guid }); + ok(!got, `No record remains with GUID ${guid}`); + // and while we are here ensure the places cache doesn't still have it. + yield Assert.rejects(PlacesUtils.promiseItemId(guid)); +} + +function* validate(collection, expectedFailures = []) { + let validator = new BookmarkValidator(); + let records = collection.payloads(); + + let problems = validator.inspectServerRecords(records).problemData; + // all non-zero problems. + let summary = problems.getSummary().filter(prob => prob.count != 0); + + // split into 2 arrays - expected and unexpected. + let isInExpectedFailures = elt => { + for (let i = 0; i < expectedFailures.length; i++) { + if (elt.name == expectedFailures[i].name && elt.count == expectedFailures[i].count) { + return true; + } + } + return false; + } + let expected = []; + let unexpected = []; + for (let elt of summary) { + (isInExpectedFailures(elt) ? expected : unexpected).push(elt); + } + if (unexpected.length || expected.length != expectedFailures.length) { + do_print("Validation failed:"); + do_print(JSON.stringify(summary)); + // print the entire validator output as it has IDs etc. + do_print(JSON.stringify(problems, undefined, 2)); + // All server records and the entire bookmark tree. + do_print("Server records:\n" + JSON.stringify(collection.payloads(), undefined, 2)); + let tree = yield PlacesUtils.promiseBookmarksTree("", { includeItemIds: true }); + do_print("Local bookmark tree:\n" + JSON.stringify(tree, undefined, 2)); + ok(false); + } +} + +add_task(function* test_dupe_bookmark() { + _("Ensure that a bookmark we consider a dupe is handled correctly."); + + let { server, collection } = this.setup(); + + try { + // The parent folder and one bookmark in it. + let {id: folder1_id, guid: folder1_guid } = createFolder(bms.toolbarFolder, "Folder 1"); + let {id: bmk1_id, guid: bmk1_guid} = createBookmark(folder1_id, "http://getfirefox.com/", "Get Firefox!"); + + engine.sync(); + + // We've added the bookmark, its parent (folder1) plus "menu", "toolbar", "unfiled", and "mobile". + equal(collection.count(), 6); + equal(getFolderChildrenIDs(folder1_id).length, 1); + + // Now create a new incoming record that looks alot like a dupe. + let newGUID = Utils.makeGUID(); + let to_apply = { + id: newGUID, + bmkUri: "http://getfirefox.com/", + type: "bookmark", + title: "Get Firefox!", + parentName: "Folder 1", + parentid: folder1_guid, + }; + + collection.insert(newGUID, encryptPayload(to_apply), Date.now() / 1000 + 10); + _("Syncing so new dupe record is processed"); + engine.lastSync = engine.lastSync - 0.01; + engine.sync(); + + // We should have logically deleted the dupe record. + equal(collection.count(), 7); + ok(getServerRecord(collection, bmk1_guid).deleted); + // and physically removed from the local store. + yield promiseNoLocalItem(bmk1_guid); + // Parent should still only have 1 item. + equal(getFolderChildrenIDs(folder1_id).length, 1); + // The parent record on the server should now reference the new GUID and not the old. + let serverRecord = getServerRecord(collection, folder1_guid); + ok(!serverRecord.children.includes(bmk1_guid)); + ok(serverRecord.children.includes(newGUID)); + + // and a final sanity check - use the validator + yield validate(collection); + } finally { + yield cleanup(server); + } +}); + +add_task(function* test_dupe_reparented_bookmark() { + _("Ensure that a bookmark we consider a dupe from a different parent is handled correctly"); + + let { server, collection } = this.setup(); + + try { + // The parent folder and one bookmark in it. + let {id: folder1_id, guid: folder1_guid } = createFolder(bms.toolbarFolder, "Folder 1"); + let {id: bmk1_id, guid: bmk1_guid} = createBookmark(folder1_id, "http://getfirefox.com/", "Get Firefox!"); + // Another parent folder *with the same name* + let {id: folder2_id, guid: folder2_guid } = createFolder(bms.toolbarFolder, "Folder 1"); + + do_print(`folder1_guid=${folder1_guid}, folder2_guid=${folder2_guid}, bmk1_guid=${bmk1_guid}`); + + engine.sync(); + + // We've added the bookmark, 2 folders plus "menu", "toolbar", "unfiled", and "mobile". + equal(collection.count(), 7); + equal(getFolderChildrenIDs(folder1_id).length, 1); + equal(getFolderChildrenIDs(folder2_id).length, 0); + + // Now create a new incoming record that looks alot like a dupe of the + // item in folder1_guid, but with a record that points to folder2_guid. + let newGUID = Utils.makeGUID(); + let to_apply = { + id: newGUID, + bmkUri: "http://getfirefox.com/", + type: "bookmark", + title: "Get Firefox!", + parentName: "Folder 1", + parentid: folder2_guid, + }; + + collection.insert(newGUID, encryptPayload(to_apply), Date.now() / 1000 + 10); + + _("Syncing so new dupe record is processed"); + engine.lastSync = engine.lastSync - 0.01; + engine.sync(); + + // We should have logically deleted the dupe record. + equal(collection.count(), 8); + ok(getServerRecord(collection, bmk1_guid).deleted); + // and physically removed from the local store. + yield promiseNoLocalItem(bmk1_guid); + // The original folder no longer has the item + equal(getFolderChildrenIDs(folder1_id).length, 0); + // But the second dupe folder does. + equal(getFolderChildrenIDs(folder2_id).length, 1); + + // The record for folder1 on the server should reference neither old or new GUIDs. + let serverRecord1 = getServerRecord(collection, folder1_guid); + ok(!serverRecord1.children.includes(bmk1_guid)); + ok(!serverRecord1.children.includes(newGUID)); + + // The record for folder2 on the server should only reference the new new GUID. + let serverRecord2 = getServerRecord(collection, folder2_guid); + ok(!serverRecord2.children.includes(bmk1_guid)); + ok(serverRecord2.children.includes(newGUID)); + + // and a final sanity check - use the validator + yield validate(collection); + } finally { + yield cleanup(server); + } +}); + +add_task(function* test_dupe_reparented_locally_changed_bookmark() { + _("Ensure that a bookmark with local changes we consider a dupe from a different parent is handled correctly"); + + let { server, collection } = this.setup(); + + try { + // The parent folder and one bookmark in it. + let {id: folder1_id, guid: folder1_guid } = createFolder(bms.toolbarFolder, "Folder 1"); + let {id: bmk1_id, guid: bmk1_guid} = createBookmark(folder1_id, "http://getfirefox.com/", "Get Firefox!"); + // Another parent folder *with the same name* + let {id: folder2_id, guid: folder2_guid } = createFolder(bms.toolbarFolder, "Folder 1"); + + do_print(`folder1_guid=${folder1_guid}, folder2_guid=${folder2_guid}, bmk1_guid=${bmk1_guid}`); + + engine.sync(); + + // We've added the bookmark, 2 folders plus "menu", "toolbar", "unfiled", and "mobile". + equal(collection.count(), 7); + equal(getFolderChildrenIDs(folder1_id).length, 1); + equal(getFolderChildrenIDs(folder2_id).length, 0); + + // Now create a new incoming record that looks alot like a dupe of the + // item in folder1_guid, but with a record that points to folder2_guid. + let newGUID = Utils.makeGUID(); + let to_apply = { + id: newGUID, + bmkUri: "http://getfirefox.com/", + type: "bookmark", + title: "Get Firefox!", + parentName: "Folder 1", + parentid: folder2_guid, + }; + + collection.insert(newGUID, encryptPayload(to_apply), Date.now() / 1000 + 10); + + // Make a change to the bookmark that's a dupe, and set the modification + // time further in the future than the incoming record. This will cause + // us to issue the infamous "DATA LOSS" warning in the logs but cause us + // to *not* apply the incoming record. + engine._tracker.addChangedID(bmk1_guid, Date.now() / 1000 + 60); + + _("Syncing so new dupe record is processed"); + engine.lastSync = engine.lastSync - 0.01; + engine.sync(); + + // We should have logically deleted the dupe record. + equal(collection.count(), 8); + ok(getServerRecord(collection, bmk1_guid).deleted); + // and physically removed from the local store. + yield promiseNoLocalItem(bmk1_guid); + // The original folder still longer has the item + equal(getFolderChildrenIDs(folder1_id).length, 1); + // The second folder does not. + equal(getFolderChildrenIDs(folder2_id).length, 0); + + // The record for folder1 on the server should reference only the GUID. + let serverRecord1 = getServerRecord(collection, folder1_guid); + ok(!serverRecord1.children.includes(bmk1_guid)); + ok(serverRecord1.children.includes(newGUID)); + + // The record for folder2 on the server should reference nothing. + let serverRecord2 = getServerRecord(collection, folder2_guid); + ok(!serverRecord2.children.includes(bmk1_guid)); + ok(!serverRecord2.children.includes(newGUID)); + + // and a final sanity check - use the validator + yield validate(collection); + } finally { + yield cleanup(server); + } +}); + +add_task(function* test_dupe_reparented_to_earlier_appearing_parent_bookmark() { + _("Ensure that a bookmark we consider a dupe from a different parent that " + + "appears in the same sync before the dupe item"); + + let { server, collection } = this.setup(); + + try { + // The parent folder and one bookmark in it. + let {id: folder1_id, guid: folder1_guid } = createFolder(bms.toolbarFolder, "Folder 1"); + let {id: bmk1_id, guid: bmk1_guid} = createBookmark(folder1_id, "http://getfirefox.com/", "Get Firefox!"); + // One more folder we'll use later. + let {id: folder2_id, guid: folder2_guid} = createFolder(bms.toolbarFolder, "A second folder"); + + do_print(`folder1=${folder1_guid}, bmk1=${bmk1_guid} folder2=${folder2_guid}`); + + engine.sync(); + + // We've added the bookmark, 2 folders plus "menu", "toolbar", "unfiled", and "mobile". + equal(collection.count(), 7); + equal(getFolderChildrenIDs(folder1_id).length, 1); + + let newGUID = Utils.makeGUID(); + let newParentGUID = Utils.makeGUID(); + + // Have the new parent appear before the dupe item. + collection.insert(newParentGUID, encryptPayload({ + id: newParentGUID, + type: "folder", + title: "Folder 1", + parentName: "A second folder", + parentid: folder2_guid, + children: [newGUID], + tags: [], + }), Date.now() / 1000 + 10); + + // And also the update to "folder 2" that references the new parent. + collection.insert(folder2_guid, encryptPayload({ + id: folder2_guid, + type: "folder", + title: "A second folder", + parentName: "Bookmarks Toolbar", + parentid: "toolbar", + children: [newParentGUID], + tags: [], + }), Date.now() / 1000 + 10); + + // Now create a new incoming record that looks alot like a dupe of the + // item in folder1_guid, with a record that points to a parent with the + // same name which appeared earlier in this sync. + collection.insert(newGUID, encryptPayload({ + id: newGUID, + bmkUri: "http://getfirefox.com/", + type: "bookmark", + title: "Get Firefox!", + parentName: "Folder 1", + parentid: newParentGUID, + tags: [], + }), Date.now() / 1000 + 10); + + + _("Syncing so new records are processed."); + engine.lastSync = engine.lastSync - 0.01; + engine.sync(); + + // Everything should be parented correctly. + equal(getFolderChildrenIDs(folder1_id).length, 0); + let newParentID = store.idForGUID(newParentGUID); + let newID = store.idForGUID(newGUID); + deepEqual(getFolderChildrenIDs(newParentID), [newID]); + + // Make sure the validator thinks everything is hunky-dory. + yield validate(collection); + } finally { + yield cleanup(server); + } +}); + +add_task(function* test_dupe_reparented_to_later_appearing_parent_bookmark() { + _("Ensure that a bookmark we consider a dupe from a different parent that " + + "doesn't exist locally as we process the child, but does appear in the same sync"); + + let { server, collection } = this.setup(); + + try { + // The parent folder and one bookmark in it. + let {id: folder1_id, guid: folder1_guid } = createFolder(bms.toolbarFolder, "Folder 1"); + let {id: bmk1_id, guid: bmk1_guid} = createBookmark(folder1_id, "http://getfirefox.com/", "Get Firefox!"); + // One more folder we'll use later. + let {id: folder2_id, guid: folder2_guid} = createFolder(bms.toolbarFolder, "A second folder"); + + do_print(`folder1=${folder1_guid}, bmk1=${bmk1_guid} folder2=${folder2_guid}`); + + engine.sync(); + + // We've added the bookmark, 2 folders plus "menu", "toolbar", "unfiled", and "mobile". + equal(collection.count(), 7); + equal(getFolderChildrenIDs(folder1_id).length, 1); + + // Now create a new incoming record that looks alot like a dupe of the + // item in folder1_guid, but with a record that points to a parent with the + // same name, but a non-existing local ID. + let newGUID = Utils.makeGUID(); + let newParentGUID = Utils.makeGUID(); + + collection.insert(newGUID, encryptPayload({ + id: newGUID, + bmkUri: "http://getfirefox.com/", + type: "bookmark", + title: "Get Firefox!", + parentName: "Folder 1", + parentid: newParentGUID, + tags: [], + }), Date.now() / 1000 + 10); + + // Now have the parent appear after (so when the record above is processed + // this is still unknown.) + collection.insert(newParentGUID, encryptPayload({ + id: newParentGUID, + type: "folder", + title: "Folder 1", + parentName: "A second folder", + parentid: folder2_guid, + children: [newGUID], + tags: [], + }), Date.now() / 1000 + 10); + // And also the update to "folder 2" that references the new parent. + collection.insert(folder2_guid, encryptPayload({ + id: folder2_guid, + type: "folder", + title: "A second folder", + parentName: "Bookmarks Toolbar", + parentid: "toolbar", + children: [newParentGUID], + tags: [], + }), Date.now() / 1000 + 10); + + _("Syncing so out-of-order records are processed."); + engine.lastSync = engine.lastSync - 0.01; + engine.sync(); + + // The intended parent did end up existing, so it should be parented + // correctly after de-duplication. + equal(getFolderChildrenIDs(folder1_id).length, 0); + let newParentID = store.idForGUID(newParentGUID); + let newID = store.idForGUID(newGUID); + deepEqual(getFolderChildrenIDs(newParentID), [newID]); + + // Make sure the validator thinks everything is hunky-dory. + yield validate(collection); + } finally { + yield cleanup(server); + } +}); + +add_task(function* test_dupe_reparented_to_future_arriving_parent_bookmark() { + _("Ensure that a bookmark we consider a dupe from a different parent that " + + "doesn't exist locally and doesn't appear in this Sync is handled correctly"); + + let { server, collection } = this.setup(); + + try { + // The parent folder and one bookmark in it. + let {id: folder1_id, guid: folder1_guid } = createFolder(bms.toolbarFolder, "Folder 1"); + let {id: bmk1_id, guid: bmk1_guid} = createBookmark(folder1_id, "http://getfirefox.com/", "Get Firefox!"); + // One more folder we'll use later. + let {id: folder2_id, guid: folder2_guid} = createFolder(bms.toolbarFolder, "A second folder"); + + do_print(`folder1=${folder1_guid}, bmk1=${bmk1_guid} folder2=${folder2_guid}`); + + engine.sync(); + + // We've added the bookmark, 2 folders plus "menu", "toolbar", "unfiled", and "mobile". + equal(collection.count(), 7); + equal(getFolderChildrenIDs(folder1_id).length, 1); + + // Now create a new incoming record that looks alot like a dupe of the + // item in folder1_guid, but with a record that points to a parent with the + // same name, but a non-existing local ID. + let newGUID = Utils.makeGUID(); + let newParentGUID = Utils.makeGUID(); + + collection.insert(newGUID, encryptPayload({ + id: newGUID, + bmkUri: "http://getfirefox.com/", + type: "bookmark", + title: "Get Firefox!", + parentName: "Folder 1", + parentid: newParentGUID, + tags: [], + }), Date.now() / 1000 + 10); + + _("Syncing so new dupe record is processed"); + engine.lastSync = engine.lastSync - 0.01; + engine.sync(); + + // We should have logically deleted the dupe record. + equal(collection.count(), 8); + ok(getServerRecord(collection, bmk1_guid).deleted); + // and physically removed from the local store. + yield promiseNoLocalItem(bmk1_guid); + // The intended parent doesn't exist, so it remains in the original folder + equal(getFolderChildrenIDs(folder1_id).length, 1); + + // The record for folder1 on the server should reference the new GUID. + let serverRecord1 = getServerRecord(collection, folder1_guid); + ok(!serverRecord1.children.includes(bmk1_guid)); + ok(serverRecord1.children.includes(newGUID)); + + // As the incoming parent is missing the item should have been annotated + // with that missing parent. + equal(PlacesUtils.annotations.getItemAnnotation(store.idForGUID(newGUID), "sync/parent"), + newParentGUID); + + // Check the validator. Sadly, this is known to cause a mismatch between + // the server and client views of the tree. + let expected = [ + // We haven't fixed the incoming record that referenced the missing parent. + { name: "orphans", count: 1 }, + ]; + yield validate(collection, expected); + + // Now have the parent magically appear in a later sync - but + // it appears as being in a different parent from our existing "Folder 1", + // so the folder itself isn't duped. + collection.insert(newParentGUID, encryptPayload({ + id: newParentGUID, + type: "folder", + title: "Folder 1", + parentName: "A second folder", + parentid: folder2_guid, + children: [newGUID], + tags: [], + }), Date.now() / 1000 + 10); + // We also queue an update to "folder 2" that references the new parent. + collection.insert(folder2_guid, encryptPayload({ + id: folder2_guid, + type: "folder", + title: "A second folder", + parentName: "Bookmarks Toolbar", + parentid: "toolbar", + children: [newParentGUID], + tags: [], + }), Date.now() / 1000 + 10); + + _("Syncing so missing parent appears"); + engine.lastSync = engine.lastSync - 0.01; + engine.sync(); + + // The intended parent now does exist, so it should have been reparented. + equal(getFolderChildrenIDs(folder1_id).length, 0); + let newParentID = store.idForGUID(newParentGUID); + let newID = store.idForGUID(newGUID); + deepEqual(getFolderChildrenIDs(newParentID), [newID]); + + // validation now has different errors :( + expected = [ + // The validator reports multipleParents because: + // * The incoming record newParentGUID still (and correctly) references + // newGUID as a child. + // * Our original Folder1 was updated to include newGUID when it + // originally de-deuped and couldn't find the parent. + // * When the parent *did* eventually arrive we used the parent annotation + // to correctly reparent - but that reparenting process does not change + // the server record. + // Hence, newGUID is a child of both those server records :( + { name: "multipleParents", count: 1 }, + ]; + yield validate(collection, expected); + + } finally { + yield cleanup(server); + } +}); + +add_task(function* test_dupe_empty_folder() { + _("Ensure that an empty folder we consider a dupe is handled correctly."); + // Empty folders aren't particularly interesting in practice (as that seems + // an edge-case) but duping folders with items is broken - bug 1293163. + let { server, collection } = this.setup(); + + try { + // The folder we will end up duping away. + let {id: folder1_id, guid: folder1_guid } = createFolder(bms.toolbarFolder, "Folder 1"); + + engine.sync(); + + // We've added 1 folder, "menu", "toolbar", "unfiled", and "mobile". + equal(collection.count(), 5); + + // Now create new incoming records that looks alot like a dupe of "Folder 1". + let newFolderGUID = Utils.makeGUID(); + collection.insert(newFolderGUID, encryptPayload({ + id: newFolderGUID, + type: "folder", + title: "Folder 1", + parentName: "Bookmarks Toolbar", + parentid: "toolbar", + children: [], + }), Date.now() / 1000 + 10); + + _("Syncing so new dupe records are processed"); + engine.lastSync = engine.lastSync - 0.01; + engine.sync(); + + yield validate(collection); + + // Collection now has one additional record - the logically deleted dupe. + equal(collection.count(), 6); + // original folder should be logically deleted. + ok(getServerRecord(collection, folder1_guid).deleted); + yield promiseNoLocalItem(folder1_guid); + } finally { + yield cleanup(server); + } +}); +// XXX - TODO - folders with children. Bug 1293163 diff --git a/services/sync/tests/unit/test_bookmark_engine.js b/services/sync/tests/unit/test_bookmark_engine.js new file mode 100644 index 0000000000..9de6c5c0dc --- /dev/null +++ b/services/sync/tests/unit/test_bookmark_engine.js @@ -0,0 +1,665 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +Cu.import("resource://gre/modules/PlacesUtils.jsm"); +Cu.import("resource://gre/modules/PlacesSyncUtils.jsm"); +Cu.import("resource://gre/modules/BookmarkJSONUtils.jsm"); +Cu.import("resource://gre/modules/Log.jsm"); +Cu.import("resource://services-sync/constants.js"); +Cu.import("resource://services-sync/engines.js"); +Cu.import("resource://services-sync/engines/bookmarks.js"); +Cu.import("resource://services-sync/service.js"); +Cu.import("resource://services-sync/util.js"); +Cu.import("resource://testing-common/services/sync/utils.js"); +Cu.import("resource://gre/modules/Promise.jsm"); + +initTestLogging("Trace"); + +Service.engineManager.register(BookmarksEngine); + +function* assertChildGuids(folderGuid, expectedChildGuids, message) { + let tree = yield PlacesUtils.promiseBookmarksTree(folderGuid); + let childGuids = tree.children.map(child => child.guid); + deepEqual(childGuids, expectedChildGuids, message); +} + +add_task(function* test_change_during_sync() { + _("Ensure that we track changes made during a sync."); + + let engine = new BookmarksEngine(Service); + let store = engine._store; + let tracker = engine._tracker; + let server = serverForFoo(engine); + new SyncTestingInfrastructure(server.server); + + let collection = server.user("foo").collection("bookmarks"); + + let bz_id = PlacesUtils.bookmarks.insertBookmark( + PlacesUtils.bookmarksMenuFolderId, Utils.makeURI("https://bugzilla.mozilla.org/"), + PlacesUtils.bookmarks.DEFAULT_INDEX, "Bugzilla"); + let bz_guid = yield PlacesUtils.promiseItemGuid(bz_id); + _(`Bugzilla GUID: ${bz_guid}`); + + Svc.Obs.notify("weave:engine:start-tracking"); + + try { + let folder1_id = PlacesUtils.bookmarks.createFolder( + PlacesUtils.bookmarks.toolbarFolder, "Folder 1", 0); + let folder1_guid = store.GUIDForId(folder1_id); + _(`Folder GUID: ${folder1_guid}`); + + let bmk1_id = PlacesUtils.bookmarks.insertBookmark( + folder1_id, Utils.makeURI("http://getthunderbird.com/"), + PlacesUtils.bookmarks.DEFAULT_INDEX, "Get Thunderbird!"); + let bmk1_guid = store.GUIDForId(bmk1_id); + _(`Thunderbird GUID: ${bmk1_guid}`); + + // Sync is synchronous, so, to simulate a bookmark change made during a + // sync, we create a server record that adds a bookmark as a side effect. + let bmk2_guid = "get-firefox1"; // New child of Folder 1, created remotely. + let bmk3_id = -1; // New child of Folder 1, created locally during sync. + let folder2_guid = "folder2-1111"; // New folder, created remotely. + let tagQuery_guid = "tag-query111"; // New tag query child of Folder 2, created remotely. + let bmk4_guid = "example-org1"; // New tagged child of Folder 2, created remotely. + { + // An existing record changed on the server that should not trigger + // another sync when applied. + let bzBmk = new Bookmark("bookmarks", bz_guid); + bzBmk.bmkUri = "https://bugzilla.mozilla.org/"; + bzBmk.description = "New description"; + bzBmk.title = "Bugzilla"; + bzBmk.tags = ["new", "tags"]; + bzBmk.parentName = "Bookmarks Toolbar"; + bzBmk.parentid = "toolbar"; + collection.insert(bz_guid, encryptPayload(bzBmk.cleartext)); + + let remoteFolder = new BookmarkFolder("bookmarks", folder2_guid); + remoteFolder.title = "Folder 2"; + remoteFolder.children = [bmk4_guid, tagQuery_guid]; + remoteFolder.parentName = "Bookmarks Menu"; + remoteFolder.parentid = "menu"; + collection.insert(folder2_guid, encryptPayload(remoteFolder.cleartext)); + + let localFxBmk = new Bookmark("bookmarks", bmk2_guid); + localFxBmk.bmkUri = "http://getfirefox.com/"; + localFxBmk.description = "Firefox is awesome."; + localFxBmk.title = "Get Firefox!"; + localFxBmk.tags = ["firefox", "awesome", "browser"]; + localFxBmk.keyword = "awesome"; + localFxBmk.loadInSidebar = false; + localFxBmk.parentName = "Folder 1"; + localFxBmk.parentid = folder1_guid; + let remoteFxBmk = collection.insert(bmk2_guid, encryptPayload(localFxBmk.cleartext)); + remoteFxBmk.get = function get() { + _("Inserting bookmark into local store"); + bmk3_id = PlacesUtils.bookmarks.insertBookmark( + folder1_id, Utils.makeURI("https://mozilla.org/"), + PlacesUtils.bookmarks.DEFAULT_INDEX, "Mozilla"); + + return ServerWBO.prototype.get.apply(this, arguments); + }; + + // A tag query referencing a nonexistent tag folder, which we should + // create locally when applying the record. + let localTagQuery = new BookmarkQuery("bookmarks", tagQuery_guid); + localTagQuery.bmkUri = "place:type=7&folder=999"; + localTagQuery.title = "Taggy tags"; + localTagQuery.folderName = "taggy"; + localTagQuery.parentName = "Folder 2"; + localTagQuery.parentid = folder2_guid; + collection.insert(tagQuery_guid, encryptPayload(localTagQuery.cleartext)); + + // A bookmark that should appear in the results for the tag query. + let localTaggedBmk = new Bookmark("bookmarks", bmk4_guid); + localTaggedBmk.bmkUri = "https://example.org"; + localTaggedBmk.title = "Tagged bookmark"; + localTaggedBmk.tags = ["taggy"]; + localTaggedBmk.parentName = "Folder 2"; + localTaggedBmk.parentid = folder2_guid; + collection.insert(bmk4_guid, encryptPayload(localTaggedBmk.cleartext)); + } + + yield* assertChildGuids(folder1_guid, [bmk1_guid], "Folder should have 1 child before first sync"); + + _("Perform first sync"); + { + let changes = engine.pullNewChanges(); + deepEqual(changes.ids().sort(), [folder1_guid, bmk1_guid, "toolbar"].sort(), + "Should track bookmark and folder created before first sync"); + yield sync_engine_and_validate_telem(engine, false); + } + + let bmk2_id = store.idForGUID(bmk2_guid); + let bmk3_guid = store.GUIDForId(bmk3_id); + _(`Mozilla GUID: ${bmk3_guid}`); + { + equal(store.GUIDForId(bmk2_id), bmk2_guid, + "Remote bookmark should be applied during first sync"); + ok(bmk3_id > -1, + "Bookmark created during first sync should exist locally"); + ok(!collection.wbo(bmk3_guid), + "Bookmark created during first sync shouldn't be uploaded yet"); + + yield* assertChildGuids(folder1_guid, [bmk1_guid, bmk3_guid, bmk2_guid], + "Folder 1 should have 3 children after first sync"); + yield* assertChildGuids(folder2_guid, [bmk4_guid, tagQuery_guid], + "Folder 2 should have 2 children after first sync"); + let taggedURIs = PlacesUtils.tagging.getURIsForTag("taggy"); + equal(taggedURIs.length, 1, "Should have 1 tagged URI"); + equal(taggedURIs[0].spec, "https://example.org/", + "Synced tagged bookmark should appear in tagged URI list"); + } + + _("Perform second sync"); + { + let changes = engine.pullNewChanges(); + deepEqual(changes.ids().sort(), [bmk3_guid, folder1_guid].sort(), + "Should track bookmark added during last sync and its parent"); + yield sync_engine_and_validate_telem(engine, false); + + ok(collection.wbo(bmk3_guid), + "Bookmark created during first sync should be uploaded during second sync"); + + yield* assertChildGuids(folder1_guid, [bmk1_guid, bmk3_guid, bmk2_guid], + "Folder 1 should have same children after second sync"); + yield* assertChildGuids(folder2_guid, [bmk4_guid, tagQuery_guid], + "Folder 2 should have same children after second sync"); + } + } finally { + store.wipe(); + Svc.Prefs.resetBranch(""); + Service.recordManager.clearCache(); + yield new Promise(resolve => server.stop(resolve)); + Svc.Obs.notify("weave:engine:stop-tracking"); + } +}); + +add_task(function* bad_record_allIDs() { + let server = new SyncServer(); + server.start(); + let syncTesting = new SyncTestingInfrastructure(server.server); + + _("Ensure that bad Places queries don't cause an error in getAllIDs."); + let engine = new BookmarksEngine(Service); + let store = engine._store; + let badRecordID = PlacesUtils.bookmarks.insertBookmark( + PlacesUtils.bookmarks.toolbarFolder, + Utils.makeURI("place:folder=1138"), + PlacesUtils.bookmarks.DEFAULT_INDEX, + null); + + do_check_true(badRecordID > 0); + _("Record is " + badRecordID); + _("Type: " + PlacesUtils.bookmarks.getItemType(badRecordID)); + + _("Fetching all IDs."); + let all = store.getAllIDs(); + + _("All IDs: " + JSON.stringify(all)); + do_check_true("menu" in all); + do_check_true("toolbar" in all); + + _("Clean up."); + PlacesUtils.bookmarks.removeItem(badRecordID); + yield new Promise(r => server.stop(r)); +}); + +function serverForFoo(engine) { + return serverForUsers({"foo": "password"}, { + meta: {global: {engines: {bookmarks: {version: engine.version, + syncID: engine.syncID}}}}, + bookmarks: {} + }); +} + +add_task(function* test_processIncoming_error_orderChildren() { + _("Ensure that _orderChildren() is called even when _processIncoming() throws an error."); + + let engine = new BookmarksEngine(Service); + let store = engine._store; + let server = serverForFoo(engine); + new SyncTestingInfrastructure(server.server); + + let collection = server.user("foo").collection("bookmarks"); + + try { + + let folder1_id = PlacesUtils.bookmarks.createFolder( + PlacesUtils.bookmarks.toolbarFolder, "Folder 1", 0); + let folder1_guid = store.GUIDForId(folder1_id); + + let fxuri = Utils.makeURI("http://getfirefox.com/"); + let tburi = Utils.makeURI("http://getthunderbird.com/"); + + let bmk1_id = PlacesUtils.bookmarks.insertBookmark( + folder1_id, fxuri, PlacesUtils.bookmarks.DEFAULT_INDEX, "Get Firefox!"); + let bmk1_guid = store.GUIDForId(bmk1_id); + let bmk2_id = PlacesUtils.bookmarks.insertBookmark( + folder1_id, tburi, PlacesUtils.bookmarks.DEFAULT_INDEX, "Get Thunderbird!"); + let bmk2_guid = store.GUIDForId(bmk2_id); + + // Create a server record for folder1 where we flip the order of + // the children. + let folder1_payload = store.createRecord(folder1_guid).cleartext; + folder1_payload.children.reverse(); + collection.insert(folder1_guid, encryptPayload(folder1_payload)); + + // Create a bogus record that when synced down will provoke a + // network error which in turn provokes an exception in _processIncoming. + const BOGUS_GUID = "zzzzzzzzzzzz"; + let bogus_record = collection.insert(BOGUS_GUID, "I'm a bogus record!"); + bogus_record.get = function get() { + throw "Sync this!"; + }; + + // Make the 10 minutes old so it will only be synced in the toFetch phase. + bogus_record.modified = Date.now() / 1000 - 60 * 10; + engine.lastSync = Date.now() / 1000 - 60; + engine.toFetch = [BOGUS_GUID]; + + let error; + try { + yield sync_engine_and_validate_telem(engine, true) + } catch(ex) { + error = ex; + } + ok(!!error); + + // Verify that the bookmark order has been applied. + let new_children = store.createRecord(folder1_guid).children; + do_check_eq(new_children.length, 2); + do_check_eq(new_children[0], folder1_payload.children[0]); + do_check_eq(new_children[1], folder1_payload.children[1]); + + do_check_eq(PlacesUtils.bookmarks.getItemIndex(bmk1_id), 1); + do_check_eq(PlacesUtils.bookmarks.getItemIndex(bmk2_id), 0); + + } finally { + store.wipe(); + Svc.Prefs.resetBranch(""); + Service.recordManager.clearCache(); + yield new Promise(resolve => server.stop(resolve)); + } +}); + +add_task(function* test_restorePromptsReupload() { + _("Ensure that restoring from a backup will reupload all records."); + let engine = new BookmarksEngine(Service); + let store = engine._store; + let server = serverForFoo(engine); + new SyncTestingInfrastructure(server.server); + + let collection = server.user("foo").collection("bookmarks"); + + Svc.Obs.notify("weave:engine:start-tracking"); // We skip usual startup... + + try { + + let folder1_id = PlacesUtils.bookmarks.createFolder( + PlacesUtils.bookmarks.toolbarFolder, "Folder 1", 0); + let folder1_guid = store.GUIDForId(folder1_id); + _("Folder 1: " + folder1_id + ", " + folder1_guid); + + let fxuri = Utils.makeURI("http://getfirefox.com/"); + let tburi = Utils.makeURI("http://getthunderbird.com/"); + + _("Create a single record."); + let bmk1_id = PlacesUtils.bookmarks.insertBookmark( + folder1_id, fxuri, PlacesUtils.bookmarks.DEFAULT_INDEX, "Get Firefox!"); + let bmk1_guid = store.GUIDForId(bmk1_id); + _("Get Firefox!: " + bmk1_id + ", " + bmk1_guid); + + + let dirSvc = Cc["@mozilla.org/file/directory_service;1"] + .getService(Ci.nsIProperties); + + let backupFile = dirSvc.get("TmpD", Ci.nsILocalFile); + + _("Make a backup."); + backupFile.append("t_b_e_" + Date.now() + ".json"); + + _("Backing up to file " + backupFile.path); + yield BookmarkJSONUtils.exportToFile(backupFile.path); + + _("Create a different record and sync."); + let bmk2_id = PlacesUtils.bookmarks.insertBookmark( + folder1_id, tburi, PlacesUtils.bookmarks.DEFAULT_INDEX, "Get Thunderbird!"); + let bmk2_guid = store.GUIDForId(bmk2_id); + _("Get Thunderbird!: " + bmk2_id + ", " + bmk2_guid); + + PlacesUtils.bookmarks.removeItem(bmk1_id); + + let error; + try { + yield sync_engine_and_validate_telem(engine, false); + } catch(ex) { + error = ex; + _("Got error: " + Log.exceptionStr(ex)); + } + do_check_true(!error); + + _("Verify that there's only one bookmark on the server, and it's Thunderbird."); + // Of course, there's also the Bookmarks Toolbar and Bookmarks Menu... + let wbos = collection.keys(function (id) { + return ["menu", "toolbar", "mobile", "unfiled", folder1_guid].indexOf(id) == -1; + }); + do_check_eq(wbos.length, 1); + do_check_eq(wbos[0], bmk2_guid); + + _("Now restore from a backup."); + yield BookmarkJSONUtils.importFromFile(backupFile, true); + + _("Ensure we have the bookmarks we expect locally."); + let guids = store.getAllIDs(); + _("GUIDs: " + JSON.stringify(guids)); + let found = false; + let count = 0; + let newFX; + for (let guid in guids) { + count++; + let id = store.idForGUID(guid, true); + // Only one bookmark, so _all_ should be Firefox! + if (PlacesUtils.bookmarks.getItemType(id) == PlacesUtils.bookmarks.TYPE_BOOKMARK) { + let uri = PlacesUtils.bookmarks.getBookmarkURI(id); + _("Found URI " + uri.spec + " for GUID " + guid); + do_check_eq(uri.spec, fxuri.spec); + newFX = guid; // Save the new GUID after restore. + found = true; // Only runs if the above check passes. + } + } + _("We found it: " + found); + do_check_true(found); + + _("Have the correct number of IDs locally, too."); + do_check_eq(count, ["menu", "toolbar", "mobile", "unfiled", folder1_id, bmk1_id].length); + + _("Sync again. This'll wipe bookmarks from the server."); + try { + yield sync_engine_and_validate_telem(engine, false); + } catch(ex) { + error = ex; + _("Got error: " + Log.exceptionStr(ex)); + } + do_check_true(!error); + + _("Verify that there's only one bookmark on the server, and it's Firefox."); + // Of course, there's also the Bookmarks Toolbar and Bookmarks Menu... + let payloads = server.user("foo").collection("bookmarks").payloads(); + let bookmarkWBOs = payloads.filter(function (wbo) { + return wbo.type == "bookmark"; + }); + let folderWBOs = payloads.filter(function (wbo) { + return ((wbo.type == "folder") && + (wbo.id != "menu") && + (wbo.id != "toolbar") && + (wbo.id != "unfiled") && + (wbo.id != "mobile")); + }); + + do_check_eq(bookmarkWBOs.length, 1); + do_check_eq(bookmarkWBOs[0].id, newFX); + do_check_eq(bookmarkWBOs[0].bmkUri, fxuri.spec); + do_check_eq(bookmarkWBOs[0].title, "Get Firefox!"); + + _("Our old friend Folder 1 is still in play."); + do_check_eq(folderWBOs.length, 1); + do_check_eq(folderWBOs[0].title, "Folder 1"); + + } finally { + store.wipe(); + Svc.Prefs.resetBranch(""); + Service.recordManager.clearCache(); + let deferred = Promise.defer(); + server.stop(deferred.resolve); + yield deferred.promise; + } +}); + +function FakeRecord(constructor, r) { + constructor.call(this, "bookmarks", r.id); + for (let x in r) { + this[x] = r[x]; + } + // Borrow the constructor's conversion functions. + this.toSyncBookmark = constructor.prototype.toSyncBookmark; +} + +// Bug 632287. +add_task(function* test_mismatched_types() { + _("Ensure that handling a record that changes type causes deletion " + + "then re-adding."); + + let oldRecord = { + "id": "l1nZZXfB8nC7", + "type":"folder", + "parentName":"Bookmarks Toolbar", + "title":"Innerst i Sneglehode", + "description":null, + "parentid": "toolbar" + }; + oldRecord.cleartext = oldRecord; + + let newRecord = { + "id": "l1nZZXfB8nC7", + "type":"livemark", + "siteUri":"http://sneglehode.wordpress.com/", + "feedUri":"http://sneglehode.wordpress.com/feed/", + "parentName":"Bookmarks Toolbar", + "title":"Innerst i Sneglehode", + "description":null, + "children": + ["HCRq40Rnxhrd", "YeyWCV1RVsYw", "GCceVZMhvMbP", "sYi2hevdArlF", + "vjbZlPlSyGY8", "UtjUhVyrpeG6", "rVq8WMG2wfZI", "Lx0tcy43ZKhZ", + "oT74WwV8_j4P", "IztsItWVSo3-"], + "parentid": "toolbar" + }; + newRecord.cleartext = newRecord; + + let engine = new BookmarksEngine(Service); + let store = engine._store; + let server = serverForFoo(engine); + new SyncTestingInfrastructure(server.server); + + _("GUID: " + store.GUIDForId(6, true)); + + try { + let bms = PlacesUtils.bookmarks; + let oldR = new FakeRecord(BookmarkFolder, oldRecord); + let newR = new FakeRecord(Livemark, newRecord); + oldR.parentid = PlacesUtils.bookmarks.toolbarGuid; + newR.parentid = PlacesUtils.bookmarks.toolbarGuid; + + store.applyIncoming(oldR); + _("Applied old. It's a folder."); + let oldID = store.idForGUID(oldR.id); + _("Old ID: " + oldID); + do_check_eq(bms.getItemType(oldID), bms.TYPE_FOLDER); + do_check_false(PlacesUtils.annotations + .itemHasAnnotation(oldID, PlacesUtils.LMANNO_FEEDURI)); + + store.applyIncoming(newR); + let newID = store.idForGUID(newR.id); + _("New ID: " + newID); + + _("Applied new. It's a livemark."); + do_check_eq(bms.getItemType(newID), bms.TYPE_FOLDER); + do_check_true(PlacesUtils.annotations + .itemHasAnnotation(newID, PlacesUtils.LMANNO_FEEDURI)); + + } finally { + store.wipe(); + Svc.Prefs.resetBranch(""); + Service.recordManager.clearCache(); + yield new Promise(r => server.stop(r)); + } +}); + +add_task(function* test_bookmark_guidMap_fail() { + _("Ensure that failures building the GUID map cause early death."); + + let engine = new BookmarksEngine(Service); + let store = engine._store; + + let server = serverForFoo(engine); + let coll = server.user("foo").collection("bookmarks"); + new SyncTestingInfrastructure(server.server); + + // Add one item to the server. + let itemID = PlacesUtils.bookmarks.createFolder( + PlacesUtils.bookmarks.toolbarFolder, "Folder 1", 0); + let itemGUID = store.GUIDForId(itemID); + let itemPayload = store.createRecord(itemGUID).cleartext; + coll.insert(itemGUID, encryptPayload(itemPayload)); + + engine.lastSync = 1; // So we don't back up. + + // Make building the GUID map fail. + + let pbt = PlacesUtils.promiseBookmarksTree; + PlacesUtils.promiseBookmarksTree = function() { return Promise.reject("Nooo"); }; + + // Ensure that we throw when accessing _guidMap. + engine._syncStartup(); + _("No error."); + do_check_false(engine._guidMapFailed); + + _("We get an error if building _guidMap fails in use."); + let err; + try { + _(engine._guidMap); + } catch (ex) { + err = ex; + } + do_check_eq(err.code, Engine.prototype.eEngineAbortApplyIncoming); + do_check_eq(err.cause, "Nooo"); + + _("We get an error and abort during processIncoming."); + err = undefined; + try { + engine._processIncoming(); + } catch (ex) { + err = ex; + } + do_check_eq(err, "Nooo"); + + PlacesUtils.promiseBookmarksTree = pbt; + yield new Promise(r => server.stop(r)); +}); + +add_task(function* test_bookmark_tag_but_no_uri() { + _("Ensure that a bookmark record with tags, but no URI, doesn't throw an exception."); + + let engine = new BookmarksEngine(Service); + let store = engine._store; + + // We're simply checking that no exception is thrown, so + // no actual checks in this test. + + yield PlacesSyncUtils.bookmarks.insert({ + kind: PlacesSyncUtils.bookmarks.KINDS.BOOKMARK, + syncId: Utils.makeGUID(), + parentSyncId: "toolbar", + url: "http://example.com", + tags: ["foo"], + }); + yield PlacesSyncUtils.bookmarks.insert({ + kind: PlacesSyncUtils.bookmarks.KINDS.BOOKMARK, + syncId: Utils.makeGUID(), + parentSyncId: "toolbar", + url: "http://example.org", + tags: null, + }); + yield PlacesSyncUtils.bookmarks.insert({ + kind: PlacesSyncUtils.bookmarks.KINDS.BOOKMARK, + syncId: Utils.makeGUID(), + url: "about:fake", + parentSyncId: "toolbar", + tags: null, + }); + + let record = new FakeRecord(BookmarkFolder, { + parentid: "toolbar", + id: Utils.makeGUID(), + description: "", + tags: ["foo"], + title: "Taggy tag", + type: "folder" + }); + + store.create(record); + record.tags = ["bar"]; + store.update(record); +}); + +add_task(function* test_misreconciled_root() { + _("Ensure that we don't reconcile an arbitrary record with a root."); + + let engine = new BookmarksEngine(Service); + let store = engine._store; + let server = serverForFoo(engine); + + // Log real hard for this test. + store._log.trace = store._log.debug; + engine._log.trace = engine._log.debug; + + engine._syncStartup(); + + // Let's find out where the toolbar is right now. + let toolbarBefore = store.createRecord("toolbar", "bookmarks"); + let toolbarIDBefore = store.idForGUID("toolbar"); + do_check_neq(-1, toolbarIDBefore); + + let parentGUIDBefore = toolbarBefore.parentid; + let parentIDBefore = store.idForGUID(parentGUIDBefore); + do_check_neq(-1, parentIDBefore); + do_check_eq("string", typeof(parentGUIDBefore)); + + _("Current parent: " + parentGUIDBefore + " (" + parentIDBefore + ")."); + + let to_apply = { + id: "zzzzzzzzzzzz", + type: "folder", + title: "Bookmarks Toolbar", + description: "Now you're for it.", + parentName: "", + parentid: "mobile", // Why not? + children: [], + }; + + let rec = new FakeRecord(BookmarkFolder, to_apply); + let encrypted = encryptPayload(rec.cleartext); + encrypted.decrypt = function () { + for (let x in rec) { + encrypted[x] = rec[x]; + } + }; + + _("Applying record."); + engine._processIncoming({ + getBatched() { + return this.get(); + }, + get: function () { + this.recordHandler(encrypted); + return {success: true} + }, + }); + + // Ensure that afterwards, toolbar is still there. + // As of 2012-12-05, this only passes because Places doesn't use "toolbar" as + // the real GUID, instead using a generated one. Sync does the translation. + let toolbarAfter = store.createRecord("toolbar", "bookmarks"); + let parentGUIDAfter = toolbarAfter.parentid; + let parentIDAfter = store.idForGUID(parentGUIDAfter); + do_check_eq(store.GUIDForId(toolbarIDBefore), "toolbar"); + do_check_eq(parentGUIDBefore, parentGUIDAfter); + do_check_eq(parentIDBefore, parentIDAfter); + + yield new Promise(r => server.stop(r)); +}); + +function run_test() { + initTestLogging("Trace"); + generateNewKeys(Service.collectionKeys); + run_next_test(); +} diff --git a/services/sync/tests/unit/test_bookmark_invalid.js b/services/sync/tests/unit/test_bookmark_invalid.js new file mode 100644 index 0000000000..af476a7f97 --- /dev/null +++ b/services/sync/tests/unit/test_bookmark_invalid.js @@ -0,0 +1,63 @@ +Cu.import("resource://gre/modules/PlacesUtils.jsm"); +Cu.import("resource://gre/modules/Log.jsm"); +Cu.import("resource://gre/modules/Task.jsm"); +Cu.import("resource://services-sync/engines.js"); +Cu.import("resource://services-sync/engines/bookmarks.js"); +Cu.import("resource://services-sync/service.js"); +Cu.import("resource://services-sync/util.js"); + +Service.engineManager.register(BookmarksEngine); + +var engine = Service.engineManager.get("bookmarks"); +var store = engine._store; +var tracker = engine._tracker; + +add_task(function* test_ignore_invalid_uri() { + _("Ensure that we don't die with invalid bookmarks."); + + // First create a valid bookmark. + let bmid = PlacesUtils.bookmarks.insertBookmark(PlacesUtils.unfiledBookmarksFolderId, + Services.io.newURI("http://example.com/", null, null), + PlacesUtils.bookmarks.DEFAULT_INDEX, + "the title"); + + // Now update moz_places with an invalid url. + yield PlacesUtils.withConnectionWrapper("test_ignore_invalid_uri", Task.async(function* (db) { + yield db.execute( + `UPDATE moz_places SET url = :url, url_hash = hash(:url) + WHERE id = (SELECT b.fk FROM moz_bookmarks b + WHERE b.id = :id LIMIT 1)`, + { id: bmid, url: "<invalid url>" }); + })); + + // Ensure that this doesn't throw even though the DB is now in a bad state (a + // bookmark has an illegal url). + engine._buildGUIDMap(); +}); + +add_task(function* test_ignore_missing_uri() { + _("Ensure that we don't die with a bookmark referencing an invalid bookmark id."); + + // First create a valid bookmark. + let bmid = PlacesUtils.bookmarks.insertBookmark(PlacesUtils.unfiledBookmarksFolderId, + Services.io.newURI("http://example.com/", null, null), + PlacesUtils.bookmarks.DEFAULT_INDEX, + "the title"); + + // Now update moz_bookmarks to reference a non-existing places ID + yield PlacesUtils.withConnectionWrapper("test_ignore_missing_uri", Task.async(function* (db) { + yield db.execute( + `UPDATE moz_bookmarks SET fk = 999999 + WHERE id = :id` + , { id: bmid }); + })); + + // Ensure that this doesn't throw even though the DB is now in a bad state (a + // bookmark has an illegal url). + engine._buildGUIDMap(); +}); + +function run_test() { + initTestLogging('Trace'); + run_next_test(); +} diff --git a/services/sync/tests/unit/test_bookmark_legacy_microsummaries_support.js b/services/sync/tests/unit/test_bookmark_legacy_microsummaries_support.js new file mode 100644 index 0000000000..207372ed68 --- /dev/null +++ b/services/sync/tests/unit/test_bookmark_legacy_microsummaries_support.js @@ -0,0 +1,99 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Tests that Sync can correctly handle a legacy microsummary record +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/NetUtil.jsm"); +Cu.import("resource://gre/modules/PlacesUtils.jsm"); + +Cu.import("resource://gre/modules/Log.jsm"); +Cu.import("resource://services-sync/engines.js"); +Cu.import("resource://services-sync/engines/bookmarks.js"); +Cu.import("resource://services-sync/record.js"); +Cu.import("resource://services-sync/service.js"); +Cu.import("resource://services-sync/util.js"); + +const GENERATORURI_ANNO = "microsummary/generatorURI"; +const STATICTITLE_ANNO = "bookmarks/staticTitle"; + +const TEST_URL = "http://micsum.mozilla.org/"; +const TEST_TITLE = "A microsummarized bookmark" +const GENERATOR_URL = "http://generate.micsum/" +const STATIC_TITLE = "Static title" + +function newMicrosummary(url, title) { + let id = PlacesUtils.bookmarks.insertBookmark( + PlacesUtils.unfiledBookmarksFolderId, NetUtil.newURI(url), + PlacesUtils.bookmarks.DEFAULT_INDEX, title + ); + PlacesUtils.annotations.setItemAnnotation(id, GENERATORURI_ANNO, + GENERATOR_URL, 0, + PlacesUtils.annotations.EXPIRE_NEVER); + PlacesUtils.annotations.setItemAnnotation(id, STATICTITLE_ANNO, + "Static title", 0, + PlacesUtils.annotations.EXPIRE_NEVER); + return id; +} + +function run_test() { + + Service.engineManager.register(BookmarksEngine); + let engine = Service.engineManager.get("bookmarks"); + let store = engine._store; + + // Clean up. + store.wipe(); + + initTestLogging("Trace"); + Log.repository.getLogger("Sync.Engine.Bookmarks").level = Log.Level.Trace; + + _("Create a microsummarized bookmark."); + let id = newMicrosummary(TEST_URL, TEST_TITLE); + let guid = store.GUIDForId(id); + _("GUID: " + guid); + do_check_true(!!guid); + + _("Create record object and verify that it's sane."); + let record = store.createRecord(guid); + do_check_true(record instanceof Bookmark); + do_check_eq(record.bmkUri, TEST_URL); + + _("Make sure the new record does not carry the microsummaries annotations."); + do_check_false("staticTitle" in record); + do_check_false("generatorUri" in record); + + _("Remove the bookmark from Places."); + PlacesUtils.bookmarks.removeItem(id); + + _("Convert record to the old microsummaries one."); + record.staticTitle = STATIC_TITLE; + record.generatorUri = GENERATOR_URL; + record.type = "microsummary"; + + _("Apply the modified record as incoming data."); + store.applyIncoming(record); + + _("Verify it has been created correctly as a simple Bookmark."); + id = store.idForGUID(record.id); + do_check_eq(store.GUIDForId(id), record.id); + do_check_eq(PlacesUtils.bookmarks.getItemType(id), + PlacesUtils.bookmarks.TYPE_BOOKMARK); + do_check_eq(PlacesUtils.bookmarks.getBookmarkURI(id).spec, TEST_URL); + do_check_eq(PlacesUtils.bookmarks.getItemTitle(id), TEST_TITLE); + do_check_eq(PlacesUtils.bookmarks.getFolderIdForItem(id), + PlacesUtils.unfiledBookmarksFolderId); + do_check_eq(PlacesUtils.bookmarks.getKeywordForBookmark(id), null); + + do_check_throws( + () => PlacesUtils.annotations.getItemAnnotation(id, GENERATORURI_ANNO), + Cr.NS_ERROR_NOT_AVAILABLE + ); + + do_check_throws( + () => PlacesUtils.annotations.getItemAnnotation(id, STATICTITLE_ANNO), + Cr.NS_ERROR_NOT_AVAILABLE + ); + + // Clean up. + store.wipe(); +} diff --git a/services/sync/tests/unit/test_bookmark_livemarks.js b/services/sync/tests/unit/test_bookmark_livemarks.js new file mode 100644 index 0000000000..8adde76d84 --- /dev/null +++ b/services/sync/tests/unit/test_bookmark_livemarks.js @@ -0,0 +1,134 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +Cu.import("resource://gre/modules/Log.jsm"); +Cu.import("resource://services-sync/record.js"); +Cu.import("resource://services-sync/engines.js"); +Cu.import("resource://services-sync/engines/bookmarks.js"); +Cu.import("resource://services-sync/util.js"); +Cu.import("resource://services-sync/service.js"); +Cu.import("resource://gre/modules/PlacesUtils.jsm"); +Cu.import("resource://testing-common/services/common/utils.js"); + +const DESCRIPTION_ANNO = "bookmarkProperties/description"; + +var engine = Service.engineManager.get("bookmarks"); +var store = engine._store; + +// Record borrowed from Bug 631361. +var record631361 = { + id: "M5bwUKK8hPyF", + index: 150, + modified: 1296768176.49, + payload: + {"id":"M5bwUKK8hPyF", + "type":"livemark", + "siteUri":"http://www.bbc.co.uk/go/rss/int/news/-/news/", + "feedUri":"http://fxfeeds.mozilla.com/en-US/firefox/headlines.xml", + "parentName":"Bookmarks Toolbar", + "parentid":"toolbar", + "title":"Latest Headlines", + "description":"", + "children": + ["7oBdEZB-8BMO", "SUd1wktMNCTB", "eZe4QWzo1BcY", "YNBhGwhVnQsN", + "92Aw2SMEkFg0", "uw0uKqrVFwd-", "x7mx2P3--8FJ", "d-jVF8UuC9Ye", + "DV1XVtKLEiZ5", "g4mTaTjr837Z", "1Zi5W3lwBw8T", "FEYqlUHtbBWS", + "qQd2u7LjosCB", "VUs2djqYfbvn", "KuhYnHocu7eg", "u2gcg9ILRg-3", + "hfK_RP-EC7Ol", "Aq5qsa4E5msH", "6pZIbxuJTn-K", "k_fp0iN3yYMR", + "59YD3iNOYO8O", "01afpSdAk2iz", "Cq-kjXDEPIoP", "HtNTjt9UwWWg", + "IOU8QRSrTR--", "HJ5lSlBx6d1D", "j2dz5R5U6Khc", "5GvEjrNR0yJl", + "67ozIBF5pNVP", "r5YB0cUx6C_w", "FtmFDBNxDQ6J", "BTACeZq9eEtw", + "ll4ozQ-_VNJe", "HpImsA4_XuW7", "nJvCUQPLSXwA", "94LG-lh6TUYe", + "WHn_QoOL94Os", "l-RvjgsZYlej", "LipQ8abcRstN", "74TiLvarE3n_", + "8fCiLQpQGK1P", "Z6h4WkbwfQFa", "GgAzhqakoS6g", "qyt92T8vpMsK", + "RyOgVCe2EAOE", "bgSEhW3w6kk5", "hWODjHKGD7Ph", "Cky673aqOHbT", + "gZCYT7nx3Nwu", "iJzaJxxrM58L", "rUHCRv68aY5L", "6Jc1hNJiVrV9", + "lmNgoayZ-ym8", "R1lyXsDzlfOd", "pinrXwDnRk6g", "Sn7TmZV01vMM", + "qoXyU6tcS1dd", "TRLanED-QfBK", "xHbhMeX_FYEA", "aPqacdRlAtaW", + "E3H04Wn2RfSi", "eaSIMI6kSrcz", "rtkRxFoG5Vqi", "dectkUglV0Dz", + "B4vUE0BE15No", "qgQFW5AQrgB0", "SxAXvwOhu8Zi", "0S6cRPOg-5Z2", + "zcZZBGeLnaWW", "B0at8hkQqVZQ", "sgPtgGulbP66", "lwtwGHSCPYaQ", + "mNTdpgoRZMbW", "-L8Vci6CbkJY", "bVzudKSQERc1", "Gxl9lb4DXsmL", + "3Qr13GucOtEh"]}, + collection: "bookmarks" +}; + +// Clean up after other tests. Only necessary in XULRunner. +store.wipe(); + +function makeLivemark(p, mintGUID) { + let b = new Livemark("bookmarks", p.id); + // Copy here, because tests mutate the contents. + b.cleartext = TestingUtils.deepCopy(p); + + if (mintGUID) + b.id = Utils.makeGUID(); + + return b; +} + + +function run_test() { + initTestLogging("Trace"); + Log.repository.getLogger("Sync.Engine.Bookmarks").level = Log.Level.Trace; + Log.repository.getLogger("Sync.Store.Bookmarks").level = Log.Level.Trace; + + run_next_test(); +} + +add_test(function test_livemark_descriptions() { + let record = record631361.payload; + + function doRecord(r) { + store._childrenToOrder = {}; + store.applyIncoming(r); + store._orderChildren(); + delete store._childrenToOrder; + } + + // Attempt to provoke an error by messing around with the description. + record.description = null; + doRecord(makeLivemark(record)); + record.description = ""; + doRecord(makeLivemark(record)); + + // Attempt to provoke an error by adding a bad description anno. + let id = store.idForGUID(record.id); + PlacesUtils.annotations.setItemAnnotation(id, DESCRIPTION_ANNO, "", 0, + PlacesUtils.annotations.EXPIRE_NEVER); + + run_next_test(); +}); + +add_test(function test_livemark_invalid() { + _("Livemarks considered invalid by nsLivemarkService are skipped."); + + _("Parent is unknown. Will be set to unfiled."); + let lateParentRec = makeLivemark(record631361.payload, true); + let parentGUID = Utils.makeGUID(); + lateParentRec.parentid = parentGUID; + do_check_eq(-1, store.idForGUID(parentGUID)); + + store.create(lateParentRec); + recID = store.idForGUID(lateParentRec.id, true); + do_check_true(recID > 0); + do_check_eq(PlacesUtils.bookmarks.getFolderIdForItem(recID), + PlacesUtils.bookmarks.unfiledBookmarksFolder); + + _("No feed URI, which is invalid. Will be skipped."); + let noFeedURIRec = makeLivemark(record631361.payload, true); + delete noFeedURIRec.cleartext.feedUri; + store.create(noFeedURIRec); + // No exception, but no creation occurs. + do_check_eq(-1, store.idForGUID(noFeedURIRec.id, true)); + + _("Parent is a Livemark. Will be skipped."); + let lmParentRec = makeLivemark(record631361.payload, true); + lmParentRec.parentid = store.GUIDForId(recID); + store.create(lmParentRec); + // No exception, but no creation occurs. + do_check_eq(-1, store.idForGUID(lmParentRec.id, true)); + + // Clear event loop. + Utils.nextTick(run_next_test); +}); diff --git a/services/sync/tests/unit/test_bookmark_order.js b/services/sync/tests/unit/test_bookmark_order.js new file mode 100644 index 0000000000..7625a813f5 --- /dev/null +++ b/services/sync/tests/unit/test_bookmark_order.js @@ -0,0 +1,529 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +_("Making sure after processing incoming bookmarks, they show up in the right order"); +Cu.import("resource://gre/modules/PlacesUtils.jsm"); +Cu.import("resource://gre/modules/Task.jsm"); +Cu.import("resource://services-sync/engines/bookmarks.js"); +Cu.import("resource://services-sync/service.js"); +Cu.import("resource://services-sync/util.js"); + +var check = Task.async(function* (expected, message) { + let root = yield PlacesUtils.promiseBookmarksTree(); + + let bookmarks = (function mapTree(children) { + return children.map(child => { + let result = { + guid: child.guid, + index: child.index, + }; + if (child.children) { + result.children = mapTree(child.children); + } + if (child.annos) { + let orphanAnno = child.annos.find( + anno => anno.name == "sync/parent"); + if (orphanAnno) { + result.requestedParent = orphanAnno.value; + } + } + return result; + }); + }(root.children)); + + _("Checking if the bookmark structure is", JSON.stringify(expected)); + _("Got bookmarks:", JSON.stringify(bookmarks)); + deepEqual(bookmarks, expected); +}); + +add_task(function* test_bookmark_order() { + let store = new BookmarksEngine(Service)._store; + initTestLogging("Trace"); + + _("Starting with a clean slate of no bookmarks"); + store.wipe(); + yield check([{ + guid: PlacesUtils.bookmarks.menuGuid, + index: 0, + }, { + guid: PlacesUtils.bookmarks.toolbarGuid, + index: 1, + }, { + // Index 2 is the tags root. (Root indices depend on the order of the + // `CreateRoot` calls in `Database::CreateBookmarkRoots`). + guid: PlacesUtils.bookmarks.unfiledGuid, + index: 3, + }, { + guid: PlacesUtils.bookmarks.mobileGuid, + index: 4, + }], "clean slate"); + + function bookmark(name, parent) { + let bookmark = new Bookmark("http://weave.server/my-bookmark"); + bookmark.id = name; + bookmark.title = name; + bookmark.bmkUri = "http://uri/"; + bookmark.parentid = parent || "unfiled"; + bookmark.tags = []; + return bookmark; + } + + function folder(name, parent, children) { + let folder = new BookmarkFolder("http://weave.server/my-bookmark-folder"); + folder.id = name; + folder.title = name; + folder.parentid = parent || "unfiled"; + folder.children = children; + return folder; + } + + function apply(record) { + store._childrenToOrder = {}; + store.applyIncoming(record); + store._orderChildren(); + delete store._childrenToOrder; + } + let id10 = "10_aaaaaaaaa"; + _("basic add first bookmark"); + apply(bookmark(id10, "")); + yield check([{ + guid: PlacesUtils.bookmarks.menuGuid, + index: 0, + }, { + guid: PlacesUtils.bookmarks.toolbarGuid, + index: 1, + }, { + guid: PlacesUtils.bookmarks.unfiledGuid, + index: 3, + children: [{ + guid: id10, + index: 0, + }], + }, { + guid: PlacesUtils.bookmarks.mobileGuid, + index: 4, + }], "basic add first bookmark"); + let id20 = "20_aaaaaaaaa"; + _("basic append behind 10"); + apply(bookmark(id20, "")); + yield check([{ + guid: PlacesUtils.bookmarks.menuGuid, + index: 0, + }, { + guid: PlacesUtils.bookmarks.toolbarGuid, + index: 1, + }, { + guid: PlacesUtils.bookmarks.unfiledGuid, + index: 3, + children: [{ + guid: id10, + index: 0, + }, { + guid: id20, + index: 1, + }], + }, { + guid: PlacesUtils.bookmarks.mobileGuid, + index: 4, + }], "basic append behind 10"); + + let id31 = "31_aaaaaaaaa"; + let id30 = "f30_aaaaaaaa"; + _("basic create in folder"); + apply(bookmark(id31, id30)); + let f30 = folder(id30, "", [id31]); + apply(f30); + yield check([{ + guid: PlacesUtils.bookmarks.menuGuid, + index: 0, + }, { + guid: PlacesUtils.bookmarks.toolbarGuid, + index: 1, + }, { + guid: PlacesUtils.bookmarks.unfiledGuid, + index: 3, + children: [{ + guid: id10, + index: 0, + }, { + guid: id20, + index: 1, + }, { + guid: id30, + index: 2, + children: [{ + guid: id31, + index: 0, + }], + }], + }, { + guid: PlacesUtils.bookmarks.mobileGuid, + index: 4, + }], "basic create in folder"); + + let id41 = "41_aaaaaaaaa"; + let id40 = "f40_aaaaaaaa"; + _("insert missing parent -> append to unfiled"); + apply(bookmark(id41, id40)); + yield check([{ + guid: PlacesUtils.bookmarks.menuGuid, + index: 0, + }, { + guid: PlacesUtils.bookmarks.toolbarGuid, + index: 1, + }, { + guid: PlacesUtils.bookmarks.unfiledGuid, + index: 3, + children: [{ + guid: id10, + index: 0, + }, { + guid: id20, + index: 1, + }, { + guid: id30, + index: 2, + children: [{ + guid: id31, + index: 0, + }], + }, { + guid: id41, + index: 3, + requestedParent: id40, + }], + }, { + guid: PlacesUtils.bookmarks.mobileGuid, + index: 4, + }], "insert missing parent -> append to unfiled"); + + let id42 = "42_aaaaaaaaa"; + + _("insert another missing parent -> append"); + apply(bookmark(id42, id40)); + yield check([{ + guid: PlacesUtils.bookmarks.menuGuid, + index: 0, + }, { + guid: PlacesUtils.bookmarks.toolbarGuid, + index: 1, + }, { + guid: PlacesUtils.bookmarks.unfiledGuid, + index: 3, + children: [{ + guid: id10, + index: 0, + }, { + guid: id20, + index: 1, + }, { + guid: id30, + index: 2, + children: [{ + guid: id31, + index: 0, + }], + }, { + guid: id41, + index: 3, + requestedParent: id40, + }, { + guid: id42, + index: 4, + requestedParent: id40, + }], + }, { + guid: PlacesUtils.bookmarks.mobileGuid, + index: 4, + }], "insert another missing parent -> append"); + + _("insert folder -> move children and followers"); + let f40 = folder(id40, "", [id41, id42]); + apply(f40); + yield check([{ + guid: PlacesUtils.bookmarks.menuGuid, + index: 0, + }, { + guid: PlacesUtils.bookmarks.toolbarGuid, + index: 1, + }, { + guid: PlacesUtils.bookmarks.unfiledGuid, + index: 3, + children: [{ + guid: id10, + index: 0, + }, { + guid: id20, + index: 1, + }, { + guid: id30, + index: 2, + children: [{ + guid: id31, + index: 0, + }], + }, { + guid: id40, + index: 3, + children: [{ + guid: id41, + index: 0, + }, { + guid: id42, + index: 1, + }] + }], + }, { + guid: PlacesUtils.bookmarks.mobileGuid, + index: 4, + }], "insert folder -> move children and followers"); + + _("Moving 41 behind 42 -> update f40"); + f40.children = [id42, id41]; + apply(f40); + yield check([{ + guid: PlacesUtils.bookmarks.menuGuid, + index: 0, + }, { + guid: PlacesUtils.bookmarks.toolbarGuid, + index: 1, + }, { + guid: PlacesUtils.bookmarks.unfiledGuid, + index: 3, + children: [{ + guid: id10, + index: 0, + }, { + guid: id20, + index: 1, + }, { + guid: id30, + index: 2, + children: [{ + guid: id31, + index: 0, + }], + }, { + guid: id40, + index: 3, + children: [{ + guid: id42, + index: 0, + }, { + guid: id41, + index: 1, + }] + }], + }, { + guid: PlacesUtils.bookmarks.mobileGuid, + index: 4, + }], "Moving 41 behind 42 -> update f40"); + + _("Moving 10 back to front -> update 10, 20"); + f40.children = [id41, id42]; + apply(f40); + yield check([{ + guid: PlacesUtils.bookmarks.menuGuid, + index: 0, + }, { + guid: PlacesUtils.bookmarks.toolbarGuid, + index: 1, + }, { + guid: PlacesUtils.bookmarks.unfiledGuid, + index: 3, + children: [{ + guid: id10, + index: 0, + }, { + guid: id20, + index: 1, + }, { + guid: id30, + index: 2, + children: [{ + guid: id31, + index: 0, + }], + }, { + guid: id40, + index: 3, + children: [{ + guid: id41, + index: 0, + }, { + guid: id42, + index: 1, + }] + }], + }, { + guid: PlacesUtils.bookmarks.mobileGuid, + index: 4, + }], "Moving 10 back to front -> update 10, 20"); + + _("Moving 20 behind 42 in f40 -> update 50"); + apply(bookmark(id20, id40)); + yield check([{ + guid: PlacesUtils.bookmarks.menuGuid, + index: 0, + }, { + guid: PlacesUtils.bookmarks.toolbarGuid, + index: 1, + }, { + guid: PlacesUtils.bookmarks.unfiledGuid, + index: 3, + children: [{ + guid: id10, + index: 0, + }, { + guid: id30, + index: 1, + children: [{ + guid: id31, + index: 0, + }], + }, { + guid: id40, + index: 2, + children: [{ + guid: id41, + index: 0, + }, { + guid: id42, + index: 1, + }, { + guid: id20, + index: 2, + }] + }], + }, { + guid: PlacesUtils.bookmarks.mobileGuid, + index: 4, + }], "Moving 20 behind 42 in f40 -> update 50"); + + _("Moving 10 in front of 31 in f30 -> update 10, f30"); + apply(bookmark(id10, id30)); + f30.children = [id10, id31]; + apply(f30); + yield check([{ + guid: PlacesUtils.bookmarks.menuGuid, + index: 0, + }, { + guid: PlacesUtils.bookmarks.toolbarGuid, + index: 1, + }, { + guid: PlacesUtils.bookmarks.unfiledGuid, + index: 3, + children: [{ + guid: id30, + index: 0, + children: [{ + guid: id10, + index: 0, + }, { + guid: id31, + index: 1, + }], + }, { + guid: id40, + index: 1, + children: [{ + guid: id41, + index: 0, + }, { + guid: id42, + index: 1, + }, { + guid: id20, + index: 2, + }] + }], + }, { + guid: PlacesUtils.bookmarks.mobileGuid, + index: 4, + }], "Moving 10 in front of 31 in f30 -> update 10, f30"); + + _("Moving 20 from f40 to f30 -> update 20, f30"); + apply(bookmark(id20, id30)); + f30.children = [id10, id20, id31]; + apply(f30); + yield check([{ + guid: PlacesUtils.bookmarks.menuGuid, + index: 0, + }, { + guid: PlacesUtils.bookmarks.toolbarGuid, + index: 1, + }, { + guid: PlacesUtils.bookmarks.unfiledGuid, + index: 3, + children: [{ + guid: id30, + index: 0, + children: [{ + guid: id10, + index: 0, + }, { + guid: id20, + index: 1, + }, { + guid: id31, + index: 2, + }], + }, { + guid: id40, + index: 1, + children: [{ + guid: id41, + index: 0, + }, { + guid: id42, + index: 1, + }] + }], + }, { + guid: PlacesUtils.bookmarks.mobileGuid, + index: 4, + }], "Moving 20 from f40 to f30 -> update 20, f30"); + + _("Move 20 back to front -> update 20, f30"); + apply(bookmark(id20, "")); + f30.children = [id10, id31]; + apply(f30); + yield check([{ + guid: PlacesUtils.bookmarks.menuGuid, + index: 0, + }, { + guid: PlacesUtils.bookmarks.toolbarGuid, + index: 1, + }, { + guid: PlacesUtils.bookmarks.unfiledGuid, + index: 3, + children: [{ + guid: id30, + index: 0, + children: [{ + guid: id10, + index: 0, + }, { + guid: id31, + index: 1, + }], + }, { + guid: id40, + index: 1, + children: [{ + guid: id41, + index: 0, + }, { + guid: id42, + index: 1, + }], + }, { + guid: id20, + index: 2, + }], + }, { + guid: PlacesUtils.bookmarks.mobileGuid, + index: 4, + }], "Move 20 back to front -> update 20, f30"); + +}); diff --git a/services/sync/tests/unit/test_bookmark_places_query_rewriting.js b/services/sync/tests/unit/test_bookmark_places_query_rewriting.js new file mode 100644 index 0000000000..0ddf81583e --- /dev/null +++ b/services/sync/tests/unit/test_bookmark_places_query_rewriting.js @@ -0,0 +1,60 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +_("Rewrite place: URIs."); +Cu.import("resource://gre/modules/PlacesUtils.jsm"); +Cu.import("resource://services-sync/engines/bookmarks.js"); +Cu.import("resource://services-sync/service.js"); +Cu.import("resource://services-sync/util.js"); + +var engine = new BookmarksEngine(Service); +var store = engine._store; + +function makeTagRecord(id, uri) { + let tagRecord = new BookmarkQuery("bookmarks", id); + tagRecord.queryId = "MagicTags"; + tagRecord.parentName = "Bookmarks Toolbar"; + tagRecord.bmkUri = uri; + tagRecord.title = "tagtag"; + tagRecord.folderName = "bar"; + tagRecord.parentid = PlacesUtils.bookmarks.toolbarGuid; + return tagRecord; +} + +function run_test() { + initTestLogging("Trace"); + Log.repository.getLogger("Sync.Engine.Bookmarks").level = Log.Level.Trace; + Log.repository.getLogger("Sync.Store.Bookmarks").level = Log.Level.Trace; + + let uri = "place:folder=499&type=7&queryType=1"; + let tagRecord = makeTagRecord("abcdefabcdef", uri); + + _("Type: " + tagRecord.type); + _("Folder name: " + tagRecord.folderName); + store.applyIncoming(tagRecord); + + let tags = PlacesUtils.getFolderContents(PlacesUtils.tagsFolderId).root; + let tagID; + try { + for (let i = 0; i < tags.childCount; ++i) { + let child = tags.getChild(i); + if (child.title == "bar") { + tagID = child.itemId; + } + } + } finally { + tags.containerOpen = false; + } + + _("Tag ID: " + tagID); + let insertedRecord = store.createRecord("abcdefabcdef", "bookmarks"); + do_check_eq(insertedRecord.bmkUri, uri.replace("499", tagID)); + + _("... but not if the type is wrong."); + let wrongTypeURI = "place:folder=499&type=2&queryType=1"; + let wrongTypeRecord = makeTagRecord("fedcbafedcba", wrongTypeURI); + store.applyIncoming(wrongTypeRecord); + + insertedRecord = store.createRecord("fedcbafedcba", "bookmarks"); + do_check_eq(insertedRecord.bmkUri, wrongTypeURI); +} diff --git a/services/sync/tests/unit/test_bookmark_record.js b/services/sync/tests/unit/test_bookmark_record.js new file mode 100644 index 0000000000..194fef5e23 --- /dev/null +++ b/services/sync/tests/unit/test_bookmark_record.js @@ -0,0 +1,48 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +Cu.import("resource://gre/modules/Log.jsm"); +Cu.import("resource://services-sync/engines/bookmarks.js"); +Cu.import("resource://services-sync/keys.js"); +Cu.import("resource://services-sync/record.js"); +Cu.import("resource://services-sync/service.js"); +Cu.import("resource://services-sync/util.js"); +Cu.import("resource://testing-common/services/sync/utils.js"); + +function prepareBookmarkItem(collection, id) { + let b = new Bookmark(collection, id); + b.cleartext.stuff = "my payload here"; + return b; +} + +function run_test() { + ensureLegacyIdentityManager(); + Service.identity.username = "john@example.com"; + Service.identity.syncKey = "abcdeabcdeabcdeabcdeabcdea"; + generateNewKeys(Service.collectionKeys); + let keyBundle = Service.identity.syncKeyBundle; + + let log = Log.repository.getLogger("Test"); + Log.repository.rootLogger.addAppender(new Log.DumpAppender()); + + log.info("Creating a record"); + + let u = "http://localhost:8080/storage/bookmarks/foo"; + let placesItem = new PlacesItem("bookmarks", "foo", "bookmark"); + let bookmarkItem = prepareBookmarkItem("bookmarks", "foo"); + + log.info("Checking getTypeObject"); + do_check_eq(placesItem.getTypeObject(placesItem.type), Bookmark); + do_check_eq(bookmarkItem.getTypeObject(bookmarkItem.type), Bookmark); + + bookmarkItem.encrypt(keyBundle); + log.info("Ciphertext is " + bookmarkItem.ciphertext); + do_check_true(bookmarkItem.ciphertext != null); + + log.info("Decrypting the record"); + + let payload = bookmarkItem.decrypt(keyBundle); + do_check_eq(payload.stuff, "my payload here"); + do_check_eq(bookmarkItem.getTypeObject(bookmarkItem.type), Bookmark); + do_check_neq(payload, bookmarkItem.payload); // wrap.data.payload is the encrypted one +} diff --git a/services/sync/tests/unit/test_bookmark_smart_bookmarks.js b/services/sync/tests/unit/test_bookmark_smart_bookmarks.js new file mode 100644 index 0000000000..942cf2761c --- /dev/null +++ b/services/sync/tests/unit/test_bookmark_smart_bookmarks.js @@ -0,0 +1,235 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +Cu.import("resource://gre/modules/PlacesUtils.jsm"); +Cu.import("resource://gre/modules/Log.jsm"); +Cu.import("resource://services-sync/engines.js"); +Cu.import("resource://services-sync/engines/bookmarks.js"); +Cu.import("resource://services-sync/service.js"); +Cu.import("resource://services-sync/util.js"); +Cu.import("resource://testing-common/services/sync/utils.js"); + +const SMART_BOOKMARKS_ANNO = "Places/SmartBookmark"; +var IOService = Cc["@mozilla.org/network/io-service;1"] + .getService(Ci.nsIIOService); +("http://www.mozilla.com", null, null); + + +Service.engineManager.register(BookmarksEngine); +var engine = Service.engineManager.get("bookmarks"); +var store = engine._store; + +// Clean up after other tests. Only necessary in XULRunner. +store.wipe(); + +function newSmartBookmark(parent, uri, position, title, queryID) { + let id = PlacesUtils.bookmarks.insertBookmark(parent, uri, position, title); + PlacesUtils.annotations.setItemAnnotation(id, SMART_BOOKMARKS_ANNO, + queryID, 0, + PlacesUtils.annotations.EXPIRE_NEVER); + return id; +} + +function smartBookmarkCount() { + // We do it this way because PlacesUtils.annotations.getItemsWithAnnotation + // doesn't work the same (or at all?) between 3.6 and 4.0. + let out = {}; + PlacesUtils.annotations.getItemsWithAnnotation(SMART_BOOKMARKS_ANNO, out); + return out.value; +} + +function clearBookmarks() { + _("Cleaning up existing items."); + PlacesUtils.bookmarks.removeFolderChildren(PlacesUtils.bookmarks.bookmarksMenuFolder); + PlacesUtils.bookmarks.removeFolderChildren(PlacesUtils.bookmarks.tagsFolder); + PlacesUtils.bookmarks.removeFolderChildren(PlacesUtils.bookmarks.toolbarFolder); + PlacesUtils.bookmarks.removeFolderChildren(PlacesUtils.bookmarks.unfiledBookmarksFolder); + startCount = smartBookmarkCount(); +} + +function serverForFoo(engine) { + return serverForUsers({"foo": "password"}, { + meta: {global: {engines: {bookmarks: {version: engine.version, + syncID: engine.syncID}}}}, + bookmarks: {} + }); +} + +// Verify that Places smart bookmarks have their annotation uploaded and +// handled locally. +add_task(function *test_annotation_uploaded() { + let server = serverForFoo(engine); + new SyncTestingInfrastructure(server.server); + + let startCount = smartBookmarkCount(); + + _("Start count is " + startCount); + + if (startCount > 0) { + // This can happen in XULRunner. + clearBookmarks(); + _("Start count is now " + startCount); + } + + _("Create a smart bookmark in the toolbar."); + let parent = PlacesUtils.toolbarFolderId; + let uri = + Utils.makeURI("place:sort=" + + Ci.nsINavHistoryQueryOptions.SORT_BY_VISITCOUNT_DESCENDING + + "&maxResults=10"); + let title = "Most Visited"; + + let mostVisitedID = newSmartBookmark(parent, uri, -1, title, "MostVisited"); + + _("New item ID: " + mostVisitedID); + do_check_true(!!mostVisitedID); + + let annoValue = PlacesUtils.annotations.getItemAnnotation(mostVisitedID, + SMART_BOOKMARKS_ANNO); + _("Anno: " + annoValue); + do_check_eq("MostVisited", annoValue); + + let guid = store.GUIDForId(mostVisitedID); + _("GUID: " + guid); + do_check_true(!!guid); + + _("Create record object and verify that it's sane."); + let record = store.createRecord(guid); + do_check_true(record instanceof Bookmark); + do_check_true(record instanceof BookmarkQuery); + + do_check_eq(record.bmkUri, uri.spec); + + _("Make sure the new record carries with it the annotation."); + do_check_eq("MostVisited", record.queryId); + + _("Our count has increased since we started."); + do_check_eq(smartBookmarkCount(), startCount + 1); + + _("Sync record to the server."); + let collection = server.user("foo").collection("bookmarks"); + + try { + yield sync_engine_and_validate_telem(engine, false); + let wbos = collection.keys(function (id) { + return ["menu", "toolbar", "mobile", "unfiled"].indexOf(id) == -1; + }); + do_check_eq(wbos.length, 1); + + _("Verify that the server WBO has the annotation."); + let serverGUID = wbos[0]; + do_check_eq(serverGUID, guid); + let serverWBO = collection.wbo(serverGUID); + do_check_true(!!serverWBO); + let body = JSON.parse(JSON.parse(serverWBO.payload).ciphertext); + do_check_eq(body.queryId, "MostVisited"); + + _("We still have the right count."); + do_check_eq(smartBookmarkCount(), startCount + 1); + + _("Clear local records; now we can't find it."); + + // "Clear" by changing attributes: if we delete it, apparently it sticks + // around as a deleted record... + PlacesUtils.bookmarks.setItemTitle(mostVisitedID, "Not Most Visited"); + PlacesUtils.bookmarks.changeBookmarkURI( + mostVisitedID, Utils.makeURI("http://something/else")); + PlacesUtils.annotations.removeItemAnnotation(mostVisitedID, + SMART_BOOKMARKS_ANNO); + store.wipe(); + engine.resetClient(); + do_check_eq(smartBookmarkCount(), startCount); + + _("Sync. Verify that the downloaded record carries the annotation."); + yield sync_engine_and_validate_telem(engine, false); + + _("Verify that the Places DB now has an annotated bookmark."); + _("Our count has increased again."); + do_check_eq(smartBookmarkCount(), startCount + 1); + + _("Find by GUID and verify that it's annotated."); + let newID = store.idForGUID(serverGUID); + let newAnnoValue = PlacesUtils.annotations.getItemAnnotation( + newID, SMART_BOOKMARKS_ANNO); + do_check_eq(newAnnoValue, "MostVisited"); + do_check_eq(PlacesUtils.bookmarks.getBookmarkURI(newID).spec, uri.spec); + + _("Test updating."); + let newRecord = store.createRecord(serverGUID); + do_check_eq(newRecord.queryId, newAnnoValue); + newRecord.queryId = "LeastVisited"; + store.update(newRecord); + do_check_eq("LeastVisited", PlacesUtils.annotations.getItemAnnotation( + newID, SMART_BOOKMARKS_ANNO)); + + + } finally { + // Clean up. + store.wipe(); + Svc.Prefs.resetBranch(""); + Service.recordManager.clearCache(); + server.stop(run_next_test); + } +}); + +add_test(function test_smart_bookmarks_duped() { + let server = serverForFoo(engine); + new SyncTestingInfrastructure(server.server); + + let parent = PlacesUtils.toolbarFolderId; + let uri = + Utils.makeURI("place:sort=" + + Ci.nsINavHistoryQueryOptions.SORT_BY_VISITCOUNT_DESCENDING + + "&maxResults=10"); + let title = "Most Visited"; + let mostVisitedID = newSmartBookmark(parent, uri, -1, title, "MostVisited"); + let mostVisitedGUID = store.GUIDForId(mostVisitedID); + + let record = store.createRecord(mostVisitedGUID); + + _("Prepare sync."); + let collection = server.user("foo").collection("bookmarks"); + + try { + engine._syncStartup(); + + _("Verify that mapDupe uses the anno, discovering a dupe regardless of URI."); + do_check_eq(mostVisitedGUID, engine._mapDupe(record)); + + record.bmkUri = "http://foo/"; + do_check_eq(mostVisitedGUID, engine._mapDupe(record)); + do_check_neq(PlacesUtils.bookmarks.getBookmarkURI(mostVisitedID).spec, + record.bmkUri); + + _("Verify that different annos don't dupe."); + let other = new BookmarkQuery("bookmarks", "abcdefabcdef"); + other.queryId = "LeastVisited"; + other.parentName = "Bookmarks Toolbar"; + other.bmkUri = "place:foo"; + other.title = ""; + do_check_eq(undefined, engine._findDupe(other)); + + _("Handle records without a queryId entry."); + record.bmkUri = uri; + delete record.queryId; + do_check_eq(mostVisitedGUID, engine._mapDupe(record)); + + engine._syncFinish(); + + } finally { + // Clean up. + store.wipe(); + server.stop(do_test_finished); + Svc.Prefs.resetBranch(""); + Service.recordManager.clearCache(); + } +}); + +function run_test() { + initTestLogging("Trace"); + Log.repository.getLogger("Sync.Engine.Bookmarks").level = Log.Level.Trace; + + generateNewKeys(Service.collectionKeys); + + run_next_test(); +} diff --git a/services/sync/tests/unit/test_bookmark_store.js b/services/sync/tests/unit/test_bookmark_store.js new file mode 100644 index 0000000000..902206ba6c --- /dev/null +++ b/services/sync/tests/unit/test_bookmark_store.js @@ -0,0 +1,534 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +Cu.import("resource://gre/modules/PlacesUtils.jsm"); +Cu.import("resource://services-sync/engines.js"); +Cu.import("resource://services-sync/engines/bookmarks.js"); +Cu.import("resource://services-sync/service.js"); +Cu.import("resource://services-sync/util.js"); + +const PARENT_ANNO = "sync/parent"; + +Service.engineManager.register(BookmarksEngine); + +var engine = Service.engineManager.get("bookmarks"); +var store = engine._store; +var tracker = engine._tracker; + +// Don't write some persistence files asynchronously. +tracker.persistChangedIDs = false; + +var fxuri = Utils.makeURI("http://getfirefox.com/"); +var tburi = Utils.makeURI("http://getthunderbird.com/"); + +add_task(function* test_ignore_specials() { + _("Ensure that we can't delete bookmark roots."); + + // Belt... + let record = new BookmarkFolder("bookmarks", "toolbar", "folder"); + record.deleted = true; + do_check_neq(null, store.idForGUID("toolbar")); + + store.applyIncoming(record); + yield store.deletePending(); + + // Ensure that the toolbar exists. + do_check_neq(null, store.idForGUID("toolbar")); + + // This will fail painfully in getItemType if the deletion worked. + engine._buildGUIDMap(); + + // Braces... + store.remove(record); + yield store.deletePending(); + do_check_neq(null, store.idForGUID("toolbar")); + engine._buildGUIDMap(); + + store.wipe(); +}); + +add_test(function test_bookmark_create() { + try { + _("Ensure the record isn't present yet."); + let ids = PlacesUtils.bookmarks.getBookmarkIdsForURI(fxuri, {}); + do_check_eq(ids.length, 0); + + _("Let's create a new record."); + let fxrecord = new Bookmark("bookmarks", "get-firefox1"); + fxrecord.bmkUri = fxuri.spec; + fxrecord.description = "Firefox is awesome."; + fxrecord.title = "Get Firefox!"; + fxrecord.tags = ["firefox", "awesome", "browser"]; + fxrecord.keyword = "awesome"; + fxrecord.loadInSidebar = false; + fxrecord.parentName = "Bookmarks Toolbar"; + fxrecord.parentid = "toolbar"; + store.applyIncoming(fxrecord); + + _("Verify it has been created correctly."); + let id = store.idForGUID(fxrecord.id); + do_check_eq(store.GUIDForId(id), fxrecord.id); + do_check_eq(PlacesUtils.bookmarks.getItemType(id), + PlacesUtils.bookmarks.TYPE_BOOKMARK); + do_check_true(PlacesUtils.bookmarks.getBookmarkURI(id).equals(fxuri)); + do_check_eq(PlacesUtils.bookmarks.getItemTitle(id), fxrecord.title); + do_check_eq(PlacesUtils.annotations.getItemAnnotation(id, "bookmarkProperties/description"), + fxrecord.description); + do_check_eq(PlacesUtils.bookmarks.getFolderIdForItem(id), + PlacesUtils.bookmarks.toolbarFolder); + do_check_eq(PlacesUtils.bookmarks.getKeywordForBookmark(id), fxrecord.keyword); + + _("Have the store create a new record object. Verify that it has the same data."); + let newrecord = store.createRecord(fxrecord.id); + do_check_true(newrecord instanceof Bookmark); + for (let property of ["type", "bmkUri", "description", "title", + "keyword", "parentName", "parentid"]) { + do_check_eq(newrecord[property], fxrecord[property]); + } + do_check_true(Utils.deepEquals(newrecord.tags.sort(), + fxrecord.tags.sort())); + + _("The calculated sort index is based on frecency data."); + do_check_true(newrecord.sortindex >= 150); + + _("Create a record with some values missing."); + let tbrecord = new Bookmark("bookmarks", "thunderbird1"); + tbrecord.bmkUri = tburi.spec; + tbrecord.parentName = "Bookmarks Toolbar"; + tbrecord.parentid = "toolbar"; + store.applyIncoming(tbrecord); + + _("Verify it has been created correctly."); + id = store.idForGUID(tbrecord.id); + do_check_eq(store.GUIDForId(id), tbrecord.id); + do_check_eq(PlacesUtils.bookmarks.getItemType(id), + PlacesUtils.bookmarks.TYPE_BOOKMARK); + do_check_true(PlacesUtils.bookmarks.getBookmarkURI(id).equals(tburi)); + do_check_eq(PlacesUtils.bookmarks.getItemTitle(id), null); + let error; + try { + PlacesUtils.annotations.getItemAnnotation(id, "bookmarkProperties/description"); + } catch(ex) { + error = ex; + } + do_check_eq(error.result, Cr.NS_ERROR_NOT_AVAILABLE); + do_check_eq(PlacesUtils.bookmarks.getFolderIdForItem(id), + PlacesUtils.bookmarks.toolbarFolder); + do_check_eq(PlacesUtils.bookmarks.getKeywordForBookmark(id), null); + } finally { + _("Clean up."); + store.wipe(); + run_next_test(); + } +}); + +add_test(function test_bookmark_update() { + try { + _("Create a bookmark whose values we'll change."); + let bmk1_id = PlacesUtils.bookmarks.insertBookmark( + PlacesUtils.bookmarks.toolbarFolder, fxuri, + PlacesUtils.bookmarks.DEFAULT_INDEX, + "Get Firefox!"); + PlacesUtils.annotations.setItemAnnotation( + bmk1_id, "bookmarkProperties/description", "Firefox is awesome.", 0, + PlacesUtils.annotations.EXPIRE_NEVER); + PlacesUtils.bookmarks.setKeywordForBookmark(bmk1_id, "firefox"); + let bmk1_guid = store.GUIDForId(bmk1_id); + + _("Update the record with some null values."); + let record = store.createRecord(bmk1_guid); + record.title = null; + record.description = null; + record.keyword = null; + record.tags = null; + store.applyIncoming(record); + + _("Verify that the values have been cleared."); + do_check_throws(function () { + PlacesUtils.annotations.getItemAnnotation( + bmk1_id, "bookmarkProperties/description"); + }, Cr.NS_ERROR_NOT_AVAILABLE); + do_check_eq(PlacesUtils.bookmarks.getItemTitle(bmk1_id), null); + do_check_eq(PlacesUtils.bookmarks.getKeywordForBookmark(bmk1_id), null); + } finally { + _("Clean up."); + store.wipe(); + run_next_test(); + } +}); + +add_test(function test_bookmark_createRecord() { + try { + _("Create a bookmark without a description or title."); + let bmk1_id = PlacesUtils.bookmarks.insertBookmark( + PlacesUtils.bookmarks.toolbarFolder, fxuri, + PlacesUtils.bookmarks.DEFAULT_INDEX, null); + let bmk1_guid = store.GUIDForId(bmk1_id); + + _("Verify that the record is created accordingly."); + let record = store.createRecord(bmk1_guid); + do_check_eq(record.title, ""); + do_check_eq(record.description, null); + do_check_eq(record.keyword, null); + + } finally { + _("Clean up."); + store.wipe(); + run_next_test(); + } +}); + +add_test(function test_folder_create() { + try { + _("Create a folder."); + let folder = new BookmarkFolder("bookmarks", "testfolder-1"); + folder.parentName = "Bookmarks Toolbar"; + folder.parentid = "toolbar"; + folder.title = "Test Folder"; + store.applyIncoming(folder); + + _("Verify it has been created correctly."); + let id = store.idForGUID(folder.id); + do_check_eq(PlacesUtils.bookmarks.getItemType(id), + PlacesUtils.bookmarks.TYPE_FOLDER); + do_check_eq(PlacesUtils.bookmarks.getItemTitle(id), folder.title); + do_check_eq(PlacesUtils.bookmarks.getFolderIdForItem(id), + PlacesUtils.bookmarks.toolbarFolder); + + _("Have the store create a new record object. Verify that it has the same data."); + let newrecord = store.createRecord(folder.id); + do_check_true(newrecord instanceof BookmarkFolder); + for (let property of ["title", "parentName", "parentid"]) + do_check_eq(newrecord[property], folder[property]); + + _("Folders have high sort index to ensure they're synced first."); + do_check_eq(newrecord.sortindex, 1000000); + } finally { + _("Clean up."); + store.wipe(); + run_next_test(); + } +}); + +add_test(function test_folder_createRecord() { + try { + _("Create a folder."); + let folder1_id = PlacesUtils.bookmarks.createFolder( + PlacesUtils.bookmarks.toolbarFolder, "Folder1", 0); + let folder1_guid = store.GUIDForId(folder1_id); + + _("Create two bookmarks in that folder without assigning them GUIDs."); + let bmk1_id = PlacesUtils.bookmarks.insertBookmark( + folder1_id, fxuri, PlacesUtils.bookmarks.DEFAULT_INDEX, "Get Firefox!"); + let bmk2_id = PlacesUtils.bookmarks.insertBookmark( + folder1_id, tburi, PlacesUtils.bookmarks.DEFAULT_INDEX, "Get Thunderbird!"); + + _("Create a record for the folder and verify basic properties."); + let record = store.createRecord(folder1_guid); + do_check_true(record instanceof BookmarkFolder); + do_check_eq(record.title, "Folder1"); + do_check_eq(record.parentid, "toolbar"); + do_check_eq(record.parentName, "Bookmarks Toolbar"); + + _("Verify the folder's children. Ensures that the bookmarks were given GUIDs."); + let bmk1_guid = store.GUIDForId(bmk1_id); + let bmk2_guid = store.GUIDForId(bmk2_id); + do_check_eq(record.children.length, 2); + do_check_eq(record.children[0], bmk1_guid); + do_check_eq(record.children[1], bmk2_guid); + + } finally { + _("Clean up."); + store.wipe(); + run_next_test(); + } +}); + +add_task(function* test_deleted() { + try { + _("Create a bookmark that will be deleted."); + let bmk1_id = PlacesUtils.bookmarks.insertBookmark( + PlacesUtils.bookmarks.toolbarFolder, fxuri, + PlacesUtils.bookmarks.DEFAULT_INDEX, "Get Firefox!"); + let bmk1_guid = store.GUIDForId(bmk1_id); + + _("Delete the bookmark through the store."); + let record = new PlacesItem("bookmarks", bmk1_guid); + record.deleted = true; + store.applyIncoming(record); + yield store.deletePending(); + _("Ensure it has been deleted."); + let error; + try { + PlacesUtils.bookmarks.getBookmarkURI(bmk1_id); + } catch(ex) { + error = ex; + } + do_check_eq(error.result, Cr.NS_ERROR_ILLEGAL_VALUE); + + let newrec = store.createRecord(bmk1_guid); + do_check_eq(newrec.deleted, true); + + } finally { + _("Clean up."); + store.wipe(); + } +}); + +add_test(function test_move_folder() { + try { + _("Create two folders and a bookmark in one of them."); + let folder1_id = PlacesUtils.bookmarks.createFolder( + PlacesUtils.bookmarks.toolbarFolder, "Folder1", 0); + let folder1_guid = store.GUIDForId(folder1_id); + let folder2_id = PlacesUtils.bookmarks.createFolder( + PlacesUtils.bookmarks.toolbarFolder, "Folder2", 0); + let folder2_guid = store.GUIDForId(folder2_id); + let bmk_id = PlacesUtils.bookmarks.insertBookmark( + folder1_id, fxuri, PlacesUtils.bookmarks.DEFAULT_INDEX, "Get Firefox!"); + let bmk_guid = store.GUIDForId(bmk_id); + + _("Get a record, reparent it and apply it to the store."); + let record = store.createRecord(bmk_guid); + do_check_eq(record.parentid, folder1_guid); + record.parentid = folder2_guid; + store.applyIncoming(record); + + _("Verify the new parent."); + let new_folder_id = PlacesUtils.bookmarks.getFolderIdForItem(bmk_id); + do_check_eq(store.GUIDForId(new_folder_id), folder2_guid); + } finally { + _("Clean up."); + store.wipe(); + run_next_test(); + } +}); + +add_test(function test_move_order() { + // Make sure the tracker is turned on. + Svc.Obs.notify("weave:engine:start-tracking"); + try { + _("Create two bookmarks"); + let bmk1_id = PlacesUtils.bookmarks.insertBookmark( + PlacesUtils.bookmarks.toolbarFolder, fxuri, + PlacesUtils.bookmarks.DEFAULT_INDEX, "Get Firefox!"); + let bmk1_guid = store.GUIDForId(bmk1_id); + let bmk2_id = PlacesUtils.bookmarks.insertBookmark( + PlacesUtils.bookmarks.toolbarFolder, tburi, + PlacesUtils.bookmarks.DEFAULT_INDEX, "Get Thunderbird!"); + let bmk2_guid = store.GUIDForId(bmk2_id); + + _("Verify order."); + do_check_eq(PlacesUtils.bookmarks.getItemIndex(bmk1_id), 0); + do_check_eq(PlacesUtils.bookmarks.getItemIndex(bmk2_id), 1); + let toolbar = store.createRecord("toolbar"); + do_check_eq(toolbar.children.length, 2); + do_check_eq(toolbar.children[0], bmk1_guid); + do_check_eq(toolbar.children[1], bmk2_guid); + + _("Move bookmarks around."); + store._childrenToOrder = {}; + toolbar.children = [bmk2_guid, bmk1_guid]; + store.applyIncoming(toolbar); + // Bookmarks engine does this at the end of _processIncoming + tracker.ignoreAll = true; + store._orderChildren(); + tracker.ignoreAll = false; + delete store._childrenToOrder; + + _("Verify new order."); + do_check_eq(PlacesUtils.bookmarks.getItemIndex(bmk2_id), 0); + do_check_eq(PlacesUtils.bookmarks.getItemIndex(bmk1_id), 1); + + } finally { + Svc.Obs.notify("weave:engine:stop-tracking"); + _("Clean up."); + store.wipe(); + run_next_test(); + } +}); + +add_test(function test_orphan() { + try { + + _("Add a new bookmark locally."); + let bmk1_id = PlacesUtils.bookmarks.insertBookmark( + PlacesUtils.bookmarks.toolbarFolder, fxuri, + PlacesUtils.bookmarks.DEFAULT_INDEX, "Get Firefox!"); + let bmk1_guid = store.GUIDForId(bmk1_id); + do_check_eq(PlacesUtils.bookmarks.getFolderIdForItem(bmk1_id), + PlacesUtils.bookmarks.toolbarFolder); + let error; + try { + PlacesUtils.annotations.getItemAnnotation(bmk1_id, PARENT_ANNO); + } catch(ex) { + error = ex; + } + do_check_eq(error.result, Cr.NS_ERROR_NOT_AVAILABLE); + + _("Apply a server record that is the same but refers to non-existent folder."); + let record = store.createRecord(bmk1_guid); + record.parentid = "non-existent"; + store.applyIncoming(record); + + _("Verify that bookmark has been flagged as orphan, has not moved."); + do_check_eq(PlacesUtils.bookmarks.getFolderIdForItem(bmk1_id), + PlacesUtils.bookmarks.toolbarFolder); + do_check_eq(PlacesUtils.annotations.getItemAnnotation(bmk1_id, PARENT_ANNO), + "non-existent"); + + } finally { + _("Clean up."); + store.wipe(); + run_next_test(); + } +}); + +add_test(function test_reparentOrphans() { + try { + let folder1_id = PlacesUtils.bookmarks.createFolder( + PlacesUtils.bookmarks.toolbarFolder, "Folder1", 0); + let folder1_guid = store.GUIDForId(folder1_id); + + _("Create a bogus orphan record and write the record back to the store to trigger _reparentOrphans."); + PlacesUtils.annotations.setItemAnnotation( + folder1_id, PARENT_ANNO, folder1_guid, 0, + PlacesUtils.annotations.EXPIRE_NEVER); + let record = store.createRecord(folder1_guid); + record.title = "New title for Folder 1"; + store._childrenToOrder = {}; + store.applyIncoming(record); + + _("Verify that is has been marked as an orphan even though it couldn't be moved into itself."); + do_check_eq(PlacesUtils.annotations.getItemAnnotation(folder1_id, PARENT_ANNO), + folder1_guid); + + } finally { + _("Clean up."); + store.wipe(); + run_next_test(); + } +}); + +// Tests Bug 806460, in which query records arrive with empty folder +// names and missing bookmark URIs. +add_test(function test_empty_query_doesnt_die() { + let record = new BookmarkQuery("bookmarks", "8xoDGqKrXf1P"); + record.folderName = ""; + record.queryId = ""; + record.parentName = "Toolbar"; + record.parentid = "toolbar"; + + // These should not throw. + store.applyIncoming(record); + + delete record.folderName; + store.applyIncoming(record); + + run_next_test(); +}); + +function assertDeleted(id) { + let error; + try { + PlacesUtils.bookmarks.getItemType(id); + } catch (e) { + error = e; + } + equal(error.result, Cr.NS_ERROR_ILLEGAL_VALUE) +} + +add_task(function* test_delete_buffering() { + store.wipe(); + try { + _("Create a folder with two bookmarks."); + let folder = new BookmarkFolder("bookmarks", "testfolder-1"); + folder.parentName = "Bookmarks Toolbar"; + folder.parentid = "toolbar"; + folder.title = "Test Folder"; + store.applyIncoming(folder); + + + let fxRecord = new Bookmark("bookmarks", "get-firefox1"); + fxRecord.bmkUri = fxuri.spec; + fxRecord.title = "Get Firefox!"; + fxRecord.parentName = "Test Folder"; + fxRecord.parentid = "testfolder-1"; + + let tbRecord = new Bookmark("bookmarks", "get-tndrbrd1"); + tbRecord.bmkUri = tburi.spec; + tbRecord.title = "Get Thunderbird!"; + tbRecord.parentName = "Test Folder"; + tbRecord.parentid = "testfolder-1"; + + store.applyIncoming(fxRecord); + store.applyIncoming(tbRecord); + + let folderId = store.idForGUID(folder.id); + let fxRecordId = store.idForGUID(fxRecord.id); + let tbRecordId = store.idForGUID(tbRecord.id); + + _("Check everything was created correctly."); + + equal(PlacesUtils.bookmarks.getItemType(fxRecordId), + PlacesUtils.bookmarks.TYPE_BOOKMARK); + equal(PlacesUtils.bookmarks.getItemType(tbRecordId), + PlacesUtils.bookmarks.TYPE_BOOKMARK); + equal(PlacesUtils.bookmarks.getItemType(folderId), + PlacesUtils.bookmarks.TYPE_FOLDER); + + equal(PlacesUtils.bookmarks.getFolderIdForItem(fxRecordId), folderId); + equal(PlacesUtils.bookmarks.getFolderIdForItem(tbRecordId), folderId); + equal(PlacesUtils.bookmarks.getFolderIdForItem(folderId), + PlacesUtils.bookmarks.toolbarFolder); + + _("Delete the folder and one bookmark."); + + let deleteFolder = new PlacesItem("bookmarks", "testfolder-1"); + deleteFolder.deleted = true; + + let deleteFxRecord = new PlacesItem("bookmarks", "get-firefox1"); + deleteFxRecord.deleted = true; + + store.applyIncoming(deleteFolder); + store.applyIncoming(deleteFxRecord); + + _("Check that we haven't deleted them yet, but that the deletions are queued"); + // these will throw if we've deleted them + equal(PlacesUtils.bookmarks.getItemType(fxRecordId), + PlacesUtils.bookmarks.TYPE_BOOKMARK); + + equal(PlacesUtils.bookmarks.getItemType(folderId), + PlacesUtils.bookmarks.TYPE_FOLDER); + + equal(PlacesUtils.bookmarks.getFolderIdForItem(fxRecordId), folderId); + + ok(store._foldersToDelete.has(folder.id)); + ok(store._atomsToDelete.has(fxRecord.id)); + ok(!store._atomsToDelete.has(tbRecord.id)); + + _("Process pending deletions and ensure that the right things are deleted."); + let updatedGuids = yield store.deletePending(); + + deepEqual(updatedGuids.sort(), ["get-tndrbrd1", "toolbar"]); + + assertDeleted(fxRecordId); + assertDeleted(folderId); + + ok(!store._foldersToDelete.has(folder.id)); + ok(!store._atomsToDelete.has(fxRecord.id)); + + equal(PlacesUtils.bookmarks.getFolderIdForItem(tbRecordId), + PlacesUtils.bookmarks.toolbarFolder); + + } finally { + _("Clean up."); + store.wipe(); + } +}); + + +function run_test() { + initTestLogging('Trace'); + run_next_test(); +} diff --git a/services/sync/tests/unit/test_bookmark_tracker.js b/services/sync/tests/unit/test_bookmark_tracker.js new file mode 100644 index 0000000000..9b92425790 --- /dev/null +++ b/services/sync/tests/unit/test_bookmark_tracker.js @@ -0,0 +1,1537 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +Cu.import("resource://gre/modules/PlacesUtils.jsm"); +Cu.import("resource://gre/modules/PlacesSyncUtils.jsm"); +Cu.import("resource://gre/modules/Task.jsm"); +Cu.import("resource://services-sync/constants.js"); +Cu.import("resource://services-sync/engines/bookmarks.js"); +Cu.import("resource://services-sync/engines.js"); +Cu.import("resource://services-sync/service.js"); +Cu.import("resource://services-sync/util.js"); +Cu.import("resource:///modules/PlacesUIUtils.jsm"); + +Service.engineManager.register(BookmarksEngine); +var engine = Service.engineManager.get("bookmarks"); +var store = engine._store; +var tracker = engine._tracker; + +store.wipe(); +tracker.persistChangedIDs = false; + +const DAY_IN_MS = 24 * 60 * 60 * 1000; + +// Test helpers. +function* verifyTrackerEmpty() { + let changes = engine.pullNewChanges(); + equal(changes.count(), 0); + equal(tracker.score, 0); +} + +function* resetTracker() { + tracker.clearChangedIDs(); + tracker.resetScore(); +} + +function* cleanup() { + store.wipe(); + yield resetTracker(); + yield stopTracking(); +} + +// startTracking is a signal that the test wants to notice things that happen +// after this is called (ie, things already tracked should be discarded.) +function* startTracking() { + Svc.Obs.notify("weave:engine:start-tracking"); +} + +function* stopTracking() { + Svc.Obs.notify("weave:engine:stop-tracking"); +} + +function* verifyTrackedItems(tracked) { + let changes = engine.pullNewChanges(); + let trackedIDs = new Set(changes.ids()); + for (let guid of tracked) { + ok(changes.has(guid), `${guid} should be tracked`); + ok(changes.getModifiedTimestamp(guid) > 0, + `${guid} should have a modified time`); + trackedIDs.delete(guid); + } + equal(trackedIDs.size, 0, `Unhandled tracked IDs: ${ + JSON.stringify(Array.from(trackedIDs))}`); +} + +function* verifyTrackedCount(expected) { + let changes = engine.pullNewChanges(); + equal(changes.count(), expected); +} + +// Copied from PlacesSyncUtils.jsm. +function findAnnoItems(anno, val) { + let annos = PlacesUtils.annotations; + return annos.getItemsWithAnnotation(anno, {}).filter(id => + annos.getItemAnnotation(id, anno) == val); +} + +add_task(function* test_tracking() { + _("Test starting and stopping the tracker"); + + let folder = PlacesUtils.bookmarks.createFolder( + PlacesUtils.bookmarks.bookmarksMenuFolder, + "Test Folder", PlacesUtils.bookmarks.DEFAULT_INDEX); + function createBmk() { + return PlacesUtils.bookmarks.insertBookmark( + folder, Utils.makeURI("http://getfirefox.com"), + PlacesUtils.bookmarks.DEFAULT_INDEX, "Get Firefox!"); + } + + try { + _("Create bookmark. Won't show because we haven't started tracking yet"); + createBmk(); + yield verifyTrackedCount(0); + do_check_eq(tracker.score, 0); + + _("Tell the tracker to start tracking changes."); + yield startTracking(); + createBmk(); + // We expect two changed items because the containing folder + // changed as well (new child). + yield verifyTrackedCount(2); + do_check_eq(tracker.score, SCORE_INCREMENT_XLARGE * 2); + + _("Notifying twice won't do any harm."); + yield startTracking(); + createBmk(); + yield verifyTrackedCount(3); + do_check_eq(tracker.score, SCORE_INCREMENT_XLARGE * 4); + + _("Let's stop tracking again."); + yield resetTracker(); + yield stopTracking(); + createBmk(); + yield verifyTrackedCount(0); + do_check_eq(tracker.score, 0); + + _("Notifying twice won't do any harm."); + yield stopTracking(); + createBmk(); + yield verifyTrackedCount(0); + do_check_eq(tracker.score, 0); + + } finally { + _("Clean up."); + yield cleanup(); + } +}); + +add_task(function* test_batch_tracking() { + _("Test tracker does the correct thing during and after a places 'batch'"); + + yield startTracking(); + + PlacesUtils.bookmarks.runInBatchMode({ + runBatched: function() { + let folder = PlacesUtils.bookmarks.createFolder( + PlacesUtils.bookmarks.bookmarksMenuFolder, + "Test Folder", PlacesUtils.bookmarks.DEFAULT_INDEX); + // We should be tracking the new folder and its parent (and need to jump + // through blocking hoops...) + Async.promiseSpinningly(Task.spawn(verifyTrackedCount(2))); + // But not have bumped the score. + do_check_eq(tracker.score, 0); + } + }, null); + + // Out of batch mode - tracker should be the same, but score should be up. + yield verifyTrackedCount(2); + do_check_eq(tracker.score, SCORE_INCREMENT_XLARGE); + yield cleanup(); +}); + +add_task(function* test_nested_batch_tracking() { + _("Test tracker does the correct thing if a places 'batch' is nested"); + + yield startTracking(); + + PlacesUtils.bookmarks.runInBatchMode({ + runBatched: function() { + + PlacesUtils.bookmarks.runInBatchMode({ + runBatched: function() { + let folder = PlacesUtils.bookmarks.createFolder( + PlacesUtils.bookmarks.bookmarksMenuFolder, + "Test Folder", PlacesUtils.bookmarks.DEFAULT_INDEX); + // We should be tracking the new folder and its parent (and need to jump + // through blocking hoops...) + Async.promiseSpinningly(Task.spawn(verifyTrackedCount(2))); + // But not have bumped the score. + do_check_eq(tracker.score, 0); + } + }, null); + _("inner batch complete."); + // should still not have a score as the outer batch is pending. + do_check_eq(tracker.score, 0); + } + }, null); + + // Out of both batches - tracker should be the same, but score should be up. + yield verifyTrackedCount(2); + do_check_eq(tracker.score, SCORE_INCREMENT_XLARGE); + yield cleanup(); +}); + +add_task(function* test_tracker_sql_batching() { + _("Test tracker does the correct thing when it is forced to batch SQL queries"); + + const SQLITE_MAX_VARIABLE_NUMBER = 999; + let numItems = SQLITE_MAX_VARIABLE_NUMBER * 2 + 10; + let createdIDs = []; + + yield startTracking(); + + PlacesUtils.bookmarks.runInBatchMode({ + runBatched: function() { + for (let i = 0; i < numItems; i++) { + let syncBmkID = PlacesUtils.bookmarks.insertBookmark( + PlacesUtils.bookmarks.unfiledBookmarksFolder, + Utils.makeURI("https://example.org/" + i), + PlacesUtils.bookmarks.DEFAULT_INDEX, + "Sync Bookmark " + i); + createdIDs.push(syncBmkID); + } + } + }, null); + + do_check_eq(createdIDs.length, numItems); + yield verifyTrackedCount(numItems + 1); // the folder is also tracked. + yield cleanup(); +}); + +add_task(function* test_onItemAdded() { + _("Items inserted via the synchronous bookmarks API should be tracked"); + + try { + yield startTracking(); + + _("Insert a folder using the sync API"); + let syncFolderID = PlacesUtils.bookmarks.createFolder( + PlacesUtils.bookmarks.bookmarksMenuFolder, "Sync Folder", + PlacesUtils.bookmarks.DEFAULT_INDEX); + let syncFolderGUID = engine._store.GUIDForId(syncFolderID); + yield verifyTrackedItems(["menu", syncFolderGUID]); + do_check_eq(tracker.score, SCORE_INCREMENT_XLARGE * 2); + + yield resetTracker(); + yield startTracking(); + + _("Insert a bookmark using the sync API"); + let syncBmkID = PlacesUtils.bookmarks.insertBookmark(syncFolderID, + Utils.makeURI("https://example.org/sync"), + PlacesUtils.bookmarks.DEFAULT_INDEX, + "Sync Bookmark"); + let syncBmkGUID = engine._store.GUIDForId(syncBmkID); + yield verifyTrackedItems([syncFolderGUID, syncBmkGUID]); + do_check_eq(tracker.score, SCORE_INCREMENT_XLARGE * 2); + + yield resetTracker(); + yield startTracking(); + + _("Insert a separator using the sync API"); + let syncSepID = PlacesUtils.bookmarks.insertSeparator( + PlacesUtils.bookmarks.bookmarksMenuFolder, + PlacesUtils.bookmarks.getItemIndex(syncFolderID)); + let syncSepGUID = engine._store.GUIDForId(syncSepID); + yield verifyTrackedItems(["menu", syncSepGUID]); + do_check_eq(tracker.score, SCORE_INCREMENT_XLARGE * 2); + } finally { + _("Clean up."); + yield cleanup(); + } +}); + +add_task(function* test_async_onItemAdded() { + _("Items inserted via the asynchronous bookmarks API should be tracked"); + + try { + yield startTracking(); + + _("Insert a folder using the async API"); + let asyncFolder = yield PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_FOLDER, + parentGuid: PlacesUtils.bookmarks.menuGuid, + title: "Async Folder", + }); + yield verifyTrackedItems(["menu", asyncFolder.guid]); + do_check_eq(tracker.score, SCORE_INCREMENT_XLARGE * 2); + + yield resetTracker(); + yield startTracking(); + + _("Insert a bookmark using the async API"); + let asyncBmk = yield PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + parentGuid: asyncFolder.guid, + url: "https://example.org/async", + title: "Async Bookmark", + }); + yield verifyTrackedItems([asyncFolder.guid, asyncBmk.guid]); + do_check_eq(tracker.score, SCORE_INCREMENT_XLARGE * 2); + + yield resetTracker(); + yield startTracking(); + + _("Insert a separator using the async API"); + let asyncSep = yield PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_SEPARATOR, + parentGuid: PlacesUtils.bookmarks.menuGuid, + index: asyncFolder.index, + }); + yield verifyTrackedItems(["menu", asyncSep.guid]); + do_check_eq(tracker.score, SCORE_INCREMENT_XLARGE * 2); + } finally { + _("Clean up."); + yield cleanup(); + } +}); + +add_task(function* test_async_onItemChanged() { + _("Items updated using the asynchronous bookmarks API should be tracked"); + + try { + yield stopTracking(); + + _("Insert a bookmark"); + let fxBmk = yield PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + parentGuid: PlacesUtils.bookmarks.menuGuid, + url: "http://getfirefox.com", + title: "Get Firefox!", + }); + _(`Firefox GUID: ${fxBmk.guid}`); + + yield startTracking(); + + _("Update the bookmark using the async API"); + yield PlacesUtils.bookmarks.update({ + guid: fxBmk.guid, + title: "Download Firefox", + url: "https://www.mozilla.org/firefox", + // PlacesUtils.bookmarks.update rejects last modified dates older than + // the added date. + lastModified: new Date(Date.now() + DAY_IN_MS), + }); + + yield verifyTrackedItems([fxBmk.guid]); + do_check_eq(tracker.score, SCORE_INCREMENT_XLARGE * 3); + } finally { + _("Clean up."); + yield cleanup(); + } +}); + +add_task(function* test_onItemChanged_itemDates() { + _("Changes to item dates should be tracked"); + + try { + yield stopTracking(); + + _("Insert a bookmark"); + let fx_id = PlacesUtils.bookmarks.insertBookmark( + PlacesUtils.bookmarks.bookmarksMenuFolder, + Utils.makeURI("http://getfirefox.com"), + PlacesUtils.bookmarks.DEFAULT_INDEX, + "Get Firefox!"); + let fx_guid = engine._store.GUIDForId(fx_id); + _(`Firefox GUID: ${fx_guid}`); + + yield startTracking(); + + _("Reset the bookmark's added date"); + // Convert to microseconds for PRTime. + let dateAdded = (Date.now() - DAY_IN_MS) * 1000; + PlacesUtils.bookmarks.setItemDateAdded(fx_id, dateAdded); + yield verifyTrackedItems([fx_guid]); + do_check_eq(tracker.score, SCORE_INCREMENT_XLARGE); + yield resetTracker(); + + _("Set the bookmark's last modified date"); + let dateModified = Date.now() * 1000; + PlacesUtils.bookmarks.setItemLastModified(fx_id, dateModified); + yield verifyTrackedItems([fx_guid]); + do_check_eq(tracker.score, SCORE_INCREMENT_XLARGE); + } finally { + _("Clean up."); + yield cleanup(); + } +}); + +add_task(function* test_onItemChanged_changeBookmarkURI() { + _("Changes to bookmark URIs should be tracked"); + + try { + yield stopTracking(); + + _("Insert a bookmark"); + let fx_id = PlacesUtils.bookmarks.insertBookmark( + PlacesUtils.bookmarks.bookmarksMenuFolder, + Utils.makeURI("http://getfirefox.com"), + PlacesUtils.bookmarks.DEFAULT_INDEX, + "Get Firefox!"); + let fx_guid = engine._store.GUIDForId(fx_id); + _(`Firefox GUID: ${fx_guid}`); + + _("Set a tracked annotation to make sure we only notify once"); + PlacesUtils.annotations.setItemAnnotation( + fx_id, PlacesSyncUtils.bookmarks.DESCRIPTION_ANNO, "A test description", 0, + PlacesUtils.annotations.EXPIRE_NEVER); + + yield startTracking(); + + _("Change the bookmark's URI"); + PlacesUtils.bookmarks.changeBookmarkURI(fx_id, + Utils.makeURI("https://www.mozilla.org/firefox")); + yield verifyTrackedItems([fx_guid]); + do_check_eq(tracker.score, SCORE_INCREMENT_XLARGE); + } finally { + _("Clean up."); + yield cleanup(); + } +}); + +add_task(function* test_onItemTagged() { + _("Items tagged using the synchronous API should be tracked"); + + try { + yield stopTracking(); + + _("Create a folder"); + let folder = PlacesUtils.bookmarks.createFolder( + PlacesUtils.bookmarks.bookmarksMenuFolder, "Parent", + PlacesUtils.bookmarks.DEFAULT_INDEX); + let folderGUID = engine._store.GUIDForId(folder); + _("Folder ID: " + folder); + _("Folder GUID: " + folderGUID); + + _("Track changes to tags"); + let uri = Utils.makeURI("http://getfirefox.com"); + let b = PlacesUtils.bookmarks.insertBookmark( + folder, uri, + PlacesUtils.bookmarks.DEFAULT_INDEX, "Get Firefox!"); + let bGUID = engine._store.GUIDForId(b); + _("New item is " + b); + _("GUID: " + bGUID); + + yield startTracking(); + + _("Tag the item"); + PlacesUtils.tagging.tagURI(uri, ["foo"]); + + // bookmark should be tracked, folder should not be. + yield verifyTrackedItems([bGUID]); + do_check_eq(tracker.score, SCORE_INCREMENT_XLARGE * 5); + } finally { + _("Clean up."); + yield cleanup(); + } +}); + +add_task(function* test_onItemUntagged() { + _("Items untagged using the synchronous API should be tracked"); + + try { + yield stopTracking(); + + _("Insert tagged bookmarks"); + let uri = Utils.makeURI("http://getfirefox.com"); + let fx1ID = PlacesUtils.bookmarks.insertBookmark( + PlacesUtils.bookmarks.bookmarksMenuFolder, uri, + PlacesUtils.bookmarks.DEFAULT_INDEX, "Get Firefox!"); + let fx1GUID = engine._store.GUIDForId(fx1ID); + // Different parent and title; same URL. + let fx2ID = PlacesUtils.bookmarks.insertBookmark( + PlacesUtils.bookmarks.toolbarFolder, uri, + PlacesUtils.bookmarks.DEFAULT_INDEX, "Download Firefox"); + let fx2GUID = engine._store.GUIDForId(fx2ID); + PlacesUtils.tagging.tagURI(uri, ["foo"]); + + yield startTracking(); + + _("Remove the tag"); + PlacesUtils.tagging.untagURI(uri, ["foo"]); + + yield verifyTrackedItems([fx1GUID, fx2GUID]); + do_check_eq(tracker.score, SCORE_INCREMENT_XLARGE * 2); + } finally { + _("Clean up."); + yield cleanup(); + } +}); + +add_task(function* test_async_onItemUntagged() { + _("Items untagged using the asynchronous API should be tracked"); + + try { + yield stopTracking(); + + _("Insert tagged bookmarks"); + let fxBmk1 = yield PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + parentGuid: PlacesUtils.bookmarks.menuGuid, + url: "http://getfirefox.com", + title: "Get Firefox!", + }); + let fxBmk2 = yield PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + url: "http://getfirefox.com", + title: "Download Firefox", + }); + let tag = yield PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_FOLDER, + parentGuid: PlacesUtils.bookmarks.tagsGuid, + title: "some tag", + }); + let fxTag = yield PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + parentGuid: tag.guid, + url: "http://getfirefox.com", + }); + + yield startTracking(); + + _("Remove the tag using the async bookmarks API"); + yield PlacesUtils.bookmarks.remove(fxTag.guid); + + yield verifyTrackedItems([fxBmk1.guid, fxBmk2.guid]); + do_check_eq(tracker.score, SCORE_INCREMENT_XLARGE * 2); + } finally { + _("Clean up."); + yield cleanup(); + } +}); + +add_task(function* test_async_onItemTagged() { + _("Items tagged using the asynchronous API should be tracked"); + + try { + yield stopTracking(); + + _("Insert untagged bookmarks"); + let folder1 = yield PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_FOLDER, + parentGuid: PlacesUtils.bookmarks.menuGuid, + title: "Folder 1", + }); + let fxBmk1 = yield PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + parentGuid: folder1.guid, + url: "http://getfirefox.com", + title: "Get Firefox!", + }); + let folder2 = yield PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_FOLDER, + parentGuid: PlacesUtils.bookmarks.menuGuid, + title: "Folder 2", + }); + // Different parent and title; same URL. + let fxBmk2 = yield PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + parentGuid: folder2.guid, + url: "http://getfirefox.com", + title: "Download Firefox", + }); + + yield startTracking(); + + // This will change once tags are moved into a separate table (bug 424160). + // We specifically test this case because Bookmarks.jsm updates tagged + // bookmarks and notifies observers. + _("Insert a tag using the async bookmarks API"); + let tag = yield PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_FOLDER, + parentGuid: PlacesUtils.bookmarks.tagsGuid, + title: "some tag", + }); + + _("Tag an item using the async bookmarks API"); + yield PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + parentGuid: tag.guid, + url: "http://getfirefox.com", + }); + + yield verifyTrackedItems([fxBmk1.guid, fxBmk2.guid]); + do_check_eq(tracker.score, SCORE_INCREMENT_XLARGE * 6); + } finally { + _("Clean up."); + yield cleanup(); + } +}); + +add_task(function* test_onItemKeywordChanged() { + _("Keyword changes via the synchronous API should be tracked"); + + try { + yield stopTracking(); + let folder = PlacesUtils.bookmarks.createFolder( + PlacesUtils.bookmarks.bookmarksMenuFolder, "Parent", + PlacesUtils.bookmarks.DEFAULT_INDEX); + let folderGUID = engine._store.GUIDForId(folder); + _("Track changes to keywords"); + let uri = Utils.makeURI("http://getfirefox.com"); + let b = PlacesUtils.bookmarks.insertBookmark( + folder, uri, + PlacesUtils.bookmarks.DEFAULT_INDEX, "Get Firefox!"); + let bGUID = engine._store.GUIDForId(b); + _("New item is " + b); + _("GUID: " + bGUID); + + yield startTracking(); + + _("Give the item a keyword"); + PlacesUtils.bookmarks.setKeywordForBookmark(b, "the_keyword"); + + // bookmark should be tracked, folder should not be. + yield verifyTrackedItems([bGUID]); + do_check_eq(tracker.score, SCORE_INCREMENT_XLARGE); + + } finally { + _("Clean up."); + yield cleanup(); + } +}); + +add_task(function* test_async_onItemKeywordChanged() { + _("Keyword changes via the asynchronous API should be tracked"); + + try { + yield stopTracking(); + + _("Insert two bookmarks with the same URL"); + let fxBmk1 = yield PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + parentGuid: PlacesUtils.bookmarks.menuGuid, + url: "http://getfirefox.com", + title: "Get Firefox!", + }); + let fxBmk2 = yield PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + url: "http://getfirefox.com", + title: "Download Firefox", + }); + + yield startTracking(); + + _("Add a keyword for both items"); + yield PlacesUtils.keywords.insert({ + keyword: "the_keyword", + url: "http://getfirefox.com", + postData: "postData", + }); + + yield verifyTrackedItems([fxBmk1.guid, fxBmk2.guid]); + do_check_eq(tracker.score, SCORE_INCREMENT_XLARGE * 2); + } finally { + _("Clean up."); + yield cleanup(); + } +}); + +add_task(function* test_async_onItemKeywordDeleted() { + _("Keyword deletions via the asynchronous API should be tracked"); + + try { + yield stopTracking(); + + _("Insert two bookmarks with the same URL and keywords"); + let fxBmk1 = yield PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + parentGuid: PlacesUtils.bookmarks.menuGuid, + url: "http://getfirefox.com", + title: "Get Firefox!", + }); + let fxBmk2 = yield PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + url: "http://getfirefox.com", + title: "Download Firefox", + }); + yield PlacesUtils.keywords.insert({ + keyword: "the_keyword", + url: "http://getfirefox.com", + }); + + yield startTracking(); + + _("Remove the keyword"); + yield PlacesUtils.keywords.remove("the_keyword"); + + yield verifyTrackedItems([fxBmk1.guid, fxBmk2.guid]); + do_check_eq(tracker.score, SCORE_INCREMENT_XLARGE * 2); + } finally { + _("Clean up."); + yield cleanup(); + } +}); + +add_task(function* test_onItemPostDataChanged() { + _("Post data changes should be tracked"); + + try { + yield stopTracking(); + + _("Insert a bookmark"); + let fx_id = PlacesUtils.bookmarks.insertBookmark( + PlacesUtils.bookmarks.bookmarksMenuFolder, + Utils.makeURI("http://getfirefox.com"), + PlacesUtils.bookmarks.DEFAULT_INDEX, + "Get Firefox!"); + let fx_guid = engine._store.GUIDForId(fx_id); + _(`Firefox GUID: ${fx_guid}`); + + yield startTracking(); + + // PlacesUtils.setPostDataForBookmark is deprecated, but still used by + // PlacesTransactions.NewBookmark. + _("Post data for the bookmark should be ignored"); + yield PlacesUtils.setPostDataForBookmark(fx_id, "postData"); + yield verifyTrackerEmpty(); + } finally { + _("Clean up."); + yield cleanup(); + } +}); + +add_task(function* test_onItemAnnoChanged() { + _("Item annotations should be tracked"); + + try { + yield stopTracking(); + let folder = PlacesUtils.bookmarks.createFolder( + PlacesUtils.bookmarks.bookmarksMenuFolder, "Parent", + PlacesUtils.bookmarks.DEFAULT_INDEX); + let folderGUID = engine._store.GUIDForId(folder); + _("Track changes to annos."); + let b = PlacesUtils.bookmarks.insertBookmark( + folder, Utils.makeURI("http://getfirefox.com"), + PlacesUtils.bookmarks.DEFAULT_INDEX, "Get Firefox!"); + let bGUID = engine._store.GUIDForId(b); + _("New item is " + b); + _("GUID: " + bGUID); + + yield startTracking(); + PlacesUtils.annotations.setItemAnnotation( + b, PlacesSyncUtils.bookmarks.DESCRIPTION_ANNO, "A test description", 0, + PlacesUtils.annotations.EXPIRE_NEVER); + // bookmark should be tracked, folder should not. + yield verifyTrackedItems([bGUID]); + do_check_eq(tracker.score, SCORE_INCREMENT_XLARGE); + yield resetTracker(); + + PlacesUtils.annotations.removeItemAnnotation(b, + PlacesSyncUtils.bookmarks.DESCRIPTION_ANNO); + yield verifyTrackedItems([bGUID]); + do_check_eq(tracker.score, SCORE_INCREMENT_XLARGE); + } finally { + _("Clean up."); + yield cleanup(); + } +}); + +add_task(function* test_onItemAdded_filtered_root() { + _("Items outside the change roots should not be tracked"); + + try { + yield startTracking(); + + _("Create a new root"); + let rootID = PlacesUtils.bookmarks.createFolder( + PlacesUtils.bookmarks.placesRoot, + "New root", + PlacesUtils.bookmarks.DEFAULT_INDEX); + let rootGUID = engine._store.GUIDForId(rootID); + _(`New root GUID: ${rootGUID}`); + + _("Insert a bookmark underneath the new root"); + let untrackedBmkID = PlacesUtils.bookmarks.insertBookmark( + rootID, + Utils.makeURI("http://getthunderbird.com"), + PlacesUtils.bookmarks.DEFAULT_INDEX, + "Get Thunderbird!"); + let untrackedBmkGUID = engine._store.GUIDForId(untrackedBmkID); + _(`New untracked bookmark GUID: ${untrackedBmkGUID}`); + + _("Insert a bookmark underneath the Places root"); + let rootBmkID = PlacesUtils.bookmarks.insertBookmark( + PlacesUtils.bookmarks.placesRoot, + Utils.makeURI("http://getfirefox.com"), + PlacesUtils.bookmarks.DEFAULT_INDEX, "Get Firefox!"); + let rootBmkGUID = engine._store.GUIDForId(rootBmkID); + _(`New Places root bookmark GUID: ${rootBmkGUID}`); + + _("New root and bookmark should be ignored"); + yield verifyTrackedItems([]); + // ...But we'll still increment the score and filter out the changes at + // sync time. + do_check_eq(tracker.score, SCORE_INCREMENT_XLARGE * 6); + } finally { + _("Clean up."); + yield cleanup(); + } +}); + +add_task(function* test_onItemDeleted_filtered_root() { + _("Deleted items outside the change roots should be tracked"); + + try { + yield stopTracking(); + + _("Insert a bookmark underneath the Places root"); + let rootBmkID = PlacesUtils.bookmarks.insertBookmark( + PlacesUtils.bookmarks.placesRoot, + Utils.makeURI("http://getfirefox.com"), + PlacesUtils.bookmarks.DEFAULT_INDEX, "Get Firefox!"); + let rootBmkGUID = engine._store.GUIDForId(rootBmkID); + _(`New Places root bookmark GUID: ${rootBmkGUID}`); + + yield startTracking(); + + PlacesUtils.bookmarks.removeItem(rootBmkID); + + // We shouldn't upload tombstones for items in filtered roots, but the + // `onItemRemoved` observer doesn't have enough context to determine + // the root, so we'll end up uploading it. + yield verifyTrackedItems([rootBmkGUID]); + // We'll increment the counter twice (once for the removed item, and once + // for the Places root), then filter out the root. + do_check_eq(tracker.score, SCORE_INCREMENT_XLARGE * 2); + } finally { + _("Clean up."); + yield cleanup(); + } +}); + +add_task(function* test_onPageAnnoChanged() { + _("Page annotations should not be tracked"); + + try { + yield stopTracking(); + + _("Insert a bookmark without an annotation"); + let pageURI = Utils.makeURI("http://getfirefox.com"); + PlacesUtils.bookmarks.insertBookmark( + PlacesUtils.bookmarks.bookmarksMenuFolder, + pageURI, + PlacesUtils.bookmarks.DEFAULT_INDEX, + "Get Firefox!"); + + yield startTracking(); + + _("Add a page annotation"); + PlacesUtils.annotations.setPageAnnotation(pageURI, "URIProperties/characterSet", + "UTF-8", 0, PlacesUtils.annotations.EXPIRE_NEVER); + yield verifyTrackerEmpty(); + yield resetTracker(); + + _("Remove the page annotation"); + PlacesUtils.annotations.removePageAnnotation(pageURI, + "URIProperties/characterSet"); + yield verifyTrackerEmpty(); + } finally { + _("Clean up."); + yield cleanup(); + } +}); + +add_task(function* test_onFaviconChanged() { + _("Favicon changes should not be tracked"); + + try { + yield stopTracking(); + + let pageURI = Utils.makeURI("http://getfirefox.com"); + let iconURI = Utils.makeURI("http://getfirefox.com/icon"); + PlacesUtils.bookmarks.insertBookmark( + PlacesUtils.bookmarks.bookmarksMenuFolder, + pageURI, + PlacesUtils.bookmarks.DEFAULT_INDEX, + "Get Firefox!"); + + yield PlacesTestUtils.addVisits(pageURI); + + yield startTracking(); + + _("Favicon annotations should be ignored"); + let iconURL = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAA" + + "AAAA6fptVAAAACklEQVQI12NgAAAAAgAB4iG8MwAAAABJRU5ErkJggg=="; + + PlacesUtils.favicons.replaceFaviconDataFromDataURL(iconURI, iconURL, 0, + Services.scriptSecurityManager.getSystemPrincipal()); + + yield new Promise(resolve => { + PlacesUtils.favicons.setAndFetchFaviconForPage(pageURI, iconURI, true, + PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE, (iconURI, dataLen, data, mimeType) => { + resolve(); + }, + Services.scriptSecurityManager.getSystemPrincipal()); + }); + yield verifyTrackerEmpty(); + } finally { + _("Clean up."); + yield cleanup(); + } +}); + +add_task(function* test_onLivemarkAdded() { + _("New livemarks should be tracked"); + + try { + yield startTracking(); + + _("Insert a livemark"); + let livemark = yield PlacesUtils.livemarks.addLivemark({ + parentGuid: PlacesUtils.bookmarks.menuGuid, + // Use a local address just in case, to avoid potential aborts for + // non-local connections. + feedURI: Utils.makeURI("http://localhost:0"), + }); + // Prevent the livemark refresh timer from requesting the URI. + livemark.terminate(); + + yield verifyTrackedItems(["menu", livemark.guid]); + // Three changes: one for the parent, one for creating the livemark + // folder, and one for setting the "livemark/feedURI" anno on the folder. + do_check_eq(tracker.score, SCORE_INCREMENT_XLARGE * 3); + } finally { + _("Clean up."); + yield cleanup(); + } +}); + +add_task(function* test_onLivemarkDeleted() { + _("Deleted livemarks should be tracked"); + + try { + yield stopTracking(); + + _("Insert a livemark"); + let livemark = yield PlacesUtils.livemarks.addLivemark({ + parentGuid: PlacesUtils.bookmarks.menuGuid, + feedURI: Utils.makeURI("http://localhost:0"), + }); + livemark.terminate(); + + yield startTracking(); + + _("Remove a livemark"); + yield PlacesUtils.livemarks.removeLivemark({ + guid: livemark.guid, + }); + + yield verifyTrackedItems(["menu", livemark.guid]); + do_check_eq(tracker.score, SCORE_INCREMENT_XLARGE * 2); + } finally { + _("Clean up."); + yield cleanup(); + } +}); + +add_task(function* test_onItemMoved() { + _("Items moved via the synchronous API should be tracked"); + + try { + let fx_id = PlacesUtils.bookmarks.insertBookmark( + PlacesUtils.bookmarks.bookmarksMenuFolder, + Utils.makeURI("http://getfirefox.com"), + PlacesUtils.bookmarks.DEFAULT_INDEX, + "Get Firefox!"); + let fx_guid = engine._store.GUIDForId(fx_id); + _("Firefox GUID: " + fx_guid); + let tb_id = PlacesUtils.bookmarks.insertBookmark( + PlacesUtils.bookmarks.bookmarksMenuFolder, + Utils.makeURI("http://getthunderbird.com"), + PlacesUtils.bookmarks.DEFAULT_INDEX, + "Get Thunderbird!"); + let tb_guid = engine._store.GUIDForId(tb_id); + _("Thunderbird GUID: " + tb_guid); + + yield startTracking(); + + // Moving within the folder will just track the folder. + PlacesUtils.bookmarks.moveItem( + tb_id, PlacesUtils.bookmarks.bookmarksMenuFolder, 0); + yield verifyTrackedItems(['menu']); + do_check_eq(tracker.score, SCORE_INCREMENT_XLARGE); + yield resetTracker(); + + // Moving a bookmark to a different folder will track the old + // folder, the new folder and the bookmark. + PlacesUtils.bookmarks.moveItem(fx_id, PlacesUtils.bookmarks.toolbarFolder, + PlacesUtils.bookmarks.DEFAULT_INDEX); + yield verifyTrackedItems(['menu', 'toolbar', fx_guid]); + do_check_eq(tracker.score, SCORE_INCREMENT_XLARGE * 3); + + } finally { + _("Clean up."); + yield cleanup(); + } +}); + +add_task(function* test_async_onItemMoved_update() { + _("Items moved via the asynchronous API should be tracked"); + + try { + yield stopTracking(); + + let fxBmk = yield PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + parentGuid: PlacesUtils.bookmarks.menuGuid, + url: "http://getfirefox.com", + title: "Get Firefox!", + }); + let tbBmk = yield PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + parentGuid: PlacesUtils.bookmarks.menuGuid, + url: "http://getthunderbird.com", + title: "Get Thunderbird!", + }); + + yield startTracking(); + + _("Repositioning a bookmark should track the folder"); + yield PlacesUtils.bookmarks.update({ + guid: tbBmk.guid, + parentGuid: PlacesUtils.bookmarks.menuGuid, + index: 0, + }); + yield verifyTrackedItems(['menu']); + do_check_eq(tracker.score, SCORE_INCREMENT_XLARGE); + yield resetTracker(); + + _("Reparenting a bookmark should track both folders and the bookmark"); + yield PlacesUtils.bookmarks.update({ + guid: tbBmk.guid, + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + index: PlacesUtils.bookmarks.DEFAULT_INDEX, + }); + yield verifyTrackedItems(['menu', 'toolbar', tbBmk.guid]); + do_check_eq(tracker.score, SCORE_INCREMENT_XLARGE * 3); + } finally { + _("Clean up."); + yield cleanup(); + } +}); + +add_task(function* test_async_onItemMoved_reorder() { + _("Items reordered via the asynchronous API should be tracked"); + + try { + yield stopTracking(); + + _("Insert out-of-order bookmarks"); + let fxBmk = yield PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + parentGuid: PlacesUtils.bookmarks.menuGuid, + url: "http://getfirefox.com", + title: "Get Firefox!", + }); + _(`Firefox GUID: ${fxBmk.guid}`); + + let tbBmk = yield PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + parentGuid: PlacesUtils.bookmarks.menuGuid, + url: "http://getthunderbird.com", + title: "Get Thunderbird!", + }); + _(`Thunderbird GUID: ${tbBmk.guid}`); + + let mozBmk = yield PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + parentGuid: PlacesUtils.bookmarks.menuGuid, + url: "https://mozilla.org", + title: "Mozilla", + }); + _(`Mozilla GUID: ${mozBmk.guid}`); + + yield startTracking(); + + _("Reorder bookmarks"); + yield PlacesUtils.bookmarks.reorder(PlacesUtils.bookmarks.menuGuid, + [mozBmk.guid, fxBmk.guid, tbBmk.guid]); + + // As with setItemIndex, we should only track the folder if we reorder + // its children, but we should bump the score for every changed item. + yield verifyTrackedItems(["menu"]); + do_check_eq(tracker.score, SCORE_INCREMENT_XLARGE * 3); + } finally { + _("Clean up."); + yield cleanup(); + } +}); + +add_task(function* test_onItemMoved_setItemIndex() { + _("Items with updated indices should be tracked"); + + try { + yield stopTracking(); + + let folder_id = PlacesUtils.bookmarks.createFolder( + PlacesUtils.bookmarks.bookmarksMenuFolder, + "Test folder", + PlacesUtils.bookmarks.DEFAULT_INDEX); + let folder_guid = engine._store.GUIDForId(folder_id); + _(`Folder GUID: ${folder_guid}`); + + let tb_id = PlacesUtils.bookmarks.insertBookmark( + folder_id, + Utils.makeURI("http://getthunderbird.com"), + PlacesUtils.bookmarks.DEFAULT_INDEX, + "Thunderbird"); + let tb_guid = engine._store.GUIDForId(tb_id); + _(`Thunderbird GUID: ${tb_guid}`); + + let fx_id = PlacesUtils.bookmarks.insertBookmark( + folder_id, + Utils.makeURI("http://getfirefox.com"), + PlacesUtils.bookmarks.DEFAULT_INDEX, + "Firefox"); + let fx_guid = engine._store.GUIDForId(fx_id); + _(`Firefox GUID: ${fx_guid}`); + + let moz_id = PlacesUtils.bookmarks.insertBookmark( + PlacesUtils.bookmarks.bookmarksMenuFolder, + Utils.makeURI("https://mozilla.org"), + PlacesUtils.bookmarks.DEFAULT_INDEX, + "Mozilla" + ); + let moz_guid = engine._store.GUIDForId(moz_id); + _(`Mozilla GUID: ${moz_guid}`); + + yield startTracking(); + + // PlacesSortFolderByNameTransaction exercises + // PlacesUtils.bookmarks.setItemIndex. + let txn = new PlacesSortFolderByNameTransaction(folder_id); + + // We're reordering items within the same folder, so only the folder + // should be tracked. + _("Execute the sort folder transaction"); + txn.doTransaction(); + yield verifyTrackedItems([folder_guid]); + do_check_eq(tracker.score, SCORE_INCREMENT_XLARGE); + yield resetTracker(); + + _("Undo the sort folder transaction"); + txn.undoTransaction(); + yield verifyTrackedItems([folder_guid]); + do_check_eq(tracker.score, SCORE_INCREMENT_XLARGE); + } finally { + _("Clean up."); + yield cleanup(); + } +}); + +add_task(function* test_onItemDeleted_removeFolderTransaction() { + _("Folders removed in a transaction should be tracked"); + + try { + yield stopTracking(); + + _("Create a folder with two children"); + let folder_id = PlacesUtils.bookmarks.createFolder( + PlacesUtils.bookmarks.bookmarksMenuFolder, + "Test folder", + PlacesUtils.bookmarks.DEFAULT_INDEX); + let folder_guid = engine._store.GUIDForId(folder_id); + _(`Folder GUID: ${folder_guid}`); + let fx_id = PlacesUtils.bookmarks.insertBookmark( + folder_id, + Utils.makeURI("http://getfirefox.com"), + PlacesUtils.bookmarks.DEFAULT_INDEX, + "Get Firefox!"); + let fx_guid = engine._store.GUIDForId(fx_id); + _(`Firefox GUID: ${fx_guid}`); + let tb_id = PlacesUtils.bookmarks.insertBookmark( + folder_id, + Utils.makeURI("http://getthunderbird.com"), + PlacesUtils.bookmarks.DEFAULT_INDEX, + "Get Thunderbird!"); + let tb_guid = engine._store.GUIDForId(tb_id); + _(`Thunderbird GUID: ${tb_guid}`); + + yield startTracking(); + + let txn = PlacesUtils.bookmarks.getRemoveFolderTransaction(folder_id); + // We haven't executed the transaction yet. + yield verifyTrackerEmpty(); + + _("Execute the remove folder transaction"); + txn.doTransaction(); + yield verifyTrackedItems(["menu", folder_guid, fx_guid, tb_guid]); + do_check_eq(tracker.score, SCORE_INCREMENT_XLARGE * 6); + yield resetTracker(); + + _("Undo the remove folder transaction"); + txn.undoTransaction(); + + // At this point, the restored folder has the same ID, but a different GUID. + let new_folder_guid = yield PlacesUtils.promiseItemGuid(folder_id); + + yield verifyTrackedItems(["menu", new_folder_guid]); + do_check_eq(tracker.score, SCORE_INCREMENT_XLARGE * 2); + yield resetTracker(); + + _("Redo the transaction"); + txn.redoTransaction(); + yield verifyTrackedItems(["menu", new_folder_guid]); + do_check_eq(tracker.score, SCORE_INCREMENT_XLARGE * 2); + } finally { + _("Clean up."); + yield cleanup(); + } +}); + +add_task(function* test_treeMoved() { + _("Moving an entire tree of bookmarks should track the parents"); + + try { + // Create a couple of parent folders. + let folder1_id = PlacesUtils.bookmarks.createFolder( + PlacesUtils.bookmarks.bookmarksMenuFolder, + "First test folder", + PlacesUtils.bookmarks.DEFAULT_INDEX); + let folder1_guid = engine._store.GUIDForId(folder1_id); + + // A second folder in the first. + let folder2_id = PlacesUtils.bookmarks.createFolder( + folder1_id, + "Second test folder", + PlacesUtils.bookmarks.DEFAULT_INDEX); + let folder2_guid = engine._store.GUIDForId(folder2_id); + + // Create a couple of bookmarks in the second folder. + let fx_id = PlacesUtils.bookmarks.insertBookmark( + folder2_id, + Utils.makeURI("http://getfirefox.com"), + PlacesUtils.bookmarks.DEFAULT_INDEX, + "Get Firefox!"); + let fx_guid = engine._store.GUIDForId(fx_id); + let tb_id = PlacesUtils.bookmarks.insertBookmark( + folder2_id, + Utils.makeURI("http://getthunderbird.com"), + PlacesUtils.bookmarks.DEFAULT_INDEX, + "Get Thunderbird!"); + let tb_guid = engine._store.GUIDForId(tb_id); + + yield startTracking(); + + // Move folder 2 to be a sibling of folder1. + PlacesUtils.bookmarks.moveItem( + folder2_id, PlacesUtils.bookmarks.bookmarksMenuFolder, 0); + // the menu and both folders should be tracked, the children should not be. + yield verifyTrackedItems(['menu', folder1_guid, folder2_guid]); + do_check_eq(tracker.score, SCORE_INCREMENT_XLARGE * 3); + } finally { + _("Clean up."); + yield cleanup(); + } +}); + +add_task(function* test_onItemDeleted() { + _("Bookmarks deleted via the synchronous API should be tracked"); + + try { + let fx_id = PlacesUtils.bookmarks.insertBookmark( + PlacesUtils.bookmarks.bookmarksMenuFolder, + Utils.makeURI("http://getfirefox.com"), + PlacesUtils.bookmarks.DEFAULT_INDEX, + "Get Firefox!"); + let fx_guid = engine._store.GUIDForId(fx_id); + let tb_id = PlacesUtils.bookmarks.insertBookmark( + PlacesUtils.bookmarks.bookmarksMenuFolder, + Utils.makeURI("http://getthunderbird.com"), + PlacesUtils.bookmarks.DEFAULT_INDEX, + "Get Thunderbird!"); + let tb_guid = engine._store.GUIDForId(tb_id); + + yield startTracking(); + + // Delete the last item - the item and parent should be tracked. + PlacesUtils.bookmarks.removeItem(tb_id); + + yield verifyTrackedItems(['menu', tb_guid]); + do_check_eq(tracker.score, SCORE_INCREMENT_XLARGE * 2); + } finally { + _("Clean up."); + yield cleanup(); + } +}); + +add_task(function* test_async_onItemDeleted() { + _("Bookmarks deleted via the asynchronous API should be tracked"); + + try { + yield stopTracking(); + + let fxBmk = yield PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + parentGuid: PlacesUtils.bookmarks.menuGuid, + url: "http://getfirefox.com", + title: "Get Firefox!", + }); + let tbBmk = yield PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + parentGuid: PlacesUtils.bookmarks.menuGuid, + url: "http://getthunderbird.com", + title: "Get Thunderbird!", + }); + + yield startTracking(); + + _("Delete the first item"); + yield PlacesUtils.bookmarks.remove(fxBmk.guid); + + yield verifyTrackedItems(["menu", fxBmk.guid]); + do_check_eq(tracker.score, SCORE_INCREMENT_XLARGE * 2); + } finally { + _("Clean up."); + yield cleanup(); + } +}); + +add_task(function* test_async_onItemDeleted_eraseEverything() { + _("Erasing everything should track all deleted items"); + + try { + yield stopTracking(); + + let fxBmk = yield PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + parentGuid: PlacesUtils.bookmarks.mobileGuid, + url: "http://getfirefox.com", + title: "Get Firefox!", + }); + _(`Firefox GUID: ${fxBmk.guid}`); + let tbBmk = yield PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + parentGuid: PlacesUtils.bookmarks.mobileGuid, + url: "http://getthunderbird.com", + title: "Get Thunderbird!", + }); + _(`Thunderbird GUID: ${tbBmk.guid}`); + let mozBmk = yield PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + parentGuid: PlacesUtils.bookmarks.menuGuid, + url: "https://mozilla.org", + title: "Mozilla", + }); + _(`Mozilla GUID: ${mozBmk.guid}`); + let mdnBmk = yield PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + parentGuid: PlacesUtils.bookmarks.menuGuid, + url: "https://developer.mozilla.org", + title: "MDN", + }); + _(`MDN GUID: ${mdnBmk.guid}`); + let bugsFolder = yield PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_FOLDER, + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + title: "Bugs", + }); + _(`Bugs folder GUID: ${bugsFolder.guid}`); + let bzBmk = yield PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + parentGuid: bugsFolder.guid, + url: "https://bugzilla.mozilla.org", + title: "Bugzilla", + }); + _(`Bugzilla GUID: ${bzBmk.guid}`); + let bugsChildFolder = yield PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_FOLDER, + parentGuid: bugsFolder.guid, + title: "Bugs child", + }); + _(`Bugs child GUID: ${bugsChildFolder.guid}`); + let bugsGrandChildBmk = yield PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + parentGuid: bugsChildFolder.guid, + url: "https://example.com", + title: "Bugs grandchild", + }); + _(`Bugs grandchild GUID: ${bugsGrandChildBmk.guid}`); + + yield startTracking(); + + yield PlacesUtils.bookmarks.eraseEverything(); + + // `eraseEverything` removes all items from the database before notifying + // observers. Because of this, grandchild lookup in the tracker's + // `onItemRemoved` observer will fail. That means we won't track + // (bzBmk.guid, bugsGrandChildBmk.guid, bugsChildFolder.guid), even + // though we should. + yield verifyTrackedItems(["menu", mozBmk.guid, mdnBmk.guid, "toolbar", + bugsFolder.guid, "mobile", fxBmk.guid, + tbBmk.guid]); + do_check_eq(tracker.score, SCORE_INCREMENT_XLARGE * 10); + } finally { + _("Clean up."); + yield cleanup(); + } +}); + +add_task(function* test_onItemDeleted_removeFolderChildren() { + _("Removing a folder's children should track the folder and its children"); + + try { + let fx_id = PlacesUtils.bookmarks.insertBookmark( + PlacesUtils.mobileFolderId, + Utils.makeURI("http://getfirefox.com"), + PlacesUtils.bookmarks.DEFAULT_INDEX, + "Get Firefox!"); + let fx_guid = engine._store.GUIDForId(fx_id); + _(`Firefox GUID: ${fx_guid}`); + + let tb_id = PlacesUtils.bookmarks.insertBookmark( + PlacesUtils.mobileFolderId, + Utils.makeURI("http://getthunderbird.com"), + PlacesUtils.bookmarks.DEFAULT_INDEX, + "Get Thunderbird!"); + let tb_guid = engine._store.GUIDForId(tb_id); + _(`Thunderbird GUID: ${tb_guid}`); + + let moz_id = PlacesUtils.bookmarks.insertBookmark( + PlacesUtils.bookmarks.bookmarksMenuFolder, + Utils.makeURI("https://mozilla.org"), + PlacesUtils.bookmarks.DEFAULT_INDEX, + "Mozilla" + ); + let moz_guid = engine._store.GUIDForId(moz_id); + _(`Mozilla GUID: ${moz_guid}`); + + yield startTracking(); + + _(`Mobile root ID: ${PlacesUtils.mobileFolderId}`); + PlacesUtils.bookmarks.removeFolderChildren(PlacesUtils.mobileFolderId); + + yield verifyTrackedItems(["mobile", fx_guid, tb_guid]); + do_check_eq(tracker.score, SCORE_INCREMENT_XLARGE * 4); + } finally { + _("Clean up."); + yield cleanup(); + } +}); + +add_task(function* test_onItemDeleted_tree() { + _("Deleting a tree of bookmarks should track all items"); + + try { + // Create a couple of parent folders. + let folder1_id = PlacesUtils.bookmarks.createFolder( + PlacesUtils.bookmarks.bookmarksMenuFolder, + "First test folder", + PlacesUtils.bookmarks.DEFAULT_INDEX); + let folder1_guid = engine._store.GUIDForId(folder1_id); + + // A second folder in the first. + let folder2_id = PlacesUtils.bookmarks.createFolder( + folder1_id, + "Second test folder", + PlacesUtils.bookmarks.DEFAULT_INDEX); + let folder2_guid = engine._store.GUIDForId(folder2_id); + + // Create a couple of bookmarks in the second folder. + let fx_id = PlacesUtils.bookmarks.insertBookmark( + folder2_id, + Utils.makeURI("http://getfirefox.com"), + PlacesUtils.bookmarks.DEFAULT_INDEX, + "Get Firefox!"); + let fx_guid = engine._store.GUIDForId(fx_id); + let tb_id = PlacesUtils.bookmarks.insertBookmark( + folder2_id, + Utils.makeURI("http://getthunderbird.com"), + PlacesUtils.bookmarks.DEFAULT_INDEX, + "Get Thunderbird!"); + let tb_guid = engine._store.GUIDForId(tb_id); + + yield startTracking(); + + // Delete folder2 - everything we created should be tracked. + PlacesUtils.bookmarks.removeItem(folder2_id); + + yield verifyTrackedItems([fx_guid, tb_guid, folder1_guid, folder2_guid]); + do_check_eq(tracker.score, SCORE_INCREMENT_XLARGE * 6); + } finally { + _("Clean up."); + yield cleanup(); + } +}); + +add_task(function* test_mobile_query() { + _("Ensure we correctly create the mobile query"); + + try { + // Creates the organizer queries as a side effect. + let leftPaneId = PlacesUIUtils.leftPaneFolderId; + _(`Left pane root ID: ${leftPaneId}`); + + let allBookmarksIds = findAnnoItems("PlacesOrganizer/OrganizerQuery", "AllBookmarks"); + equal(allBookmarksIds.length, 1, "Should create folder with all bookmarks queries"); + let allBookmarkGuid = yield PlacesUtils.promiseItemGuid(allBookmarksIds[0]); + + _("Try creating query after organizer is ready"); + tracker._ensureMobileQuery(); + let queryIds = findAnnoItems("PlacesOrganizer/OrganizerQuery", "MobileBookmarks"); + equal(queryIds.length, 0, "Should not create query without any mobile bookmarks"); + + _("Insert mobile bookmark, then create query"); + yield PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.mobileGuid, + url: "https://mozilla.org", + }); + tracker._ensureMobileQuery(); + queryIds = findAnnoItems("PlacesOrganizer/OrganizerQuery", "MobileBookmarks", {}); + equal(queryIds.length, 1, "Should create query once mobile bookmarks exist"); + + let queryId = queryIds[0]; + let queryGuid = yield PlacesUtils.promiseItemGuid(queryId); + + let queryInfo = yield PlacesUtils.bookmarks.fetch(queryGuid); + equal(queryInfo.url, `place:folder=${PlacesUtils.mobileFolderId}`, "Query should point to mobile root"); + equal(queryInfo.title, "Mobile Bookmarks", "Query title should be localized"); + equal(queryInfo.parentGuid, allBookmarkGuid, "Should append mobile query to all bookmarks queries"); + + _("Rename root and query, then recreate"); + yield PlacesUtils.bookmarks.update({ + guid: PlacesUtils.bookmarks.mobileGuid, + title: "renamed root", + }); + yield PlacesUtils.bookmarks.update({ + guid: queryGuid, + title: "renamed query", + }); + tracker._ensureMobileQuery(); + let rootInfo = yield PlacesUtils.bookmarks.fetch(PlacesUtils.bookmarks.mobileGuid); + equal(rootInfo.title, "Mobile Bookmarks", "Should fix root title"); + queryInfo = yield PlacesUtils.bookmarks.fetch(queryGuid); + equal(queryInfo.title, "Mobile Bookmarks", "Should fix query title"); + + _("Point query to different folder"); + yield PlacesUtils.bookmarks.update({ + guid: queryGuid, + url: "place:folder=BOOKMARKS_MENU", + }); + tracker._ensureMobileQuery(); + queryInfo = yield PlacesUtils.bookmarks.fetch(queryGuid); + equal(queryInfo.url.href, `place:folder=${PlacesUtils.mobileFolderId}`, + "Should fix query URL to point to mobile root"); + + _("We shouldn't track the query or the left pane root"); + yield verifyTrackedCount(0); + do_check_eq(tracker.score, 0); + } finally { + _("Clean up."); + yield cleanup(); + } +}); diff --git a/services/sync/tests/unit/test_bookmark_validator.js b/services/sync/tests/unit/test_bookmark_validator.js new file mode 100644 index 0000000000..cc0b3b08fc --- /dev/null +++ b/services/sync/tests/unit/test_bookmark_validator.js @@ -0,0 +1,347 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +Components.utils.import("resource://services-sync/bookmark_validator.js"); +Components.utils.import("resource://services-sync/util.js"); + +function inspectServerRecords(data) { + return new BookmarkValidator().inspectServerRecords(data); +} + +add_test(function test_isr_rootOnServer() { + let c = inspectServerRecords([{ + id: 'places', + type: 'folder', + children: [], + }]); + ok(c.problemData.rootOnServer); + run_next_test(); +}); + +add_test(function test_isr_empty() { + let c = inspectServerRecords([]); + ok(!c.problemData.rootOnServer); + notEqual(c.root, null); + run_next_test(); +}); + +add_test(function test_isr_cycles() { + let c = inspectServerRecords([ + {id: 'C', type: 'folder', children: ['A', 'B'], parentid: 'places'}, + {id: 'A', type: 'folder', children: ['B'], parentid: 'B'}, + {id: 'B', type: 'folder', children: ['A'], parentid: 'A'}, + ]).problemData; + + equal(c.cycles.length, 1); + ok(c.cycles[0].indexOf('A') >= 0); + ok(c.cycles[0].indexOf('B') >= 0); + run_next_test(); +}); + +add_test(function test_isr_orphansMultiParents() { + let c = inspectServerRecords([ + { id: 'A', type: 'bookmark', parentid: 'D' }, + { id: 'B', type: 'folder', parentid: 'places', children: ['A']}, + { id: 'C', type: 'folder', parentid: 'places', children: ['A']}, + + ]).problemData; + deepEqual(c.orphans, [{ id: "A", parent: "D" }]); + equal(c.multipleParents.length, 1) + ok(c.multipleParents[0].parents.indexOf('B') >= 0); + ok(c.multipleParents[0].parents.indexOf('C') >= 0); + run_next_test(); +}); + +add_test(function test_isr_orphansMultiParents2() { + let c = inspectServerRecords([ + { id: 'A', type: 'bookmark', parentid: 'D' }, + { id: 'B', type: 'folder', parentid: 'places', children: ['A']}, + ]).problemData; + equal(c.orphans.length, 1); + equal(c.orphans[0].id, 'A'); + equal(c.multipleParents.length, 0); + run_next_test(); +}); + +add_test(function test_isr_deletedParents() { + let c = inspectServerRecords([ + { id: 'A', type: 'bookmark', parentid: 'B' }, + { id: 'B', type: 'folder', parentid: 'places', children: ['A']}, + { id: 'B', type: 'item', deleted: true}, + ]).problemData; + deepEqual(c.deletedParents, ['A']) + run_next_test(); +}); + +add_test(function test_isr_badChildren() { + let c = inspectServerRecords([ + { id: 'A', type: 'bookmark', parentid: 'places', children: ['B', 'C'] }, + { id: 'C', type: 'bookmark', parentid: 'A' } + ]).problemData; + deepEqual(c.childrenOnNonFolder, ['A']) + deepEqual(c.missingChildren, [{parent: 'A', child: 'B'}]); + deepEqual(c.parentNotFolder, ['C']); + run_next_test(); +}); + + +add_test(function test_isr_parentChildMismatches() { + let c = inspectServerRecords([ + { id: 'A', type: 'folder', parentid: 'places', children: [] }, + { id: 'B', type: 'bookmark', parentid: 'A' } + ]).problemData; + deepEqual(c.parentChildMismatches, [{parent: 'A', child: 'B'}]); + run_next_test(); +}); + +add_test(function test_isr_duplicatesAndMissingIDs() { + let c = inspectServerRecords([ + {id: 'A', type: 'folder', parentid: 'places', children: []}, + {id: 'A', type: 'folder', parentid: 'places', children: []}, + {type: 'folder', parentid: 'places', children: []} + ]).problemData; + equal(c.missingIDs, 1); + deepEqual(c.duplicates, ['A']); + run_next_test(); +}); + +add_test(function test_isr_duplicateChildren() { + let c = inspectServerRecords([ + {id: 'A', type: 'folder', parentid: 'places', children: ['B', 'B']}, + {id: 'B', type: 'bookmark', parentid: 'A'}, + ]).problemData; + deepEqual(c.duplicateChildren, ['A']); + run_next_test(); +}); + +// Each compareServerWithClient test mutates these, so we can't just keep them +// global +function getDummyServerAndClient() { + let server = [ + { + id: 'menu', + parentid: 'places', + type: 'folder', + parentName: '', + title: 'foo', + children: ['bbbbbbbbbbbb', 'cccccccccccc'] + }, + { + id: 'bbbbbbbbbbbb', + type: 'bookmark', + parentid: 'menu', + parentName: 'foo', + title: 'bar', + bmkUri: 'http://baz.com' + }, + { + id: 'cccccccccccc', + parentid: 'menu', + parentName: 'foo', + title: '', + type: 'query', + bmkUri: 'place:type=6&sort=14&maxResults=10' + } + ]; + + let client = { + "guid": "root________", + "title": "", + "id": 1, + "type": "text/x-moz-place-container", + "children": [ + { + "guid": "menu________", + "title": "foo", + "id": 1000, + "type": "text/x-moz-place-container", + "children": [ + { + "guid": "bbbbbbbbbbbb", + "title": "bar", + "id": 1001, + "type": "text/x-moz-place", + "uri": "http://baz.com" + }, + { + "guid": "cccccccccccc", + "title": "", + "id": 1002, + "annos": [{ + "name": "Places/SmartBookmark", + "flags": 0, + "expires": 4, + "value": "RecentTags" + }], + "type": "text/x-moz-place", + "uri": "place:type=6&sort=14&maxResults=10" + } + ] + } + ] + }; + return {server, client}; +} + + +add_test(function test_cswc_valid() { + let {server, client} = getDummyServerAndClient(); + + let c = new BookmarkValidator().compareServerWithClient(server, client).problemData; + equal(c.clientMissing.length, 0); + equal(c.serverMissing.length, 0); + equal(c.differences.length, 0); + run_next_test(); +}); + +add_test(function test_cswc_serverMissing() { + let {server, client} = getDummyServerAndClient(); + // remove c + server.pop(); + server[0].children.pop(); + + let c = new BookmarkValidator().compareServerWithClient(server, client).problemData; + deepEqual(c.serverMissing, ['cccccccccccc']); + equal(c.clientMissing.length, 0); + deepEqual(c.structuralDifferences, [{id: 'menu', differences: ['childGUIDs']}]); + run_next_test(); +}); + +add_test(function test_cswc_clientMissing() { + let {server, client} = getDummyServerAndClient(); + client.children[0].children.pop(); + + let c = new BookmarkValidator().compareServerWithClient(server, client).problemData; + deepEqual(c.clientMissing, ['cccccccccccc']); + equal(c.serverMissing.length, 0); + deepEqual(c.structuralDifferences, [{id: 'menu', differences: ['childGUIDs']}]); + run_next_test(); +}); + +add_test(function test_cswc_differences() { + { + let {server, client} = getDummyServerAndClient(); + client.children[0].children[0].title = 'asdf'; + let c = new BookmarkValidator().compareServerWithClient(server, client).problemData; + equal(c.clientMissing.length, 0); + equal(c.serverMissing.length, 0); + deepEqual(c.differences, [{id: 'bbbbbbbbbbbb', differences: ['title']}]); + } + + { + let {server, client} = getDummyServerAndClient(); + server[2].type = 'bookmark'; + let c = new BookmarkValidator().compareServerWithClient(server, client).problemData; + equal(c.clientMissing.length, 0); + equal(c.serverMissing.length, 0); + deepEqual(c.differences, [{id: 'cccccccccccc', differences: ['type']}]); + } + run_next_test(); +}); + +add_test(function test_cswc_serverUnexpected() { + let {server, client} = getDummyServerAndClient(); + client.children.push({ + "guid": "dddddddddddd", + "title": "", + "id": 2000, + "annos": [{ + "name": "places/excludeFromBackup", + "flags": 0, + "expires": 4, + "value": 1 + }, { + "name": "PlacesOrganizer/OrganizerFolder", + "flags": 0, + "expires": 4, + "value": 7 + }], + "type": "text/x-moz-place-container", + "children": [{ + "guid": "eeeeeeeeeeee", + "title": "History", + "annos": [{ + "name": "places/excludeFromBackup", + "flags": 0, + "expires": 4, + "value": 1 + }, { + "name": "PlacesOrganizer/OrganizerQuery", + "flags": 0, + "expires": 4, + "value": "History" + }], + "type": "text/x-moz-place", + "uri": "place:type=3&sort=4" + }] + }); + server.push({ + id: 'dddddddddddd', + parentid: 'places', + parentName: '', + title: '', + type: 'folder', + children: ['eeeeeeeeeeee'] + }, { + id: 'eeeeeeeeeeee', + parentid: 'dddddddddddd', + parentName: '', + title: 'History', + type: 'query', + bmkUri: 'place:type=3&sort=4' + }); + + let c = new BookmarkValidator().compareServerWithClient(server, client).problemData; + equal(c.clientMissing.length, 0); + equal(c.serverMissing.length, 0); + equal(c.serverUnexpected.length, 2); + deepEqual(c.serverUnexpected, ["dddddddddddd", "eeeeeeeeeeee"]); + run_next_test(); +}); + +function validationPing(server, client, duration) { + return wait_for_ping(function() { + // fake this entirely + Svc.Obs.notify("weave:service:sync:start"); + Svc.Obs.notify("weave:engine:sync:start", null, "bookmarks"); + Svc.Obs.notify("weave:engine:sync:finish", null, "bookmarks"); + let validator = new BookmarkValidator(); + let data = { + // We fake duration and version just so that we can verify they're passed through. + duration, + version: validator.version, + recordCount: server.length, + problems: validator.compareServerWithClient(server, client).problemData, + }; + Svc.Obs.notify("weave:engine:validate:finish", data, "bookmarks"); + Svc.Obs.notify("weave:service:sync:finish"); + }, true); // Allow "failing" pings, since having validation info indicates failure. +} + +add_task(function *test_telemetry_integration() { + let {server, client} = getDummyServerAndClient(); + // remove "c" + server.pop(); + server[0].children.pop(); + const duration = 50; + let ping = yield validationPing(server, client, duration); + ok(ping.engines); + let bme = ping.engines.find(e => e.name === "bookmarks"); + ok(bme); + ok(bme.validation); + ok(bme.validation.problems) + equal(bme.validation.checked, server.length); + equal(bme.validation.took, duration); + bme.validation.problems.sort((a, b) => String.localeCompare(a.name, b.name)); + equal(bme.validation.version, new BookmarkValidator().version); + deepEqual(bme.validation.problems, [ + { name: "badClientRoots", count: 3 }, + { name: "sdiff:childGUIDs", count: 1 }, + { name: "serverMissing", count: 1 }, + { name: "structuralDifferences", count: 1 }, + ]); +}); + +function run_test() { + run_next_test(); +} diff --git a/services/sync/tests/unit/test_browserid_identity.js b/services/sync/tests/unit/test_browserid_identity.js new file mode 100644 index 0000000000..531c01bf6d --- /dev/null +++ b/services/sync/tests/unit/test_browserid_identity.js @@ -0,0 +1,890 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +Cu.import("resource://gre/modules/FxAccounts.jsm"); +Cu.import("resource://services-sync/browserid_identity.js"); +Cu.import("resource://services-sync/rest.js"); +Cu.import("resource://services-sync/util.js"); +Cu.import("resource://services-common/utils.js"); +Cu.import("resource://services-crypto/utils.js"); +Cu.import("resource://testing-common/services/sync/utils.js"); +Cu.import("resource://testing-common/services/sync/fxa_utils.js"); +Cu.import("resource://services-common/hawkclient.js"); +Cu.import("resource://gre/modules/FxAccounts.jsm"); +Cu.import("resource://gre/modules/FxAccountsClient.jsm"); +Cu.import("resource://gre/modules/FxAccountsCommon.js"); +Cu.import("resource://services-sync/service.js"); +Cu.import("resource://services-sync/status.js"); +Cu.import("resource://services-sync/constants.js"); +Cu.import("resource://services-common/tokenserverclient.js"); + +const SECOND_MS = 1000; +const MINUTE_MS = SECOND_MS * 60; +const HOUR_MS = MINUTE_MS * 60; + +var identityConfig = makeIdentityConfig(); +var browseridManager = new BrowserIDManager(); +configureFxAccountIdentity(browseridManager, identityConfig); + +/** + * Mock client clock and skew vs server in FxAccounts signed-in user module and + * API client. browserid_identity.js queries these values to construct HAWK + * headers. We will use this to test clock skew compensation in these headers + * below. + */ +var MockFxAccountsClient = function() { + FxAccountsClient.apply(this); +}; +MockFxAccountsClient.prototype = { + __proto__: FxAccountsClient.prototype, + accountStatus() { + return Promise.resolve(true); + } +}; + +function MockFxAccounts() { + let fxa = new FxAccounts({ + _now_is: Date.now(), + + now: function () { + return this._now_is; + }, + + fxAccountsClient: new MockFxAccountsClient() + }); + fxa.internal.currentAccountState.getCertificate = function(data, keyPair, mustBeValidUntil) { + this.cert = { + validUntil: fxa.internal.now() + CERT_LIFETIME, + cert: "certificate", + }; + return Promise.resolve(this.cert.cert); + }; + return fxa; +} + +function run_test() { + initTestLogging("Trace"); + Log.repository.getLogger("Sync.Identity").level = Log.Level.Trace; + Log.repository.getLogger("Sync.BrowserIDManager").level = Log.Level.Trace; + run_next_test(); +}; + +add_test(function test_initial_state() { + _("Verify initial state"); + do_check_false(!!browseridManager._token); + do_check_false(browseridManager.hasValidToken()); + run_next_test(); + } +); + +add_task(function* test_initialializeWithCurrentIdentity() { + _("Verify start after initializeWithCurrentIdentity"); + browseridManager.initializeWithCurrentIdentity(); + yield browseridManager.whenReadyToAuthenticate.promise; + do_check_true(!!browseridManager._token); + do_check_true(browseridManager.hasValidToken()); + do_check_eq(browseridManager.account, identityConfig.fxaccount.user.email); + } +); + +add_task(function* test_initialializeWithAuthErrorAndDeletedAccount() { + _("Verify sync unpair after initializeWithCurrentIdentity with auth error + account deleted"); + + var identityConfig = makeIdentityConfig(); + var browseridManager = new BrowserIDManager(); + + // Use the real `_getAssertion` method that calls + // `mockFxAClient.signCertificate`. + let fxaInternal = makeFxAccountsInternalMock(identityConfig); + delete fxaInternal._getAssertion; + + configureFxAccountIdentity(browseridManager, identityConfig, fxaInternal); + browseridManager._fxaService.internal.initialize(); + + let signCertificateCalled = false; + let accountStatusCalled = false; + + let MockFxAccountsClient = function() { + FxAccountsClient.apply(this); + }; + MockFxAccountsClient.prototype = { + __proto__: FxAccountsClient.prototype, + signCertificate() { + signCertificateCalled = true; + return Promise.reject({ + code: 401, + errno: ERRNO_INVALID_AUTH_TOKEN, + }); + }, + accountStatus() { + accountStatusCalled = true; + return Promise.resolve(false); + } + }; + + let mockFxAClient = new MockFxAccountsClient(); + browseridManager._fxaService.internal._fxAccountsClient = mockFxAClient; + + yield browseridManager.initializeWithCurrentIdentity(); + yield Assert.rejects(browseridManager.whenReadyToAuthenticate.promise, + "should reject due to an auth error"); + + do_check_true(signCertificateCalled); + do_check_true(accountStatusCalled); + do_check_false(browseridManager.account); + do_check_false(browseridManager._token); + do_check_false(browseridManager.hasValidToken()); + do_check_false(browseridManager.account); +}); + +add_task(function* test_initialializeWithNoKeys() { + _("Verify start after initializeWithCurrentIdentity without kA, kB or keyFetchToken"); + let identityConfig = makeIdentityConfig(); + delete identityConfig.fxaccount.user.kA; + delete identityConfig.fxaccount.user.kB; + // there's no keyFetchToken by default, so the initialize should fail. + configureFxAccountIdentity(browseridManager, identityConfig); + + yield browseridManager.initializeWithCurrentIdentity(); + yield browseridManager.whenReadyToAuthenticate.promise; + do_check_eq(Status.login, LOGIN_SUCCEEDED, "login succeeded even without keys"); + do_check_false(browseridManager._canFetchKeys(), "_canFetchKeys reflects lack of keys"); + do_check_eq(browseridManager._token, null, "we don't have a token"); +}); + +add_test(function test_getResourceAuthenticator() { + _("BrowserIDManager supplies a Resource Authenticator callback which returns a Hawk header."); + configureFxAccountIdentity(browseridManager); + let authenticator = browseridManager.getResourceAuthenticator(); + do_check_true(!!authenticator); + let req = {uri: CommonUtils.makeURI( + "https://example.net/somewhere/over/the/rainbow"), + method: 'GET'}; + let output = authenticator(req, 'GET'); + do_check_true('headers' in output); + do_check_true('authorization' in output.headers); + do_check_true(output.headers.authorization.startsWith('Hawk')); + _("Expected internal state after successful call."); + do_check_eq(browseridManager._token.uid, identityConfig.fxaccount.token.uid); + run_next_test(); + } +); + +add_test(function test_getRESTRequestAuthenticator() { + _("BrowserIDManager supplies a REST Request Authenticator callback which sets a Hawk header on a request object."); + let request = new SyncStorageRequest( + "https://example.net/somewhere/over/the/rainbow"); + let authenticator = browseridManager.getRESTRequestAuthenticator(); + do_check_true(!!authenticator); + let output = authenticator(request, 'GET'); + do_check_eq(request.uri, output.uri); + do_check_true(output._headers.authorization.startsWith('Hawk')); + do_check_true(output._headers.authorization.includes('nonce')); + do_check_true(browseridManager.hasValidToken()); + run_next_test(); + } +); + +add_test(function test_resourceAuthenticatorSkew() { + _("BrowserIDManager Resource Authenticator compensates for clock skew in Hawk header."); + + // Clock is skewed 12 hours into the future + // We pick a date in the past so we don't risk concealing bugs in code that + // uses new Date() instead of our given date. + let now = new Date("Fri Apr 09 2004 00:00:00 GMT-0700").valueOf() + 12 * HOUR_MS; + let browseridManager = new BrowserIDManager(); + let hawkClient = new HawkClient("https://example.net/v1", "/foo"); + + // mock fxa hawk client skew + hawkClient.now = function() { + dump("mocked client now: " + now + '\n'); + return now; + } + // Imagine there's already been one fxa request and the hawk client has + // already detected skew vs the fxa auth server. + let localtimeOffsetMsec = -1 * 12 * HOUR_MS; + hawkClient._localtimeOffsetMsec = localtimeOffsetMsec; + + let fxaClient = new MockFxAccountsClient(); + fxaClient.hawk = hawkClient; + + // Sanity check + do_check_eq(hawkClient.now(), now); + do_check_eq(hawkClient.localtimeOffsetMsec, localtimeOffsetMsec); + + // Properly picked up by the client + do_check_eq(fxaClient.now(), now); + do_check_eq(fxaClient.localtimeOffsetMsec, localtimeOffsetMsec); + + let fxa = new MockFxAccounts(); + fxa.internal._now_is = now; + fxa.internal.fxAccountsClient = fxaClient; + + // Picked up by the signed-in user module + do_check_eq(fxa.internal.now(), now); + do_check_eq(fxa.internal.localtimeOffsetMsec, localtimeOffsetMsec); + + do_check_eq(fxa.now(), now); + do_check_eq(fxa.localtimeOffsetMsec, localtimeOffsetMsec); + + // Mocks within mocks... + configureFxAccountIdentity(browseridManager, identityConfig); + + // Ensure the new FxAccounts mock has a signed-in user. + fxa.internal.currentAccountState.signedInUser = browseridManager._fxaService.internal.currentAccountState.signedInUser; + + browseridManager._fxaService = fxa; + + do_check_eq(browseridManager._fxaService.internal.now(), now); + do_check_eq(browseridManager._fxaService.internal.localtimeOffsetMsec, + localtimeOffsetMsec); + + do_check_eq(browseridManager._fxaService.now(), now); + do_check_eq(browseridManager._fxaService.localtimeOffsetMsec, + localtimeOffsetMsec); + + let request = new SyncStorageRequest("https://example.net/i/like/pie/"); + let authenticator = browseridManager.getResourceAuthenticator(); + let output = authenticator(request, 'GET'); + dump("output" + JSON.stringify(output)); + let authHeader = output.headers.authorization; + do_check_true(authHeader.startsWith('Hawk')); + + // Skew correction is applied in the header and we're within the two-minute + // window. + do_check_eq(getTimestamp(authHeader), now - 12 * HOUR_MS); + do_check_true( + (getTimestampDelta(authHeader, now) - 12 * HOUR_MS) < 2 * MINUTE_MS); + + run_next_test(); +}); + +add_test(function test_RESTResourceAuthenticatorSkew() { + _("BrowserIDManager REST Resource Authenticator compensates for clock skew in Hawk header."); + + // Clock is skewed 12 hours into the future from our arbitary date + let now = new Date("Fri Apr 09 2004 00:00:00 GMT-0700").valueOf() + 12 * HOUR_MS; + let browseridManager = new BrowserIDManager(); + let hawkClient = new HawkClient("https://example.net/v1", "/foo"); + + // mock fxa hawk client skew + hawkClient.now = function() { + return now; + } + // Imagine there's already been one fxa request and the hawk client has + // already detected skew vs the fxa auth server. + hawkClient._localtimeOffsetMsec = -1 * 12 * HOUR_MS; + + let fxaClient = new MockFxAccountsClient(); + fxaClient.hawk = hawkClient; + let fxa = new MockFxAccounts(); + fxa.internal._now_is = now; + fxa.internal.fxAccountsClient = fxaClient; + + configureFxAccountIdentity(browseridManager, identityConfig); + + // Ensure the new FxAccounts mock has a signed-in user. + fxa.internal.currentAccountState.signedInUser = browseridManager._fxaService.internal.currentAccountState.signedInUser; + + browseridManager._fxaService = fxa; + + do_check_eq(browseridManager._fxaService.internal.now(), now); + + let request = new SyncStorageRequest("https://example.net/i/like/pie/"); + let authenticator = browseridManager.getResourceAuthenticator(); + let output = authenticator(request, 'GET'); + dump("output" + JSON.stringify(output)); + let authHeader = output.headers.authorization; + do_check_true(authHeader.startsWith('Hawk')); + + // Skew correction is applied in the header and we're within the two-minute + // window. + do_check_eq(getTimestamp(authHeader), now - 12 * HOUR_MS); + do_check_true( + (getTimestampDelta(authHeader, now) - 12 * HOUR_MS) < 2 * MINUTE_MS); + + run_next_test(); +}); + +add_task(function* test_ensureLoggedIn() { + configureFxAccountIdentity(browseridManager); + yield browseridManager.initializeWithCurrentIdentity(); + yield browseridManager.whenReadyToAuthenticate.promise; + Assert.equal(Status.login, LOGIN_SUCCEEDED, "original initialize worked"); + yield browseridManager.ensureLoggedIn(); + Assert.equal(Status.login, LOGIN_SUCCEEDED, "original ensureLoggedIn worked"); + Assert.ok(browseridManager._shouldHaveSyncKeyBundle, + "_shouldHaveSyncKeyBundle should always be true after ensureLogin completes."); + + // arrange for no logged in user. + let fxa = browseridManager._fxaService + let signedInUser = fxa.internal.currentAccountState.storageManager.accountData; + fxa.internal.currentAccountState.storageManager.accountData = null; + browseridManager.initializeWithCurrentIdentity(); + Assert.ok(!browseridManager._shouldHaveSyncKeyBundle, + "_shouldHaveSyncKeyBundle should be false so we know we are testing what we think we are."); + Status.login = LOGIN_FAILED_NO_USERNAME; + yield Assert.rejects(browseridManager.ensureLoggedIn(), "expecting rejection due to no user"); + Assert.ok(browseridManager._shouldHaveSyncKeyBundle, + "_shouldHaveSyncKeyBundle should always be true after ensureLogin completes."); + // Restore the logged in user to what it was. + fxa.internal.currentAccountState.storageManager.accountData = signedInUser; + Status.login = LOGIN_FAILED_LOGIN_REJECTED; + yield Assert.rejects(browseridManager.ensureLoggedIn(), + "LOGIN_FAILED_LOGIN_REJECTED should have caused immediate rejection"); + Assert.equal(Status.login, LOGIN_FAILED_LOGIN_REJECTED, + "status should remain LOGIN_FAILED_LOGIN_REJECTED"); + Status.login = LOGIN_FAILED_NETWORK_ERROR; + yield browseridManager.ensureLoggedIn(); + Assert.equal(Status.login, LOGIN_SUCCEEDED, "final ensureLoggedIn worked"); +}); + +add_test(function test_tokenExpiration() { + _("BrowserIDManager notices token expiration:"); + let bimExp = new BrowserIDManager(); + configureFxAccountIdentity(bimExp, identityConfig); + + let authenticator = bimExp.getResourceAuthenticator(); + do_check_true(!!authenticator); + let req = {uri: CommonUtils.makeURI( + "https://example.net/somewhere/over/the/rainbow"), + method: 'GET'}; + authenticator(req, 'GET'); + + // Mock the clock. + _("Forcing the token to expire ..."); + Object.defineProperty(bimExp, "_now", { + value: function customNow() { + return (Date.now() + 3000001); + }, + writable: true, + }); + do_check_true(bimExp._token.expiration < bimExp._now()); + _("... means BrowserIDManager knows to re-fetch it on the next call."); + do_check_false(bimExp.hasValidToken()); + run_next_test(); + } +); + +add_test(function test_sha256() { + // Test vectors from http://www.bichlmeier.info/sha256test.html + let vectors = [ + ["", + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"], + ["abc", + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"], + ["message digest", + "f7846f55cf23e14eebeab5b4e1550cad5b509e3348fbc4efa3a1413d393cb650"], + ["secure hash algorithm", + "f30ceb2bb2829e79e4ca9753d35a8ecc00262d164cc077080295381cbd643f0d"], + ["SHA256 is considered to be safe", + "6819d915c73f4d1e77e4e1b52d1fa0f9cf9beaead3939f15874bd988e2a23630"], + ["abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq", + "248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1"], + ["For this sample, this 63-byte string will be used as input data", + "f08a78cbbaee082b052ae0708f32fa1e50c5c421aa772ba5dbb406a2ea6be342"], + ["This is exactly 64 bytes long, not counting the terminating byte", + "ab64eff7e88e2e46165e29f2bce41826bd4c7b3552f6b382a9e7d3af47c245f8"] + ]; + let bidUser = new BrowserIDManager(); + for (let [input,output] of vectors) { + do_check_eq(CommonUtils.bytesAsHex(bidUser._sha256(input)), output); + } + run_next_test(); +}); + +add_test(function test_computeXClientStateHeader() { + let kBhex = "fd5c747806c07ce0b9d69dcfea144663e630b65ec4963596a22f24910d7dd15d"; + let kB = CommonUtils.hexToBytes(kBhex); + + let bidUser = new BrowserIDManager(); + let header = bidUser._computeXClientState(kB); + + do_check_eq(header, "6ae94683571c7a7c54dab4700aa3995f"); + run_next_test(); +}); + +add_task(function* test_getTokenErrors() { + _("BrowserIDManager correctly handles various failures to get a token."); + + _("Arrange for a 401 - Sync should reflect an auth error."); + initializeIdentityWithTokenServerResponse({ + status: 401, + headers: {"content-type": "application/json"}, + body: JSON.stringify({}), + }); + let browseridManager = Service.identity; + + yield browseridManager.initializeWithCurrentIdentity(); + yield Assert.rejects(browseridManager.whenReadyToAuthenticate.promise, + "should reject due to 401"); + Assert.equal(Status.login, LOGIN_FAILED_LOGIN_REJECTED, "login was rejected"); + + // XXX - other interesting responses to return? + + // And for good measure, some totally "unexpected" errors - we generally + // assume these problems are going to magically go away at some point. + _("Arrange for an empty body with a 200 response - should reflect a network error."); + initializeIdentityWithTokenServerResponse({ + status: 200, + headers: [], + body: "", + }); + browseridManager = Service.identity; + yield browseridManager.initializeWithCurrentIdentity(); + yield Assert.rejects(browseridManager.whenReadyToAuthenticate.promise, + "should reject due to non-JSON response"); + Assert.equal(Status.login, LOGIN_FAILED_NETWORK_ERROR, "login state is LOGIN_FAILED_NETWORK_ERROR"); +}); + +add_task(function* test_refreshCertificateOn401() { + _("BrowserIDManager refreshes the FXA certificate after a 401."); + var identityConfig = makeIdentityConfig(); + var browseridManager = new BrowserIDManager(); + // Use the real `_getAssertion` method that calls + // `mockFxAClient.signCertificate`. + let fxaInternal = makeFxAccountsInternalMock(identityConfig); + delete fxaInternal._getAssertion; + configureFxAccountIdentity(browseridManager, identityConfig, fxaInternal); + browseridManager._fxaService.internal.initialize(); + + let getCertCount = 0; + + let MockFxAccountsClient = function() { + FxAccountsClient.apply(this); + }; + MockFxAccountsClient.prototype = { + __proto__: FxAccountsClient.prototype, + signCertificate() { + ++getCertCount; + } + }; + + let mockFxAClient = new MockFxAccountsClient(); + browseridManager._fxaService.internal._fxAccountsClient = mockFxAClient; + + let didReturn401 = false; + let didReturn200 = false; + let mockTSC = mockTokenServer(() => { + if (getCertCount <= 1) { + didReturn401 = true; + return { + status: 401, + headers: {"content-type": "application/json"}, + body: JSON.stringify({}), + }; + } else { + didReturn200 = true; + return { + status: 200, + headers: {"content-type": "application/json"}, + body: JSON.stringify({ + id: "id", + key: "key", + api_endpoint: "http://example.com/", + uid: "uid", + duration: 300, + }) + }; + } + }); + + browseridManager._tokenServerClient = mockTSC; + + yield browseridManager.initializeWithCurrentIdentity(); + yield browseridManager.whenReadyToAuthenticate.promise; + + do_check_eq(getCertCount, 2); + do_check_true(didReturn401); + do_check_true(didReturn200); + do_check_true(browseridManager.account); + do_check_true(browseridManager._token); + do_check_true(browseridManager.hasValidToken()); + do_check_true(browseridManager.account); +}); + + + +add_task(function* test_getTokenErrorWithRetry() { + _("tokenserver sends an observer notification on various backoff headers."); + + // Set Sync's backoffInterval to zero - after we simulated the backoff header + // it should reflect the value we sent. + Status.backoffInterval = 0; + _("Arrange for a 503 with a Retry-After header."); + initializeIdentityWithTokenServerResponse({ + status: 503, + headers: {"content-type": "application/json", + "retry-after": "100"}, + body: JSON.stringify({}), + }); + let browseridManager = Service.identity; + + yield browseridManager.initializeWithCurrentIdentity(); + yield Assert.rejects(browseridManager.whenReadyToAuthenticate.promise, + "should reject due to 503"); + + // The observer should have fired - check it got the value in the response. + Assert.equal(Status.login, LOGIN_FAILED_NETWORK_ERROR, "login was rejected"); + // Sync will have the value in ms with some slop - so check it is at least that. + Assert.ok(Status.backoffInterval >= 100000); + + _("Arrange for a 200 with an X-Backoff header."); + Status.backoffInterval = 0; + initializeIdentityWithTokenServerResponse({ + status: 503, + headers: {"content-type": "application/json", + "x-backoff": "200"}, + body: JSON.stringify({}), + }); + browseridManager = Service.identity; + + yield browseridManager.initializeWithCurrentIdentity(); + yield Assert.rejects(browseridManager.whenReadyToAuthenticate.promise, + "should reject due to no token in response"); + + // The observer should have fired - check it got the value in the response. + Assert.ok(Status.backoffInterval >= 200000); +}); + +add_task(function* test_getKeysErrorWithBackoff() { + _("Auth server (via hawk) sends an observer notification on backoff headers."); + + // Set Sync's backoffInterval to zero - after we simulated the backoff header + // it should reflect the value we sent. + Status.backoffInterval = 0; + _("Arrange for a 503 with a X-Backoff header."); + + let config = makeIdentityConfig(); + // We want no kA or kB so we attempt to fetch them. + delete config.fxaccount.user.kA; + delete config.fxaccount.user.kB; + config.fxaccount.user.keyFetchToken = "keyfetchtoken"; + yield initializeIdentityWithHAWKResponseFactory(config, function(method, data, uri) { + Assert.equal(method, "get"); + Assert.equal(uri, "http://mockedserver:9999/account/keys") + return { + status: 503, + headers: {"content-type": "application/json", + "x-backoff": "100"}, + body: "{}", + } + }); + + let browseridManager = Service.identity; + yield Assert.rejects(browseridManager.whenReadyToAuthenticate.promise, + "should reject due to 503"); + + // The observer should have fired - check it got the value in the response. + Assert.equal(Status.login, LOGIN_FAILED_NETWORK_ERROR, "login was rejected"); + // Sync will have the value in ms with some slop - so check it is at least that. + Assert.ok(Status.backoffInterval >= 100000); +}); + +add_task(function* test_getKeysErrorWithRetry() { + _("Auth server (via hawk) sends an observer notification on retry headers."); + + // Set Sync's backoffInterval to zero - after we simulated the backoff header + // it should reflect the value we sent. + Status.backoffInterval = 0; + _("Arrange for a 503 with a Retry-After header."); + + let config = makeIdentityConfig(); + // We want no kA or kB so we attempt to fetch them. + delete config.fxaccount.user.kA; + delete config.fxaccount.user.kB; + config.fxaccount.user.keyFetchToken = "keyfetchtoken"; + yield initializeIdentityWithHAWKResponseFactory(config, function(method, data, uri) { + Assert.equal(method, "get"); + Assert.equal(uri, "http://mockedserver:9999/account/keys") + return { + status: 503, + headers: {"content-type": "application/json", + "retry-after": "100"}, + body: "{}", + } + }); + + let browseridManager = Service.identity; + yield Assert.rejects(browseridManager.whenReadyToAuthenticate.promise, + "should reject due to 503"); + + // The observer should have fired - check it got the value in the response. + Assert.equal(Status.login, LOGIN_FAILED_NETWORK_ERROR, "login was rejected"); + // Sync will have the value in ms with some slop - so check it is at least that. + Assert.ok(Status.backoffInterval >= 100000); +}); + +add_task(function* test_getHAWKErrors() { + _("BrowserIDManager correctly handles various HAWK failures."); + + _("Arrange for a 401 - Sync should reflect an auth error."); + let config = makeIdentityConfig(); + yield initializeIdentityWithHAWKResponseFactory(config, function(method, data, uri) { + Assert.equal(method, "post"); + Assert.equal(uri, "http://mockedserver:9999/certificate/sign") + return { + status: 401, + headers: {"content-type": "application/json"}, + body: JSON.stringify({}), + } + }); + Assert.equal(Status.login, LOGIN_FAILED_LOGIN_REJECTED, "login was rejected"); + + // XXX - other interesting responses to return? + + // And for good measure, some totally "unexpected" errors - we generally + // assume these problems are going to magically go away at some point. + _("Arrange for an empty body with a 200 response - should reflect a network error."); + yield initializeIdentityWithHAWKResponseFactory(config, function(method, data, uri) { + Assert.equal(method, "post"); + Assert.equal(uri, "http://mockedserver:9999/certificate/sign") + return { + status: 200, + headers: [], + body: "", + } + }); + Assert.equal(Status.login, LOGIN_FAILED_NETWORK_ERROR, "login state is LOGIN_FAILED_NETWORK_ERROR"); +}); + +add_task(function* test_getGetKeysFailing401() { + _("BrowserIDManager correctly handles 401 responses fetching keys."); + + _("Arrange for a 401 - Sync should reflect an auth error."); + let config = makeIdentityConfig(); + // We want no kA or kB so we attempt to fetch them. + delete config.fxaccount.user.kA; + delete config.fxaccount.user.kB; + config.fxaccount.user.keyFetchToken = "keyfetchtoken"; + yield initializeIdentityWithHAWKResponseFactory(config, function(method, data, uri) { + Assert.equal(method, "get"); + Assert.equal(uri, "http://mockedserver:9999/account/keys") + return { + status: 401, + headers: {"content-type": "application/json"}, + body: "{}", + } + }); + Assert.equal(Status.login, LOGIN_FAILED_LOGIN_REJECTED, "login was rejected"); +}); + +add_task(function* test_getGetKeysFailing503() { + _("BrowserIDManager correctly handles 5XX responses fetching keys."); + + _("Arrange for a 503 - Sync should reflect a network error."); + let config = makeIdentityConfig(); + // We want no kA or kB so we attempt to fetch them. + delete config.fxaccount.user.kA; + delete config.fxaccount.user.kB; + config.fxaccount.user.keyFetchToken = "keyfetchtoken"; + yield initializeIdentityWithHAWKResponseFactory(config, function(method, data, uri) { + Assert.equal(method, "get"); + Assert.equal(uri, "http://mockedserver:9999/account/keys") + return { + status: 503, + headers: {"content-type": "application/json"}, + body: "{}", + } + }); + Assert.equal(Status.login, LOGIN_FAILED_NETWORK_ERROR, "state reflects network error"); +}); + +add_task(function* test_getKeysMissing() { + _("BrowserIDManager correctly handles getKeys succeeding but not returning keys."); + + let browseridManager = new BrowserIDManager(); + let identityConfig = makeIdentityConfig(); + // our mock identity config already has kA and kB - remove them or we never + // try and fetch them. + delete identityConfig.fxaccount.user.kA; + delete identityConfig.fxaccount.user.kB; + identityConfig.fxaccount.user.keyFetchToken = 'keyFetchToken'; + + configureFxAccountIdentity(browseridManager, identityConfig); + + // Mock a fxAccounts object that returns no keys + let fxa = new FxAccounts({ + fetchAndUnwrapKeys: function () { + return Promise.resolve({}); + }, + fxAccountsClient: new MockFxAccountsClient(), + newAccountState(credentials) { + // We only expect this to be called with null indicating the (mock) + // storage should be read. + if (credentials) { + throw new Error("Not expecting to have credentials passed"); + } + let storageManager = new MockFxaStorageManager(); + storageManager.initialize(identityConfig.fxaccount.user); + return new AccountState(storageManager); + }, + }); + + // Add a mock to the currentAccountState object. + fxa.internal.currentAccountState.getCertificate = function(data, keyPair, mustBeValidUntil) { + this.cert = { + validUntil: fxa.internal.now() + CERT_LIFETIME, + cert: "certificate", + }; + return Promise.resolve(this.cert.cert); + }; + + browseridManager._fxaService = fxa; + + yield browseridManager.initializeWithCurrentIdentity(); + + let ex; + try { + yield browseridManager.whenReadyToAuthenticate.promise; + } catch (e) { + ex = e; + } + + Assert.ok(ex.message.indexOf("missing kA or kB") >= 0); +}); + +add_task(function* test_signedInUserMissing() { + _("BrowserIDManager detects getSignedInUser returning incomplete account data"); + + let browseridManager = new BrowserIDManager(); + let config = makeIdentityConfig(); + // Delete stored keys and the key fetch token. + delete identityConfig.fxaccount.user.kA; + delete identityConfig.fxaccount.user.kB; + delete identityConfig.fxaccount.user.keyFetchToken; + + configureFxAccountIdentity(browseridManager, identityConfig); + + let fxa = new FxAccounts({ + fetchAndUnwrapKeys: function () { + return Promise.resolve({}); + }, + fxAccountsClient: new MockFxAccountsClient(), + newAccountState(credentials) { + // We only expect this to be called with null indicating the (mock) + // storage should be read. + if (credentials) { + throw new Error("Not expecting to have credentials passed"); + } + let storageManager = new MockFxaStorageManager(); + storageManager.initialize(identityConfig.fxaccount.user); + return new AccountState(storageManager); + }, + }); + + browseridManager._fxaService = fxa; + + let status = yield browseridManager.unlockAndVerifyAuthState(); + Assert.equal(status, LOGIN_FAILED_LOGIN_REJECTED); +}); + +// End of tests +// Utility functions follow + +// Create a new browserid_identity object and initialize it with a +// hawk mock that simulates HTTP responses. +// The callback function will be called each time the mocked hawk server wants +// to make a request. The result of the callback should be the mock response +// object that will be returned to hawk. +// A token server mock will be used that doesn't hit a server, so we move +// directly to a hawk request. +function* initializeIdentityWithHAWKResponseFactory(config, cbGetResponse) { + // A mock request object. + function MockRESTRequest(uri, credentials, extra) { + this._uri = uri; + this._credentials = credentials; + this._extra = extra; + }; + MockRESTRequest.prototype = { + setHeader: function() {}, + post: function(data, callback) { + this.response = cbGetResponse("post", data, this._uri, this._credentials, this._extra); + callback.call(this); + }, + get: function(callback) { + // Skip /status requests (browserid_identity checks if the account still + // exists after an auth error) + if (this._uri.startsWith("http://mockedserver:9999/account/status")) { + this.response = { + status: 200, + headers: {"content-type": "application/json"}, + body: JSON.stringify({exists: true}), + }; + } else { + this.response = cbGetResponse("get", null, this._uri, this._credentials, this._extra); + } + callback.call(this); + } + } + + // The hawk client. + function MockedHawkClient() {} + MockedHawkClient.prototype = new HawkClient("http://mockedserver:9999"); + MockedHawkClient.prototype.constructor = MockedHawkClient; + MockedHawkClient.prototype.newHAWKAuthenticatedRESTRequest = function(uri, credentials, extra) { + return new MockRESTRequest(uri, credentials, extra); + } + // Arrange for the same observerPrefix as FxAccountsClient uses + MockedHawkClient.prototype.observerPrefix = "FxA:hawk"; + + // tie it all together - configureFxAccountIdentity isn't useful here :( + let fxaClient = new MockFxAccountsClient(); + fxaClient.hawk = new MockedHawkClient(); + let internal = { + fxAccountsClient: fxaClient, + newAccountState(credentials) { + // We only expect this to be called with null indicating the (mock) + // storage should be read. + if (credentials) { + throw new Error("Not expecting to have credentials passed"); + } + let storageManager = new MockFxaStorageManager(); + storageManager.initialize(config.fxaccount.user); + return new AccountState(storageManager); + }, + } + let fxa = new FxAccounts(internal); + + browseridManager._fxaService = fxa; + browseridManager._signedInUser = null; + yield browseridManager.initializeWithCurrentIdentity(); + yield Assert.rejects(browseridManager.whenReadyToAuthenticate.promise, + "expecting rejection due to hawk error"); +} + + +function getTimestamp(hawkAuthHeader) { + return parseInt(/ts="(\d+)"/.exec(hawkAuthHeader)[1], 10) * SECOND_MS; +} + +function getTimestampDelta(hawkAuthHeader, now=Date.now()) { + return Math.abs(getTimestamp(hawkAuthHeader) - now); +} + +function mockTokenServer(func) { + let requestLog = Log.repository.getLogger("testing.mock-rest"); + if (!requestLog.appenders.length) { // might as well see what it says :) + requestLog.addAppender(new Log.DumpAppender()); + requestLog.level = Log.Level.Trace; + } + function MockRESTRequest(url) {}; + MockRESTRequest.prototype = { + _log: requestLog, + setHeader: function() {}, + get: function(callback) { + this.response = func(); + callback.call(this); + } + } + // The mocked TokenServer client which will get the response. + function MockTSC() { } + MockTSC.prototype = new TokenServerClient(); + MockTSC.prototype.constructor = MockTSC; + MockTSC.prototype.newRESTRequest = function(url) { + return new MockRESTRequest(url); + } + // Arrange for the same observerPrefix as browserid_identity uses. + MockTSC.prototype.observerPrefix = "weave:service"; + return new MockTSC(); +} 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..d2123f80a0 --- /dev/null +++ b/services/sync/tests/unit/test_clients_engine.js @@ -0,0 +1,1439 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +Cu.import("resource://services-sync/constants.js"); +Cu.import("resource://services-sync/engines.js"); +Cu.import("resource://services-sync/engines/clients.js"); +Cu.import("resource://services-sync/record.js"); +Cu.import("resource://services-sync/service.js"); +Cu.import("resource://services-sync/util.js"); +Cu.import("resource://testing-common/services/sync/utils.js"); + +const MORE_THAN_CLIENTS_TTL_REFRESH = 691200; // 8 days +const LESS_THAN_CLIENTS_TTL_REFRESH = 86400; // 1 day + +var engine = Service.clientsEngine; + +/** + * Unpack the record with this ID, and verify that it has the same version that + * we should be putting into records. + */ +function check_record_version(user, id) { + let payload = JSON.parse(user.collection("clients").wbo(id).payload); + + let rec = new CryptoWrapper(); + rec.id = id; + rec.collection = "clients"; + rec.ciphertext = payload.ciphertext; + rec.hmac = payload.hmac; + rec.IV = payload.IV; + + let cleartext = rec.decrypt(Service.collectionKeys.keyForCollection("clients")); + + _("Payload is " + JSON.stringify(cleartext)); + equal(Services.appinfo.version, cleartext.version); + equal(2, cleartext.protocols.length); + equal("1.1", cleartext.protocols[0]); + equal("1.5", cleartext.protocols[1]); +} + +add_test(function test_bad_hmac() { + _("Ensure that Clients engine deletes corrupt records."); + let contents = { + meta: {global: {engines: {clients: {version: engine.version, + syncID: engine.syncID}}}}, + clients: {}, + crypto: {} + }; + let deletedCollections = []; + let deletedItems = []; + let callback = { + __proto__: SyncServerCallback, + onItemDeleted: function (username, coll, wboID) { + deletedItems.push(coll + "/" + wboID); + }, + onCollectionDeleted: function (username, coll) { + deletedCollections.push(coll); + } + } + let server = serverForUsers({"foo": "password"}, contents, callback); + let user = server.user("foo"); + + function check_clients_count(expectedCount) { + let stack = Components.stack.caller; + let coll = user.collection("clients"); + + // Treat a non-existent collection as empty. + equal(expectedCount, coll ? coll.count() : 0, stack); + } + + function check_client_deleted(id) { + let coll = user.collection("clients"); + let wbo = coll.wbo(id); + return !wbo || !wbo.payload; + } + + function uploadNewKeys() { + generateNewKeys(Service.collectionKeys); + let serverKeys = Service.collectionKeys.asWBO("crypto", "keys"); + serverKeys.encrypt(Service.identity.syncKeyBundle); + ok(serverKeys.upload(Service.resource(Service.cryptoKeysURL)).success); + } + + try { + ensureLegacyIdentityManager(); + let passphrase = "abcdeabcdeabcdeabcdeabcdea"; + Service.serverURL = server.baseURI; + Service.login("foo", "ilovejane", passphrase); + + generateNewKeys(Service.collectionKeys); + + _("First sync, client record is uploaded"); + equal(engine.lastRecordUpload, 0); + check_clients_count(0); + engine._sync(); + check_clients_count(1); + ok(engine.lastRecordUpload > 0); + + // Our uploaded record has a version. + 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(); + engine.resetClient(); + generateNewKeys(Service.collectionKeys); + let serverKeys = Service.collectionKeys.asWBO("crypto", "keys"); + serverKeys.encrypt(Service.identity.syncKeyBundle); + ok(serverKeys.upload(Service.resource(Service.cryptoKeysURL)).success); + + _("Sync."); + engine._sync(); + + _("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(); + engine.resetClient(); + generateNewKeys(Service.collectionKeys); + deletedCollections = []; + deletedItems = []; + check_clients_count(1); + engine._sync(); + + _("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(); + engine.resetClient(); + deletedCollections = []; + deletedItems = []; + check_clients_count(0); + + uploadNewKeys(); + + // Sync once to upload a record. + engine._sync(); + check_clients_count(1); + + // Generate and upload new keys, so the old client record is wrong. + 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(); + engine.resetClient(); + generateNewKeys(Service.collectionKeys); + let oldKey = Service.collectionKeys.keyForCollection(); + + equal(deletedCollections.length, 0); + equal(deletedItems.length, 0); + engine._sync(); + equal(deletedItems.length, 1); + check_client_deleted(oldLocalID); + check_clients_count(1); + let newKey = Service.collectionKeys.keyForCollection(); + ok(!oldKey.equals(newKey)); + + } finally { + Svc.Prefs.resetBranch(""); + Service.recordManager.clearCache(); + server.stop(run_next_test); + } +}); + +add_test(function test_properties() { + _("Test lastRecordUpload property"); + try { + equal(Svc.Prefs.get("clients.lastRecordUpload"), undefined); + equal(engine.lastRecordUpload, 0); + + let now = Date.now(); + engine.lastRecordUpload = now / 1000; + equal(engine.lastRecordUpload, Math.floor(now / 1000)); + } finally { + Svc.Prefs.resetBranch(""); + run_next_test(); + } +}); + +add_test(function test_full_sync() { + _("Ensure that Clients engine fetches all records for each sync."); + + let now = Date.now() / 1000; + let contents = { + meta: {global: {engines: {clients: {version: engine.version, + syncID: engine.syncID}}}}, + clients: {}, + crypto: {} + }; + let server = serverForUsers({"foo": "password"}, contents); + let user = server.user("foo"); + + new SyncTestingInfrastructure(server.server); + generateNewKeys(Service.collectionKeys); + + let activeID = Utils.makeGUID(); + server.insertWBO("foo", "clients", new ServerWBO(activeID, encryptPayload({ + id: activeID, + name: "Active client", + type: "desktop", + commands: [], + version: "48", + protocols: ["1.5"], + }), now - 10)); + + let deletedID = Utils.makeGUID(); + server.insertWBO("foo", "clients", new ServerWBO(deletedID, encryptPayload({ + 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); + engine._sync(); + ok(engine.lastRecordUpload > 0); + deepEqual(user.collection("clients").keys().sort(), + [activeID, deletedID, engine.localID].sort(), + "Our record should be uploaded on first sync"); + deepEqual(Object.keys(store.getAllIDs()).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. + engine.lastModified = now; + engine._sync(); + + _("Record should be updated"); + deepEqual(Object.keys(store.getAllIDs()).sort(), + [activeID, engine.localID].sort(), + "Deleted client should be removed on next sync"); + } finally { + Svc.Prefs.resetBranch(""); + Service.recordManager.clearCache(); + + try { + server.deleteCollections("foo"); + } finally { + server.stop(run_next_test); + } + } +}); + +add_test(function test_sync() { + _("Ensure that Clients engine uploads a new client record once a week."); + + let contents = { + meta: {global: {engines: {clients: {version: engine.version, + syncID: engine.syncID}}}}, + clients: {}, + crypto: {} + }; + let server = serverForUsers({"foo": "password"}, contents); + let user = server.user("foo"); + + new SyncTestingInfrastructure(server.server); + 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); + engine._sync(); + ok(!!clientWBO().payload); + ok(engine.lastRecordUpload > 0); + + _("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; + engine._sync(); + ok(!!clientWBO().payload); + ok(engine.lastRecordUpload > lastweek); + + _("Remove client record."); + 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; + engine._sync(); + equal(clientWBO().payload, undefined); + equal(engine.lastRecordUpload, yesterday); + + } finally { + Svc.Prefs.resetBranch(""); + Service.recordManager.clearCache(); + server.stop(run_next_test); + } +}); + +add_test(function test_client_name_change() { + _("Ensure client name change incurs a client record update."); + + let tracker = engine._tracker; + + let localID = engine.localID; + let initialName = engine.localName; + + Svc.Obs.notify("weave:engine:start-tracking"); + _("initial name: " + initialName); + + // Tracker already has data, so clear it. + tracker.clearChangedIDs(); + + let initialScore = tracker.score; + + equal(Object.keys(tracker.changedIDs).length, 0); + + Svc.Prefs.set("client.name", "new name"); + + _("new name: " + engine.localName); + notEqual(initialName, engine.localName); + equal(Object.keys(tracker.changedIDs).length, 1); + ok(engine.localID in tracker.changedIDs); + ok(tracker.score > initialScore); + ok(tracker.score >= SCORE_INCREMENT_XLARGE); + + Svc.Obs.notify("weave:engine:stop-tracking"); + + run_next_test(); +}); + +add_test(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); + + store.create(rec); + let remoteRecord = store.createRecord(remoteId, "clients"); + + let action = "testCommand"; + let args = ["foo", "bar"]; + + engine._sendCommandToClient(action, args, remoteId); + + let newRecord = store._remoteClients[remoteId]; + let clientCommands = 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); + + notEqual(tracker.changedIDs[remoteId], undefined); + + run_next_test(); +}); + +add_test(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], + ["wipeAll", [], true ], + ["wipeAll", ["foo"], 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); + + store.create(rec); + store.createRecord(remoteId, "clients"); + + engine.sendCommand(action, args, remoteId); + + let newRecord = store._remoteClients[remoteId]; + notEqual(newRecord, undefined); + + let clientCommands = 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); + notEqual(engine._tracker.changedIDs[remoteId], undefined); + } else { + _("Ensuring command is scrubbed: " + action); + equal(clientCommands, undefined); + + if (store._tracker) { + equal(engine._tracker[remoteId], undefined); + } + } + + } + run_next_test(); +}); + +add_test(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); + store.create(rec); + store.createRecord(remoteId, "clients"); + + let action = "resetAll"; + let args = []; + + engine.sendCommand(action, args, remoteId); + engine.sendCommand(action, args, remoteId); + + let newRecord = store._remoteClients[remoteId]; + let clientCommands = engine._readCommands()[remoteId]; + equal(clientCommands.length, 1); + + _("Check variant args length"); + engine._saveCommands({}); + + action = "resetEngine"; + engine.sendCommand(action, [{ x: "foo" }], remoteId); + engine.sendCommand(action, [{ x: "bar" }], remoteId); + + _("Make sure we spot a real dupe argument."); + engine.sendCommand(action, [{ x: "bar" }], remoteId); + + clientCommands = engine._readCommands()[remoteId]; + equal(clientCommands.length, 2); + + run_next_test(); +}); + +add_test(function test_command_invalid_client() { + _("Ensures invalid client IDs are caught"); + + let id = Utils.makeGUID(); + let error; + + try { + engine.sendCommand("wipeAll", [], id); + } catch (ex) { + error = ex; + } + + equal(error.message.indexOf("Unknown remote client ID: "), 0); + + run_next_test(); +}); + +add_test(function test_process_incoming_commands() { + _("Ensures local commands are executed"); + + engine.localCommands = [{ command: "logout", args: [] }]; + + let ev = "weave:service:logout:finish"; + + var handler = function() { + Svc.Obs.remove(ev, handler); + + Svc.Prefs.resetBranch(""); + Service.recordManager.clearCache(); + engine._resetClient(); + + run_next_test(); + }; + + Svc.Obs.add(ev, handler); + + // logout command causes processIncomingCommands to return explicit false. + ok(!engine.processIncomingCommands()); +}); + +add_test(function test_filter_duplicate_names() { + _("Ensure that we exclude clients with identical names that haven't synced in a week."); + + let now = Date.now() / 1000; + let contents = { + meta: {global: {engines: {clients: {version: engine.version, + syncID: engine.syncID}}}}, + clients: {}, + crypto: {} + }; + let server = serverForUsers({"foo": "password"}, contents); + let user = server.user("foo"); + + new SyncTestingInfrastructure(server.server); + generateNewKeys(Service.collectionKeys); + + // Synced recently. + let recentID = Utils.makeGUID(); + server.insertWBO("foo", "clients", new ServerWBO(recentID, encryptPayload({ + 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(); + server.insertWBO("foo", "clients", new ServerWBO(dupeID, encryptPayload({ + id: dupeID, + name: engine.localName, + type: "desktop", + commands: [], + version: "48", + protocols: ["1.5"], + }), now - 604810)); + + // Synced more than 1 week ago, but not a dupe. + let oldID = Utils.makeGUID(); + server.insertWBO("foo", "clients", new ServerWBO(oldID, encryptPayload({ + 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); + engine._sync(); + ok(engine.lastRecordUpload > 0); + deepEqual(user.collection("clients").keys().sort(), + [recentID, dupeID, oldID, engine.localID].sort(), + "Our record should be uploaded on first sync"); + + deepEqual(Object.keys(store.getAllIDs()).sort(), + [recentID, dupeID, oldID, engine.localID].sort(), + "Duplicate ID should remain in getAllIDs"); + ok(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; + }); + + engine._sync(); + 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"); + engine.sendCommand("logout", []); + engine._sync(); + + let collection = server.getCollection("foo", "clients"); + let recentPayload = JSON.parse(JSON.parse(collection.payload(recentID)).ciphertext); + deepEqual(recentPayload.commands, [{ command: "logout", args: [] }], + "Should send commands to the recent client"); + + let oldPayload = JSON.parse(JSON.parse(collection.payload(oldID)).ciphertext); + deepEqual(oldPayload.commands, [{ command: "logout", args: [] }], + "Should send commands to the week-old client"); + + let dupePayload = JSON.parse(JSON.parse(collection.payload(dupeID)).ciphertext); + deepEqual(dupePayload.commands, [], + "Should not send commands to the dupe client"); + + _("Update the dupe client's modified time"); + server.insertWBO("foo", "clients", new ServerWBO(dupeID, encryptPayload({ + id: dupeID, + name: engine.localName, + type: "desktop", + commands: [], + version: "48", + protocols: ["1.5"], + }), now - 10)); + + _("Second sync."); + engine._sync(); + + deepEqual(Object.keys(store.getAllIDs()).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 { + Svc.Prefs.resetBranch(""); + Service.recordManager.clearCache(); + + try { + server.deleteCollections("foo"); + } finally { + server.stop(run_next_test); + } + } +}); + +add_test(function test_command_sync() { + _("Ensure that commands are synced across clients."); + + engine._store.wipe(); + generateNewKeys(Service.collectionKeys); + + let contents = { + meta: {global: {engines: {clients: {version: engine.version, + syncID: engine.syncID}}}}, + clients: {}, + crypto: {} + }; + let server = serverForUsers({"foo": "password"}, contents); + new SyncTestingInfrastructure(server.server); + + let user = server.user("foo"); + let remoteId = Utils.makeGUID(); + + function clientWBO(id) { + return user.collection("clients").wbo(id); + } + + _("Create remote client record"); + server.insertWBO("foo", "clients", new ServerWBO(remoteId, encryptPayload({ + id: remoteId, + name: "Remote client", + type: "desktop", + commands: [], + version: "48", + protocols: ["1.5"], + }), Date.now() / 1000)); + + try { + _("Syncing."); + engine._sync(); + + _("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."); + engine.sendCommand("wipeAll", []); + let clientCommands = engine._readCommands()[remoteId]; + equal(clientCommands.length, 1); + engine._sync(); + + _("Checking record was uploaded."); + notEqual(clientWBO(engine.localID).payload, undefined); + ok(engine.lastRecordUpload > 0); + + notEqual(clientWBO(remoteId).payload, undefined); + + Svc.Prefs.set("client.GUID", remoteId); + engine._resetClient(); + equal(engine.localID, remoteId); + _("Performing sync on resetted client."); + engine._sync(); + notEqual(engine.localCommands, undefined); + equal(engine.localCommands.length, 1); + + let command = engine.localCommands[0]; + equal(command.command, "wipeAll"); + equal(command.args.length, 0); + + } finally { + Svc.Prefs.resetBranch(""); + Service.recordManager.clearCache(); + + try { + let collection = server.getCollection("foo", "clients"); + collection.remove(remoteId); + } finally { + server.stop(run_next_test); + } + } +}); + +add_test(function test_send_uri_to_client_for_display() { + _("Ensure sendURIToClientForDisplay() sends command properly."); + + let tracker = engine._tracker; + let store = engine._store; + + let remoteId = Utils.makeGUID(); + let rec = new ClientsRec("clients", remoteId); + rec.name = "remote"; + store.create(rec); + let remoteRecord = store.createRecord(remoteId, "clients"); + + tracker.clearChangedIDs(); + let initialScore = tracker.score; + + let uri = "http://www.mozilla.org/"; + let title = "Title of the Page"; + engine.sendURIToClientForDisplay(uri, remoteId, title); + + let newRecord = store._remoteClients[remoteId]; + + notEqual(newRecord, undefined); + let clientCommands = engine._readCommands()[remoteId]; + equal(clientCommands.length, 1); + + let command = clientCommands[0]; + equal(command.command, "displayURI"); + equal(command.args.length, 3); + equal(command.args[0], uri); + equal(command.args[1], engine.localID); + equal(command.args[2], title); + + ok(tracker.score > initialScore); + ok(tracker.score - initialScore >= SCORE_INCREMENT_XLARGE); + + _("Ensure unknown client IDs result in exception."); + let unknownId = Utils.makeGUID(); + let error; + + try { + engine.sendURIToClientForDisplay(uri, unknownId); + } catch (ex) { + error = ex; + } + + equal(error.message.indexOf("Unknown remote client ID: "), 0); + + Svc.Prefs.resetBranch(""); + Service.recordManager.clearCache(); + engine._resetClient(); + + run_next_test(); +}); + +add_test(function test_receive_display_uri() { + _("Ensure processing of received 'displayURI' commands works."); + + // We don't set up WBOs and perform syncing because other tests verify + // the command API works as advertised. This saves us a little work. + + let uri = "http://www.mozilla.org/"; + let remoteId = Utils.makeGUID(); + let title = "Page Title!"; + + let command = { + command: "displayURI", + args: [uri, remoteId, title], + }; + + engine.localCommands = [command]; + + // Received 'displayURI' command should result in the topic defined below + // being called. + let ev = "weave:engine:clients:display-uris"; + + let handler = function(subject, data) { + Svc.Obs.remove(ev, handler); + + equal(subject[0].uri, uri); + equal(subject[0].clientId, remoteId); + equal(subject[0].title, title); + equal(data, null); + + run_next_test(); + }; + + Svc.Obs.add(ev, handler); + + ok(engine.processIncomingCommands()); + + Svc.Prefs.resetBranch(""); + Service.recordManager.clearCache(); + engine._resetClient(); +}); + +add_test(function test_optional_client_fields() { + _("Ensure that we produce records with the fields added in Bug 1097222."); + + const SUPPORTED_PROTOCOL_VERSIONS = ["1.1", "1.5"]; + let local = 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. + + engine._resetClient(); + run_next_test(); +}); + +add_test(function test_merge_commands() { + _("Verifies local commands for remote clients are merged with the server's"); + + let now = Date.now() / 1000; + let contents = { + meta: {global: {engines: {clients: {version: engine.version, + syncID: engine.syncID}}}}, + clients: {}, + crypto: {} + }; + let server = serverForUsers({"foo": "password"}, contents); + let user = server.user("foo"); + + new SyncTestingInfrastructure(server.server); + generateNewKeys(Service.collectionKeys); + + let desktopID = Utils.makeGUID(); + server.insertWBO("foo", "clients", new ServerWBO(desktopID, encryptPayload({ + id: desktopID, + name: "Desktop client", + type: "desktop", + commands: [{ + command: "displayURI", + args: ["https://example.com", engine.localID, "Yak Herders Anonymous"], + }], + version: "48", + protocols: ["1.5"], + }), now - 10)); + + let mobileID = Utils.makeGUID(); + server.insertWBO("foo", "clients", new ServerWBO(mobileID, encryptPayload({ + id: mobileID, + name: "Mobile client", + type: "mobile", + commands: [{ + command: "logout", + args: [], + }], + version: "48", + protocols: ["1.5"], + }), now - 10)); + + try { + let store = engine._store; + + _("First sync. 2 records downloaded."); + strictEqual(engine.lastRecordUpload, 0); + engine._sync(); + + _("Broadcast logout to all clients"); + engine.sendCommand("logout", []); + engine._sync(); + + let collection = server.getCollection("foo", "clients"); + let desktopPayload = JSON.parse(JSON.parse(collection.payload(desktopID)).ciphertext); + deepEqual(desktopPayload.commands, [{ + command: "displayURI", + args: ["https://example.com", engine.localID, "Yak Herders Anonymous"], + }, { + command: "logout", + args: [], + }], "Should send the logout command to the desktop client"); + + let mobilePayload = JSON.parse(JSON.parse(collection.payload(mobileID)).ciphertext); + deepEqual(mobilePayload.commands, [{ command: "logout", args: [] }], + "Should not send a duplicate logout to the mobile client"); + } finally { + Svc.Prefs.resetBranch(""); + Service.recordManager.clearCache(); + engine._resetClient(); + + try { + server.deleteCollections("foo"); + } finally { + server.stop(run_next_test); + } + } +}); + +add_test(function test_duplicate_remote_commands() { + _("Verifies local commands for remote clients are sent only once (bug 1289287)"); + + let now = Date.now() / 1000; + let contents = { + meta: {global: {engines: {clients: {version: engine.version, + syncID: engine.syncID}}}}, + clients: {}, + crypto: {} + }; + let server = serverForUsers({"foo": "password"}, contents); + let user = server.user("foo"); + + new SyncTestingInfrastructure(server.server); + generateNewKeys(Service.collectionKeys); + + let desktopID = Utils.makeGUID(); + server.insertWBO("foo", "clients", new ServerWBO(desktopID, encryptPayload({ + id: desktopID, + name: "Desktop client", + type: "desktop", + commands: [], + version: "48", + protocols: ["1.5"], + }), now - 10)); + + try { + let store = engine._store; + + _("First sync. 1 record downloaded."); + strictEqual(engine.lastRecordUpload, 0); + engine._sync(); + + _("Send tab to client"); + engine.sendCommand("displayURI", ["https://example.com", engine.localID, "Yak Herders Anonymous"]); + engine._sync(); + + _("Simulate the desktop client consuming the command and syncing to the server"); + server.insertWBO("foo", "clients", new ServerWBO(desktopID, encryptPayload({ + id: desktopID, + name: "Desktop client", + type: "desktop", + commands: [], + version: "48", + protocols: ["1.5"], + }), now - 10)); + + _("Send another tab to the desktop client"); + engine.sendCommand("displayURI", ["https://foobar.com", engine.localID, "Foo bar!"], desktopID); + engine._sync(); + + let collection = server.getCollection("foo", "clients"); + let desktopPayload = JSON.parse(JSON.parse(collection.payload(desktopID)).ciphertext); + deepEqual(desktopPayload.commands, [{ + command: "displayURI", + args: ["https://foobar.com", engine.localID, "Foo bar!"], + }], "Should only send the second command to the desktop client"); + } finally { + Svc.Prefs.resetBranch(""); + Service.recordManager.clearCache(); + engine._resetClient(); + + try { + server.deleteCollections("foo"); + } finally { + server.stop(run_next_test); + } + } +}); + +add_test(function test_upload_after_reboot() { + _("Multiple downloads, reboot, then upload (bug 1289287)"); + + let now = Date.now() / 1000; + let contents = { + meta: {global: {engines: {clients: {version: engine.version, + syncID: engine.syncID}}}}, + clients: {}, + crypto: {} + }; + let server = serverForUsers({"foo": "password"}, contents); + let user = server.user("foo"); + + new SyncTestingInfrastructure(server.server); + generateNewKeys(Service.collectionKeys); + + let deviceBID = Utils.makeGUID(); + let deviceCID = Utils.makeGUID(); + server.insertWBO("foo", "clients", new ServerWBO(deviceBID, encryptPayload({ + id: deviceBID, + name: "Device B", + type: "desktop", + commands: [{ + command: "displayURI", args: ["https://deviceclink.com", deviceCID, "Device C link"] + }], + version: "48", + protocols: ["1.5"], + }), now - 10)); + server.insertWBO("foo", "clients", new ServerWBO(deviceCID, encryptPayload({ + id: deviceCID, + name: "Device C", + type: "desktop", + commands: [], + version: "48", + protocols: ["1.5"], + }), now - 10)); + + try { + let store = engine._store; + + _("First sync. 2 records downloaded."); + strictEqual(engine.lastRecordUpload, 0); + engine._sync(); + + _("Send tab to client"); + engine.sendCommand("displayURI", ["https://example.com", engine.localID, "Yak Herders Anonymous"], deviceBID); + + const oldUploadOutgoing = SyncEngine.prototype._uploadOutgoing; + SyncEngine.prototype._uploadOutgoing = () => engine._onRecordsWritten.call(engine, [], [deviceBID]); + engine._sync(); + + let collection = server.getCollection("foo", "clients"); + let deviceBPayload = JSON.parse(JSON.parse(collection.payload(deviceBID)).ciphertext); + deepEqual(deviceBPayload.commands, [{ + command: "displayURI", args: ["https://deviceclink.com", deviceCID, "Device C link"] + }], "Should be the same because the upload failed"); + + _("Simulate the client B consuming the command and syncing to the server"); + server.insertWBO("foo", "clients", new ServerWBO(deviceBID, encryptPayload({ + 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); + + engine._sync(); + + deviceBPayload = JSON.parse(JSON.parse(collection.payload(deviceBID)).ciphertext); + deepEqual(deviceBPayload.commands, [{ + command: "displayURI", + args: ["https://example.com", engine.localID, "Yak Herders Anonymous"], + }], "Should only had written our outgoing command"); + } finally { + Svc.Prefs.resetBranch(""); + Service.recordManager.clearCache(); + engine._resetClient(); + + try { + server.deleteCollections("foo"); + } finally { + server.stop(run_next_test); + } + } +}); + +add_test(function test_keep_cleared_commands_after_reboot() { + _("Download commands, fail upload, reboot, then apply new commands (bug 1289287)"); + + let now = Date.now() / 1000; + let contents = { + meta: {global: {engines: {clients: {version: engine.version, + syncID: engine.syncID}}}}, + clients: {}, + crypto: {} + }; + let server = serverForUsers({"foo": "password"}, contents); + let user = server.user("foo"); + + new SyncTestingInfrastructure(server.server); + generateNewKeys(Service.collectionKeys); + + let deviceBID = Utils.makeGUID(); + let deviceCID = Utils.makeGUID(); + server.insertWBO("foo", "clients", new ServerWBO(engine.localID, encryptPayload({ + id: engine.localID, + name: "Device A", + type: "desktop", + commands: [{ + command: "displayURI", args: ["https://deviceblink.com", deviceBID, "Device B link"] + }, + { + command: "displayURI", args: ["https://deviceclink.com", deviceCID, "Device C link"] + }], + version: "48", + protocols: ["1.5"], + }), now - 10)); + server.insertWBO("foo", "clients", new ServerWBO(deviceBID, encryptPayload({ + id: deviceBID, + name: "Device B", + type: "desktop", + commands: [], + version: "48", + protocols: ["1.5"], + }), now - 10)); + server.insertWBO("foo", "clients", new ServerWBO(deviceCID, encryptPayload({ + id: deviceCID, + name: "Device C", + type: "desktop", + commands: [], + version: "48", + protocols: ["1.5"], + }), now - 10)); + + try { + let store = engine._store; + + _("First sync. Download remote and our record."); + strictEqual(engine.lastRecordUpload, 0); + + let collection = server.getCollection("foo", "clients"); + const oldUploadOutgoing = SyncEngine.prototype._uploadOutgoing; + SyncEngine.prototype._uploadOutgoing = () => engine._onRecordsWritten.call(engine, [], [deviceBID]); + let commandsProcessed = 0; + engine._handleDisplayURIs = (uris) => { commandsProcessed = uris.length }; + + engine._sync(); + engine.processIncomingCommands(); // Not called by the engine.sync(), gotta call it ourselves + equal(commandsProcessed, 2, "We processed 2 commands"); + + let localRemoteRecord = JSON.parse(JSON.parse(collection.payload(engine.localID)).ciphertext); + deepEqual(localRemoteRecord.commands, [{ + command: "displayURI", args: ["https://deviceblink.com", deviceBID, "Device B link"] + }, + { + command: "displayURI", args: ["https://deviceclink.com", deviceCID, "Device C link"] + }], "Should be the same because the upload failed"); + + // Another client sends another link + server.insertWBO("foo", "clients", new ServerWBO(engine.localID, encryptPayload({ + id: engine.localID, + name: "Device A", + type: "desktop", + commands: [{ + command: "displayURI", args: ["https://deviceblink.com", deviceBID, "Device B link"] + }, + { + command: "displayURI", args: ["https://deviceclink.com", deviceCID, "Device C link"] + }, + { + command: "displayURI", args: ["https://deviceclink2.com", deviceCID, "Device C link 2"] + }], + version: "48", + protocols: ["1.5"], + }), now - 10)); + + // Simulate reboot + SyncEngine.prototype._uploadOutgoing = oldUploadOutgoing; + engine = Service.clientsEngine = new ClientEngine(Service); + + commandsProcessed = 0; + engine._handleDisplayURIs = (uris) => { commandsProcessed = uris.length }; + engine._sync(); + engine.processIncomingCommands(); + equal(commandsProcessed, 1, "We processed one command (the other were cleared)"); + + localRemoteRecord = JSON.parse(JSON.parse(collection.payload(deviceBID)).ciphertext); + deepEqual(localRemoteRecord.commands, [], "Should be empty"); + } finally { + Svc.Prefs.resetBranch(""); + Service.recordManager.clearCache(); + + // Reset service (remove mocks) + engine = Service.clientsEngine = new ClientEngine(Service); + engine._resetClient(); + + try { + server.deleteCollections("foo"); + } finally { + server.stop(run_next_test); + } + } +}); + +add_test(function test_deleted_commands() { + _("Verifies commands for a deleted client are discarded"); + + let now = Date.now() / 1000; + let contents = { + meta: {global: {engines: {clients: {version: engine.version, + syncID: engine.syncID}}}}, + clients: {}, + crypto: {} + }; + let server = serverForUsers({"foo": "password"}, contents); + let user = server.user("foo"); + + new SyncTestingInfrastructure(server.server); + generateNewKeys(Service.collectionKeys); + + let activeID = Utils.makeGUID(); + server.insertWBO("foo", "clients", new ServerWBO(activeID, encryptPayload({ + id: activeID, + name: "Active client", + type: "desktop", + commands: [], + version: "48", + protocols: ["1.5"], + }), now - 10)); + + let deletedID = Utils.makeGUID(); + server.insertWBO("foo", "clients", new ServerWBO(deletedID, encryptPayload({ + 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."); + engine._sync(); + + _("Delete a record on the server."); + let collection = server.getCollection("foo", "clients"); + collection.remove(deletedID); + + _("Broadcast a command to all clients"); + engine.sendCommand("logout", []); + engine._sync(); + + deepEqual(collection.keys().sort(), [activeID, engine.localID].sort(), + "Should not reupload deleted clients"); + + let activePayload = JSON.parse(JSON.parse(collection.payload(activeID)).ciphertext); + deepEqual(activePayload.commands, [{ command: "logout", args: [] }], + "Should send the command to the active client"); + } finally { + Svc.Prefs.resetBranch(""); + Service.recordManager.clearCache(); + engine._resetClient(); + + try { + server.deleteCollections("foo"); + } finally { + server.stop(run_next_test); + } + } +}); + +add_test(function test_send_uri_ack() { + _("Ensure a sent URI is deleted when the client syncs"); + + let now = Date.now() / 1000; + let contents = { + meta: {global: {engines: {clients: {version: engine.version, + syncID: engine.syncID}}}}, + clients: {}, + crypto: {} + }; + let server = serverForUsers({"foo": "password"}, contents); + let user = server.user("foo"); + + new SyncTestingInfrastructure(server.server); + generateNewKeys(Service.collectionKeys); + + try { + let fakeSenderID = Utils.makeGUID(); + + _("Initial sync for empty clients collection"); + engine._sync(); + let collection = server.getCollection("foo", "clients"); + let ourPayload = JSON.parse(JSON.parse(collection.payload(engine.localID)).ciphertext); + ok(ourPayload, "Should upload our client record"); + + _("Send a URL to the device on the server"); + ourPayload.commands = [{ + command: "displayURI", + args: ["https://example.com", fakeSenderID, "Yak Herders Anonymous"], + }]; + server.insertWBO("foo", "clients", new ServerWBO(engine.localID, encryptPayload(ourPayload), now)); + + _("Sync again"); + engine._sync(); + deepEqual(engine.localCommands, [{ + command: "displayURI", + args: ["https://example.com", fakeSenderID, "Yak Herders Anonymous"], + }], "Should receive incoming URI"); + ok(engine.processIncomingCommands(), "Should process incoming commands"); + const clearedCommands = engine._readCommands()[engine.localID]; + deepEqual(clearedCommands, [{ + command: "displayURI", + args: ["https://example.com", fakeSenderID, "Yak Herders Anonymous"], + }], "Should mark the commands as cleared after processing"); + + _("Check that the command was removed on the server"); + engine._sync(); + ourPayload = JSON.parse(JSON.parse(collection.payload(engine.localID)).ciphertext); + ok(ourPayload, "Should upload the synced client record"); + deepEqual(ourPayload.commands, [], "Should not reupload cleared commands"); + } finally { + Svc.Prefs.resetBranch(""); + Service.recordManager.clearCache(); + engine._resetClient(); + + try { + server.deleteCollections("foo"); + } finally { + server.stop(run_next_test); + } + } +}); + +add_test(function test_command_sync() { + _("Notify other clients when writing their record."); + + engine._store.wipe(); + generateNewKeys(Service.collectionKeys); + + let contents = { + meta: {global: {engines: {clients: {version: engine.version, + syncID: engine.syncID}}}}, + clients: {}, + crypto: {} + }; + let server = serverForUsers({"foo": "password"}, contents); + new SyncTestingInfrastructure(server.server); + + let user = server.user("foo"); + let collection = server.getCollection("foo", "clients"); + let remoteId = Utils.makeGUID(); + let remoteId2 = Utils.makeGUID(); + + function clientWBO(id) { + return user.collection("clients").wbo(id); + } + + _("Create remote client record 1"); + server.insertWBO("foo", "clients", new ServerWBO(remoteId, encryptPayload({ + id: remoteId, + name: "Remote client", + type: "desktop", + commands: [], + version: "48", + protocols: ["1.5"] + }), Date.now() / 1000)); + + _("Create remote client record 2"); + server.insertWBO("foo", "clients", new ServerWBO(remoteId2, encryptPayload({ + id: remoteId2, + name: "Remote client 2", + type: "mobile", + commands: [], + version: "48", + protocols: ["1.5"] + }), Date.now() / 1000)); + + try { + equal(collection.count(), 2, "2 remote records written"); + engine._sync(); + equal(collection.count(), 3, "3 remote records written (+1 for the synced local record)"); + + let notifiedIds; + engine.sendCommand("wipeAll", []); + engine._tracker.addChangedID(engine.localID); + engine.getClientFxaDeviceId = (id) => "fxa-" + id; + engine._notifyCollectionChanged = (ids) => (notifiedIds = ids); + _("Syncing."); + engine._sync(); + deepEqual(notifiedIds, ["fxa-fake-guid-00","fxa-fake-guid-01"]); + ok(!notifiedIds.includes(engine.getClientFxaDeviceId(engine.localID)), + "We never notify the local device"); + + } finally { + Svc.Prefs.resetBranch(""); + Service.recordManager.clearCache(); + + try { + server.deleteCollections("foo"); + } finally { + server.stop(run_next_test); + } + } +}); + +function run_test() { + initTestLogging("Trace"); + Log.repository.getLogger("Sync.Engine.Clients").level = Log.Level.Trace; + run_next_test(); +} diff --git a/services/sync/tests/unit/test_clients_escape.js b/services/sync/tests/unit/test_clients_escape.js new file mode 100644 index 0000000000..8c8cd63e3e --- /dev/null +++ b/services/sync/tests/unit/test_clients_escape.js @@ -0,0 +1,64 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +Cu.import("resource://services-sync/keys.js"); +Cu.import("resource://services-sync/record.js"); +Cu.import("resource://services-sync/service.js"); +Cu.import("resource://services-sync/util.js"); +Cu.import("resource://testing-common/services/sync/utils.js"); + +function run_test() { + _("Set up test fixtures."); + + ensureLegacyIdentityManager(); + Service.identity.username = "john@example.com"; + Service.clusterURL = "http://fakebase/"; + let baseUri = "http://fakebase/1.1/foo/storage/"; + let pubUri = baseUri + "keys/pubkey"; + let privUri = baseUri + "keys/privkey"; + + Service.identity.syncKey = "abcdeabcdeabcdeabcdeabcdea"; + let keyBundle = Service.identity.syncKeyBundle; + + let engine = Service.clientsEngine; + + try { + _("Test that serializing client records results in uploadable ascii"); + engine.localID = "ascii"; + engine.localName = "wéävê"; + + _("Make sure we have the expected record"); + let record = engine._createRecord("ascii"); + do_check_eq(record.id, "ascii"); + do_check_eq(record.name, "wéävê"); + + _("Encrypting record..."); + record.encrypt(keyBundle); + _("Encrypted."); + + let serialized = JSON.stringify(record); + let checkCount = 0; + _("Checking for all ASCII:", serialized); + Array.forEach(serialized, function(ch) { + let code = ch.charCodeAt(0); + _("Checking asciiness of '", ch, "'=", code); + do_check_true(code < 128); + checkCount++; + }); + + _("Processed", checkCount, "characters out of", serialized.length); + do_check_eq(checkCount, serialized.length); + + _("Making sure the record still looks like it did before"); + record.decrypt(keyBundle); + do_check_eq(record.id, "ascii"); + do_check_eq(record.name, "wéävê"); + + _("Sanity check that creating the record also gives the same"); + record = engine._createRecord("ascii"); + do_check_eq(record.id, "ascii"); + do_check_eq(record.name, "wéävê"); + } finally { + Svc.Prefs.resetBranch(""); + } +} diff --git a/services/sync/tests/unit/test_collection_getBatched.js b/services/sync/tests/unit/test_collection_getBatched.js new file mode 100644 index 0000000000..c6523d497a --- /dev/null +++ b/services/sync/tests/unit/test_collection_getBatched.js @@ -0,0 +1,195 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +Cu.import("resource://services-sync/record.js"); +Cu.import("resource://services-sync/service.js"); + +function run_test() { + initTestLogging("Trace"); + Log.repository.getLogger("Sync.Collection").level = Log.Level.Trace; + run_next_test(); +} + +function recordRange(lim, offset, total) { + let res = []; + for (let i = offset; i < Math.min(lim + offset, total); ++i) { + res.push(JSON.stringify({ id: String(i), payload: "test:" + i })); + } + return res.join("\n") + "\n"; +} + +function get_test_collection_info({ totalRecords, batchSize, lastModified, + throwAfter = Infinity, + interruptedAfter = Infinity }) { + let coll = new Collection("http://example.com/test/", WBORecord, Service); + coll.full = true; + let requests = []; + let responses = []; + let sawRecord = false; + coll.get = function() { + ok(!sawRecord); // make sure we call record handler after all requests. + let limit = +this.limit; + let offset = 0; + if (this.offset) { + equal(this.offset.slice(0, 6), "foobar"); + offset = +this.offset.slice(6); + } + requests.push({ + limit, + offset, + spec: this.spec, + headers: Object.assign({}, this.headers) + }); + if (--throwAfter === 0) { + throw "Some Network Error"; + } + let body = recordRange(limit, offset, totalRecords); + this._onProgress.call({ _data: body }); + let response = { + body, + success: true, + status: 200, + headers: {} + }; + if (--interruptedAfter === 0) { + response.success = false; + response.status = 412; + response.body = ""; + } else if (offset + limit < totalRecords) { + // Ensure we're treating this as an opaque string, since the docs say + // it might not be numeric. + response.headers["x-weave-next-offset"] = "foobar" + (offset + batchSize); + } + response.headers["x-last-modified"] = lastModified; + responses.push(response); + return response; + }; + + let records = []; + coll.recordHandler = function(record) { + sawRecord = true; + // ensure records are coming in in the right order + equal(record.id, String(records.length)); + equal(record.payload, "test:" + records.length); + records.push(record); + }; + return { records, responses, requests, coll }; +} + +add_test(function test_success() { + const totalRecords = 11; + const batchSize = 2; + const lastModified = "111111"; + let { records, responses, requests, coll } = get_test_collection_info({ + totalRecords, + batchSize, + lastModified, + }); + let response = coll.getBatched(batchSize); + + equal(requests.length, Math.ceil(totalRecords / batchSize)); + + // records are mostly checked in recordHandler, we just care about the length + equal(records.length, totalRecords); + + // ensure we're returning the last response + equal(responses[responses.length - 1], response); + + // check first separately since its a bit of a special case + ok(!requests[0].headers["x-if-unmodified-since"]); + ok(!requests[0].offset); + equal(requests[0].limit, batchSize); + let expectedOffset = 2; + for (let i = 1; i < requests.length; ++i) { + let req = requests[i]; + equal(req.headers["x-if-unmodified-since"], lastModified); + equal(req.limit, batchSize); + if (i !== requests.length - 1) { + equal(req.offset, expectedOffset); + } + + expectedOffset += batchSize; + } + + // ensure we cleaned up anything that would break further + // use of this collection. + ok(!coll._headers["x-if-unmodified-since"]); + ok(!coll.offset); + ok(!coll.limit || (coll.limit == Infinity)); + + run_next_test(); +}); + +add_test(function test_total_limit() { + _("getBatched respects the (initial) value of the limit property"); + const totalRecords = 100; + const recordLimit = 11; + const batchSize = 2; + const lastModified = "111111"; + let { records, responses, requests, coll } = get_test_collection_info({ + totalRecords, + batchSize, + lastModified, + }); + coll.limit = recordLimit; + let response = coll.getBatched(batchSize); + + equal(requests.length, Math.ceil(recordLimit / batchSize)); + equal(records.length, recordLimit); + + for (let i = 0; i < requests.length; ++i) { + let req = requests[i]; + if (i !== requests.length - 1) { + equal(req.limit, batchSize); + } else { + equal(req.limit, recordLimit % batchSize); + } + } + + equal(coll._limit, recordLimit); + + run_next_test(); +}); + +add_test(function test_412() { + _("We shouldn't record records if we get a 412 in the middle of a batch"); + const totalRecords = 11; + const batchSize = 2; + const lastModified = "111111"; + let { records, responses, requests, coll } = get_test_collection_info({ + totalRecords, + batchSize, + lastModified, + interruptedAfter: 3 + }); + let response = coll.getBatched(batchSize); + + equal(requests.length, 3); + equal(records.length, 0); // record handler shouldn't be called for anything + + // ensure we're returning the last response + equal(responses[responses.length - 1], response); + + ok(!response.success); + equal(response.status, 412); + run_next_test(); +}); + +add_test(function test_get_throws() { + _("We shouldn't record records if get() throws for some reason"); + const totalRecords = 11; + const batchSize = 2; + const lastModified = "111111"; + let { records, responses, requests, coll } = get_test_collection_info({ + totalRecords, + batchSize, + lastModified, + throwAfter: 3 + }); + + throws(() => coll.getBatched(batchSize), "Some Network Error"); + + equal(requests.length, 3); + equal(records.length, 0); + run_next_test(); +}); diff --git a/services/sync/tests/unit/test_collection_inc_get.js b/services/sync/tests/unit/test_collection_inc_get.js new file mode 100644 index 0000000000..7747c0ef3e --- /dev/null +++ b/services/sync/tests/unit/test_collection_inc_get.js @@ -0,0 +1,188 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +_("Make sure Collection can correctly incrementally parse GET requests"); +Cu.import("resource://services-sync/record.js"); +Cu.import("resource://services-sync/service.js"); + +function run_test() { + let base = "http://fake/"; + let coll = new Collection("http://fake/uri/", WBORecord, Service); + let stream = { _data: "" }; + let called, recCount, sum; + + _("Not-JSON, string payloads are strings"); + called = false; + stream._data = '{"id":"hello","payload":"world"}\n'; + coll.recordHandler = function(rec) { + called = true; + _("Got record:", JSON.stringify(rec)); + rec.collection = "uri"; // This would be done by an engine, so do it here. + do_check_eq(rec.collection, "uri"); + do_check_eq(rec.id, "hello"); + do_check_eq(rec.uri(base).spec, "http://fake/uri/hello"); + do_check_eq(rec.payload, "world"); + }; + coll._onProgress.call(stream); + do_check_eq(stream._data, ''); + do_check_true(called); + _("\n"); + + + _("Parse record with payload"); + called = false; + stream._data = '{"payload":"{\\"value\\":123}"}\n'; + coll.recordHandler = function(rec) { + called = true; + _("Got record:", JSON.stringify(rec)); + do_check_eq(rec.payload.value, 123); + }; + coll._onProgress.call(stream); + do_check_eq(stream._data, ''); + do_check_true(called); + _("\n"); + + + _("Parse multiple records in one go"); + called = false; + recCount = 0; + sum = 0; + stream._data = '{"id":"hundred","payload":"{\\"value\\":100}"}\n{"id":"ten","payload":"{\\"value\\":10}"}\n{"id":"one","payload":"{\\"value\\":1}"}\n'; + coll.recordHandler = function(rec) { + called = true; + _("Got record:", JSON.stringify(rec)); + recCount++; + sum += rec.payload.value; + _("Incremental status: count", recCount, "sum", sum); + rec.collection = "uri"; + switch (recCount) { + case 1: + do_check_eq(rec.id, "hundred"); + do_check_eq(rec.uri(base).spec, "http://fake/uri/hundred"); + do_check_eq(rec.payload.value, 100); + do_check_eq(sum, 100); + break; + case 2: + do_check_eq(rec.id, "ten"); + do_check_eq(rec.uri(base).spec, "http://fake/uri/ten"); + do_check_eq(rec.payload.value, 10); + do_check_eq(sum, 110); + break; + case 3: + do_check_eq(rec.id, "one"); + do_check_eq(rec.uri(base).spec, "http://fake/uri/one"); + do_check_eq(rec.payload.value, 1); + do_check_eq(sum, 111); + break; + default: + do_throw("unexpected number of record counts", recCount); + break; + } + }; + coll._onProgress.call(stream); + do_check_eq(recCount, 3); + do_check_eq(sum, 111); + do_check_eq(stream._data, ''); + do_check_true(called); + _("\n"); + + + _("Handle incremental data incoming"); + called = false; + recCount = 0; + sum = 0; + stream._data = '{"payl'; + coll.recordHandler = function(rec) { + called = true; + do_throw("shouldn't have gotten a record.."); + }; + coll._onProgress.call(stream); + _("shouldn't have gotten anything yet"); + do_check_eq(recCount, 0); + do_check_eq(sum, 0); + _("leading array bracket should have been trimmed"); + do_check_eq(stream._data, '{"payl'); + do_check_false(called); + _(); + + _("adding more data enough for one record.."); + called = false; + stream._data += 'oad":"{\\"value\\":100}"}\n'; + coll.recordHandler = function(rec) { + called = true; + _("Got record:", JSON.stringify(rec)); + recCount++; + sum += rec.payload.value; + }; + coll._onProgress.call(stream); + _("should have 1 record with sum 100"); + do_check_eq(recCount, 1); + do_check_eq(sum, 100); + _("all data should have been consumed including trailing comma"); + do_check_eq(stream._data, ''); + do_check_true(called); + _(); + + _("adding more data.."); + called = false; + stream._data += '{"payload":"{\\"value\\":10}"'; + coll.recordHandler = function(rec) { + called = true; + do_throw("shouldn't have gotten a record.."); + }; + coll._onProgress.call(stream); + _("should still have 1 record with sum 100"); + do_check_eq(recCount, 1); + do_check_eq(sum, 100); + _("should almost have a record"); + do_check_eq(stream._data, '{"payload":"{\\"value\\":10}"'); + do_check_false(called); + _(); + + _("add data for two records.."); + called = false; + stream._data += '}\n{"payload":"{\\"value\\":1}"}\n'; + coll.recordHandler = function(rec) { + called = true; + _("Got record:", JSON.stringify(rec)); + recCount++; + sum += rec.payload.value; + switch (recCount) { + case 2: + do_check_eq(rec.payload.value, 10); + do_check_eq(sum, 110); + break; + case 3: + do_check_eq(rec.payload.value, 1); + do_check_eq(sum, 111); + break; + default: + do_throw("unexpected number of record counts", recCount); + break; + } + }; + coll._onProgress.call(stream); + _("should have gotten all 3 records with sum 111"); + do_check_eq(recCount, 3); + do_check_eq(sum, 111); + _("should have consumed all data"); + do_check_eq(stream._data, ''); + do_check_true(called); + _(); + + _("add no extra data"); + called = false; + stream._data += ''; + coll.recordHandler = function(rec) { + called = true; + do_throw("shouldn't have gotten a record.."); + }; + coll._onProgress.call(stream); + _("should still have 3 records with sum 111"); + do_check_eq(recCount, 3); + do_check_eq(sum, 111); + _("should have consumed nothing but still have nothing"); + do_check_eq(stream._data, ""); + do_check_false(called); + _("\n"); +} diff --git a/services/sync/tests/unit/test_collections_recovery.js b/services/sync/tests/unit/test_collections_recovery.js new file mode 100644 index 0000000000..0e7f546766 --- /dev/null +++ b/services/sync/tests/unit/test_collections_recovery.js @@ -0,0 +1,85 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Verify that we wipe the server if we have to regenerate keys. +Cu.import("resource://services-sync/service.js"); +Cu.import("resource://services-sync/util.js"); +Cu.import("resource://testing-common/services/sync/utils.js"); + +add_identity_test(this, function* test_missing_crypto_collection() { + let johnHelper = track_collections_helper(); + let johnU = johnHelper.with_updated_collection; + let johnColls = johnHelper.collections; + + let empty = false; + function maybe_empty(handler) { + return function (request, response) { + if (empty) { + let body = "{}"; + response.setStatusLine(request.httpVersion, 200, "OK"); + response.bodyOutputStream.write(body, body.length); + } else { + handler(request, response); + } + }; + } + + yield configureIdentity({username: "johndoe"}); + + let handlers = { + "/1.1/johndoe/info/collections": maybe_empty(johnHelper.handler), + "/1.1/johndoe/storage/crypto/keys": johnU("crypto", new ServerWBO("keys").handler()), + "/1.1/johndoe/storage/meta/global": johnU("meta", new ServerWBO("global").handler()) + }; + let collections = ["clients", "bookmarks", "forms", "history", + "passwords", "prefs", "tabs"]; + // Disable addon sync because AddonManager won't be initialized here. + Service.engineManager.unregister("addons"); + + for (let coll of collections) { + handlers["/1.1/johndoe/storage/" + coll] = + johnU(coll, new ServerCollection({}, true).handler()); + } + let server = httpd_setup(handlers); + Service.serverURL = server.baseURI; + + try { + let fresh = 0; + let orig = Service._freshStart; + Service._freshStart = function() { + _("Called _freshStart."); + orig.call(Service); + fresh++; + }; + + _("Startup, no meta/global: freshStart called once."); + yield sync_and_validate_telem(); + do_check_eq(fresh, 1); + fresh = 0; + + _("Regular sync: no need to freshStart."); + Service.sync(); + do_check_eq(fresh, 0); + + _("Simulate a bad info/collections."); + delete johnColls.crypto; + yield sync_and_validate_telem(); + do_check_eq(fresh, 1); + fresh = 0; + + _("Regular sync: no need to freshStart."); + yield sync_and_validate_telem(); + do_check_eq(fresh, 0); + + } finally { + Svc.Prefs.resetBranch(""); + let deferred = Promise.defer(); + server.stop(deferred.resolve); + yield deferred.promise; + } +}); + +function run_test() { + initTestLogging("Trace"); + run_next_test(); +} diff --git a/services/sync/tests/unit/test_corrupt_keys.js b/services/sync/tests/unit/test_corrupt_keys.js new file mode 100644 index 0000000000..009461c2a9 --- /dev/null +++ b/services/sync/tests/unit/test_corrupt_keys.js @@ -0,0 +1,233 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +Cu.import("resource://gre/modules/PlacesUtils.jsm"); +Cu.import("resource://gre/modules/Log.jsm"); +Cu.import("resource://services-sync/constants.js"); +Cu.import("resource://services-sync/engines.js"); +Cu.import("resource://services-sync/engines/tabs.js"); +Cu.import("resource://services-sync/engines/history.js"); +Cu.import("resource://services-sync/record.js"); +Cu.import("resource://services-sync/service.js"); +Cu.import("resource://services-sync/status.js"); +Cu.import("resource://services-sync/util.js"); +Cu.import("resource://testing-common/services/sync/utils.js"); +Cu.import("resource://gre/modules/Promise.jsm"); + +add_task(function* test_locally_changed_keys() { + let passphrase = "abcdeabcdeabcdeabcdeabcdea"; + + let hmacErrorCount = 0; + function counting(f) { + return function() { + hmacErrorCount++; + return f.call(this); + }; + } + + Service.handleHMACEvent = counting(Service.handleHMACEvent); + + let server = new SyncServer(); + let johndoe = server.registerUser("johndoe", "password"); + johndoe.createContents({ + meta: {}, + crypto: {}, + clients: {} + }); + server.start(); + + try { + Svc.Prefs.set("registerEngines", "Tab"); + _("Set up some tabs."); + let myTabs = + {windows: [{tabs: [{index: 1, + entries: [{ + url: "http://foo.com/", + title: "Title" + }], + attributes: { + image: "image" + } + }]}]}; + delete Svc.Session; + Svc.Session = { + getBrowserState: () => JSON.stringify(myTabs) + }; + + setBasicCredentials("johndoe", "password", passphrase); + Service.serverURL = server.baseURI; + Service.clusterURL = server.baseURI; + + Service.engineManager.register(HistoryEngine); + Service.engineManager.unregister("addons"); + + function corrupt_local_keys() { + Service.collectionKeys._default.keyPair = [Svc.Crypto.generateRandomKey(), + Svc.Crypto.generateRandomKey()]; + } + + _("Setting meta."); + + // Bump version on the server. + let m = new WBORecord("meta", "global"); + m.payload = {"syncID": "foooooooooooooooooooooooooo", + "storageVersion": STORAGE_VERSION}; + m.upload(Service.resource(Service.metaURL)); + + _("New meta/global: " + JSON.stringify(johndoe.collection("meta").wbo("global"))); + + // Upload keys. + generateNewKeys(Service.collectionKeys); + let serverKeys = Service.collectionKeys.asWBO("crypto", "keys"); + serverKeys.encrypt(Service.identity.syncKeyBundle); + do_check_true(serverKeys.upload(Service.resource(Service.cryptoKeysURL)).success); + + // Check that login works. + do_check_true(Service.login("johndoe", "ilovejane", passphrase)); + do_check_true(Service.isLoggedIn); + + // Sync should upload records. + yield sync_and_validate_telem(); + + // Tabs exist. + _("Tabs modified: " + johndoe.modified("tabs")); + do_check_true(johndoe.modified("tabs") > 0); + + let coll_modified = Service.collectionKeys.lastModified; + + // Let's create some server side history records. + let liveKeys = Service.collectionKeys.keyForCollection("history"); + _("Keys now: " + liveKeys.keyPair); + let visitType = Ci.nsINavHistoryService.TRANSITION_LINK; + let history = johndoe.createCollection("history"); + for (let i = 0; i < 5; i++) { + let id = 'record-no--' + i; + let modified = Date.now()/1000 - 60*(i+10); + + let w = new CryptoWrapper("history", "id"); + w.cleartext = { + id: id, + histUri: "http://foo/bar?" + id, + title: id, + sortindex: i, + visits: [{date: (modified - 5) * 1000000, type: visitType}], + deleted: false}; + w.encrypt(liveKeys); + + let payload = {ciphertext: w.ciphertext, + IV: w.IV, + hmac: w.hmac}; + history.insert(id, payload, modified); + } + + history.timestamp = Date.now() / 1000; + let old_key_time = johndoe.modified("crypto"); + _("Old key time: " + old_key_time); + + // Check that we can decrypt one. + let rec = new CryptoWrapper("history", "record-no--0"); + rec.fetch(Service.resource(Service.storageURL + "history/record-no--0")); + _(JSON.stringify(rec)); + do_check_true(!!rec.decrypt(liveKeys)); + + do_check_eq(hmacErrorCount, 0); + + // Fill local key cache with bad data. + corrupt_local_keys(); + _("Keys now: " + Service.collectionKeys.keyForCollection("history").keyPair); + + do_check_eq(hmacErrorCount, 0); + + _("HMAC error count: " + hmacErrorCount); + // Now syncing should succeed, after one HMAC error. + let ping = yield wait_for_ping(() => Service.sync(), true); + equal(ping.engines.find(e => e.name == "history").incoming.applied, 5); + + do_check_eq(hmacErrorCount, 1); + _("Keys now: " + Service.collectionKeys.keyForCollection("history").keyPair); + + // And look! We downloaded history! + let store = Service.engineManager.get("history")._store; + do_check_true(yield promiseIsURIVisited("http://foo/bar?record-no--0")); + do_check_true(yield promiseIsURIVisited("http://foo/bar?record-no--1")); + do_check_true(yield promiseIsURIVisited("http://foo/bar?record-no--2")); + do_check_true(yield promiseIsURIVisited("http://foo/bar?record-no--3")); + do_check_true(yield promiseIsURIVisited("http://foo/bar?record-no--4")); + do_check_eq(hmacErrorCount, 1); + + _("Busting some new server values."); + // Now what happens if we corrupt the HMAC on the server? + for (let i = 5; i < 10; i++) { + let id = 'record-no--' + i; + let modified = 1 + (Date.now() / 1000); + + let w = new CryptoWrapper("history", "id"); + w.cleartext = { + id: id, + histUri: "http://foo/bar?" + id, + title: id, + sortindex: i, + visits: [{date: (modified - 5 ) * 1000000, type: visitType}], + deleted: false}; + w.encrypt(Service.collectionKeys.keyForCollection("history")); + w.hmac = w.hmac.toUpperCase(); + + let payload = {ciphertext: w.ciphertext, + IV: w.IV, + hmac: w.hmac}; + history.insert(id, payload, modified); + } + history.timestamp = Date.now() / 1000; + + _("Server key time hasn't changed."); + do_check_eq(johndoe.modified("crypto"), old_key_time); + + _("Resetting HMAC error timer."); + Service.lastHMACEvent = 0; + + _("Syncing..."); + ping = yield sync_and_validate_telem(true); + + do_check_eq(ping.engines.find(e => e.name == "history").incoming.failed, 5); + _("Keys now: " + Service.collectionKeys.keyForCollection("history").keyPair); + _("Server keys have been updated, and we skipped over 5 more HMAC errors without adjusting history."); + do_check_true(johndoe.modified("crypto") > old_key_time); + do_check_eq(hmacErrorCount, 6); + do_check_false(yield promiseIsURIVisited("http://foo/bar?record-no--5")); + do_check_false(yield promiseIsURIVisited("http://foo/bar?record-no--6")); + do_check_false(yield promiseIsURIVisited("http://foo/bar?record-no--7")); + do_check_false(yield promiseIsURIVisited("http://foo/bar?record-no--8")); + do_check_false(yield promiseIsURIVisited("http://foo/bar?record-no--9")); + } finally { + Svc.Prefs.resetBranch(""); + let deferred = Promise.defer(); + server.stop(deferred.resolve); + yield deferred.promise; + } +}); + +function run_test() { + let logger = Log.repository.rootLogger; + Log.repository.rootLogger.addAppender(new Log.DumpAppender()); + validate_all_future_pings(); + + ensureLegacyIdentityManager(); + + run_next_test(); +} + +/** + * Asynchronously check a url is visited. + * @param url the url + * @return {Promise} + * @resolves When the check has been added successfully. + * @rejects JavaScript exception. + */ +function promiseIsURIVisited(url) { + let deferred = Promise.defer(); + PlacesUtils.asyncHistory.isURIVisited(Utils.makeURI(url), function(aURI, aIsVisited) { + deferred.resolve(aIsVisited); + }); + + return deferred.promise; +} diff --git a/services/sync/tests/unit/test_declined.js b/services/sync/tests/unit/test_declined.js new file mode 100644 index 0000000000..e9e9b002a1 --- /dev/null +++ b/services/sync/tests/unit/test_declined.js @@ -0,0 +1,153 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +Cu.import("resource://services-sync/stages/declined.js"); +Cu.import("resource://services-sync/stages/enginesync.js"); +Cu.import("resource://services-sync/engines.js"); +Cu.import("resource://services-sync/service.js"); +Cu.import("resource://services-common/observers.js"); + +function run_test() { + run_next_test(); +} + +function PetrolEngine() {} +PetrolEngine.prototype.name = "petrol"; + +function DieselEngine() {} +DieselEngine.prototype.name = "diesel"; + +function DummyEngine() {} +DummyEngine.prototype.name = "dummy"; + +function ActualEngine() {} +ActualEngine.prototype = {__proto__: Engine.prototype, + name: 'actual'}; + +function getEngineManager() { + let manager = new EngineManager(Service); + Service.engineManager = manager; + manager._engines = { + "petrol": new PetrolEngine(), + "diesel": new DieselEngine(), + "dummy": new DummyEngine(), + "actual": new ActualEngine(), + }; + return manager; +} + +/** + * 'Fetch' a meta/global record that doesn't mention declined. + * + * Push it into the EngineSynchronizer to set enabled; verify that those are + * correct. + * + * Then push it into DeclinedEngines to set declined; verify that none are + * declined, and a notification is sent for our locally disabled-but-not- + * declined engines. + */ +add_test(function testOldMeta() { + let meta = { + payload: { + engines: { + "petrol": 1, + "diesel": 2, + "nonlocal": 3, // Enabled but not supported. + }, + }, + }; + + _("Record: " + JSON.stringify(meta)); + + let manager = getEngineManager(); + + // Update enabled from meta/global. + let engineSync = new EngineSynchronizer(Service); + engineSync._updateEnabledFromMeta(meta, 3, manager); + + Assert.ok(manager._engines["petrol"].enabled, "'petrol' locally enabled."); + Assert.ok(manager._engines["diesel"].enabled, "'diesel' locally enabled."); + Assert.ok(!("nonlocal" in manager._engines), "We don't know anything about the 'nonlocal' engine."); + Assert.ok(!manager._engines["actual"].enabled, "'actual' not locally enabled."); + Assert.ok(!manager.isDeclined("actual"), "'actual' not declined, though."); + + let declinedEngines = new DeclinedEngines(Service); + + function onNotDeclined(subject, topic, data) { + Observers.remove("weave:engines:notdeclined", onNotDeclined); + Assert.ok(subject.undecided.has("actual"), "EngineManager observed that 'actual' was undecided."); + + let declined = manager.getDeclined(); + _("Declined: " + JSON.stringify(declined)); + + Assert.ok(!meta.changed, "No need to upload a new meta/global."); + run_next_test(); + } + + Observers.add("weave:engines:notdeclined", onNotDeclined); + + declinedEngines.updateDeclined(meta, manager); +}); + +/** + * 'Fetch' a meta/global that declines an engine we don't + * recognize. Ensure that we track that declined engine along + * with any we locally declined, and that the meta/global + * record is marked as changed and includes all declined + * engines. + */ +add_test(function testDeclinedMeta() { + let meta = { + payload: { + engines: { + "petrol": 1, + "diesel": 2, + "nonlocal": 3, // Enabled but not supported. + }, + declined: ["nonexistent"], // Declined and not supported. + }, + }; + + _("Record: " + JSON.stringify(meta)); + + let manager = getEngineManager(); + manager._engines["petrol"].enabled = true; + manager._engines["diesel"].enabled = true; + manager._engines["dummy"].enabled = true; + manager._engines["actual"].enabled = false; // Disabled but not declined. + + manager.decline(["localdecline"]); // Declined and not supported. + + let declinedEngines = new DeclinedEngines(Service); + + function onNotDeclined(subject, topic, data) { + Observers.remove("weave:engines:notdeclined", onNotDeclined); + Assert.ok(subject.undecided.has("actual"), "EngineManager observed that 'actual' was undecided."); + + let declined = manager.getDeclined(); + _("Declined: " + JSON.stringify(declined)); + + Assert.equal(declined.indexOf("actual"), -1, "'actual' is locally disabled, but not marked as declined."); + + Assert.equal(declined.indexOf("clients"), -1, "'clients' is enabled and not remotely declined."); + Assert.equal(declined.indexOf("petrol"), -1, "'petrol' is enabled and not remotely declined."); + Assert.equal(declined.indexOf("diesel"), -1, "'diesel' is enabled and not remotely declined."); + Assert.equal(declined.indexOf("dummy"), -1, "'dummy' is enabled and not remotely declined."); + + Assert.ok(0 <= declined.indexOf("nonexistent"), "'nonexistent' was declined on the server."); + + Assert.ok(0 <= declined.indexOf("localdecline"), "'localdecline' was declined locally."); + + // The meta/global is modified, too. + Assert.ok(0 <= meta.payload.declined.indexOf("nonexistent"), "meta/global's declined contains 'nonexistent'."); + Assert.ok(0 <= meta.payload.declined.indexOf("localdecline"), "meta/global's declined contains 'localdecline'."); + Assert.strictEqual(true, meta.changed, "meta/global was changed."); + + run_next_test(); + } + + Observers.add("weave:engines:notdeclined", onNotDeclined); + + declinedEngines.updateDeclined(meta, manager); +}); + diff --git a/services/sync/tests/unit/test_engine.js b/services/sync/tests/unit/test_engine.js new file mode 100644 index 0000000000..be637efc87 --- /dev/null +++ b/services/sync/tests/unit/test_engine.js @@ -0,0 +1,219 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +Cu.import("resource://services-common/observers.js"); +Cu.import("resource://services-sync/engines.js"); +Cu.import("resource://services-sync/service.js"); +Cu.import("resource://services-sync/util.js"); + +function SteamStore(engine) { + Store.call(this, "Steam", engine); + this.wasWiped = false; +} +SteamStore.prototype = { + __proto__: Store.prototype, + + wipe: function() { + this.wasWiped = true; + } +}; + +function SteamTracker(name, engine) { + Tracker.call(this, name || "Steam", engine); +} +SteamTracker.prototype = { + __proto__: Tracker.prototype +}; + +function SteamEngine(name, service) { + Engine.call(this, name, service); + this.wasReset = false; + this.wasSynced = false; +} +SteamEngine.prototype = { + __proto__: Engine.prototype, + _storeObj: SteamStore, + _trackerObj: SteamTracker, + + _resetClient: function () { + this.wasReset = true; + }, + + _sync: function () { + this.wasSynced = true; + } +}; + +var engineObserver = { + topics: [], + + observe: function(subject, topic, data) { + do_check_eq(data, "steam"); + this.topics.push(topic); + }, + + reset: function() { + this.topics = []; + } +}; +Observers.add("weave:engine:reset-client:start", engineObserver); +Observers.add("weave:engine:reset-client:finish", engineObserver); +Observers.add("weave:engine:wipe-client:start", engineObserver); +Observers.add("weave:engine:wipe-client:finish", engineObserver); +Observers.add("weave:engine:sync:start", engineObserver); +Observers.add("weave:engine:sync:finish", engineObserver); + +function run_test() { + run_next_test(); +} + +add_test(function test_members() { + _("Engine object members"); + let engine = new SteamEngine("Steam", Service); + do_check_eq(engine.Name, "Steam"); + do_check_eq(engine.prefName, "steam"); + do_check_true(engine._store instanceof SteamStore); + do_check_true(engine._tracker instanceof SteamTracker); + run_next_test(); +}); + +add_test(function test_score() { + _("Engine.score corresponds to tracker.score and is readonly"); + let engine = new SteamEngine("Steam", Service); + do_check_eq(engine.score, 0); + engine._tracker.score += 5; + do_check_eq(engine.score, 5); + + try { + engine.score = 10; + } catch(ex) { + // Setting an attribute that has a getter produces an error in + // Firefox <= 3.6 and is ignored in later versions. Either way, + // the attribute's value won't change. + } + do_check_eq(engine.score, 5); + run_next_test(); +}); + +add_test(function test_resetClient() { + _("Engine.resetClient calls _resetClient"); + let engine = new SteamEngine("Steam", Service); + do_check_false(engine.wasReset); + + engine.resetClient(); + do_check_true(engine.wasReset); + do_check_eq(engineObserver.topics[0], "weave:engine:reset-client:start"); + do_check_eq(engineObserver.topics[1], "weave:engine:reset-client:finish"); + + engine.wasReset = false; + engineObserver.reset(); + run_next_test(); +}); + +add_test(function test_invalidChangedIDs() { + _("Test that invalid changed IDs on disk don't end up live."); + let engine = new SteamEngine("Steam", Service); + let tracker = engine._tracker; + tracker.changedIDs = 5; + tracker.saveChangedIDs(function onSaved() { + tracker.changedIDs = {placeholder: true}; + tracker.loadChangedIDs(function onLoaded(json) { + do_check_null(json); + do_check_true(tracker.changedIDs.placeholder); + run_next_test(); + }); + }); +}); + +add_test(function test_wipeClient() { + _("Engine.wipeClient calls resetClient, wipes store, clears changed IDs"); + let engine = new SteamEngine("Steam", Service); + do_check_false(engine.wasReset); + do_check_false(engine._store.wasWiped); + do_check_true(engine._tracker.addChangedID("a-changed-id")); + do_check_true("a-changed-id" in engine._tracker.changedIDs); + + engine.wipeClient(); + do_check_true(engine.wasReset); + do_check_true(engine._store.wasWiped); + do_check_eq(JSON.stringify(engine._tracker.changedIDs), "{}"); + do_check_eq(engineObserver.topics[0], "weave:engine:wipe-client:start"); + do_check_eq(engineObserver.topics[1], "weave:engine:reset-client:start"); + do_check_eq(engineObserver.topics[2], "weave:engine:reset-client:finish"); + do_check_eq(engineObserver.topics[3], "weave:engine:wipe-client:finish"); + + engine.wasReset = false; + engine._store.wasWiped = false; + engineObserver.reset(); + run_next_test(); +}); + +add_test(function test_enabled() { + _("Engine.enabled corresponds to preference"); + let engine = new SteamEngine("Steam", Service); + try { + do_check_false(engine.enabled); + Svc.Prefs.set("engine.steam", true); + do_check_true(engine.enabled); + + engine.enabled = false; + do_check_false(Svc.Prefs.get("engine.steam")); + run_next_test(); + } finally { + Svc.Prefs.resetBranch(""); + } +}); + +add_test(function test_sync() { + let engine = new SteamEngine("Steam", Service); + try { + _("Engine.sync doesn't call _sync if it's not enabled"); + do_check_false(engine.enabled); + do_check_false(engine.wasSynced); + engine.sync(); + + do_check_false(engine.wasSynced); + + _("Engine.sync calls _sync if it's enabled"); + engine.enabled = true; + + engine.sync(); + do_check_true(engine.wasSynced); + do_check_eq(engineObserver.topics[0], "weave:engine:sync:start"); + do_check_eq(engineObserver.topics[1], "weave:engine:sync:finish"); + run_next_test(); + } finally { + Svc.Prefs.resetBranch(""); + engine.wasSynced = false; + engineObserver.reset(); + } +}); + +add_test(function test_disabled_no_track() { + _("When an engine is disabled, its tracker is not tracking."); + let engine = new SteamEngine("Steam", Service); + let tracker = engine._tracker; + do_check_eq(engine, tracker.engine); + + do_check_false(engine.enabled); + do_check_false(tracker._isTracking); + do_check_empty(tracker.changedIDs); + + do_check_false(tracker.engineIsEnabled()); + tracker.observe(null, "weave:engine:start-tracking", null); + do_check_false(tracker._isTracking); + do_check_empty(tracker.changedIDs); + + engine.enabled = true; + tracker.observe(null, "weave:engine:start-tracking", null); + do_check_true(tracker._isTracking); + do_check_empty(tracker.changedIDs); + + tracker.addChangedID("abcdefghijkl"); + do_check_true(0 < tracker.changedIDs["abcdefghijkl"]); + Svc.Prefs.set("engine." + engine.prefName, false); + do_check_false(tracker._isTracking); + do_check_empty(tracker.changedIDs); + + run_next_test(); +}); diff --git a/services/sync/tests/unit/test_engine_abort.js b/services/sync/tests/unit/test_engine_abort.js new file mode 100644 index 0000000000..8ec8664435 --- /dev/null +++ b/services/sync/tests/unit/test_engine_abort.js @@ -0,0 +1,69 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +Cu.import("resource://services-sync/engines.js"); +Cu.import("resource://services-sync/record.js"); +Cu.import("resource://services-sync/service.js"); +Cu.import("resource://services-sync/util.js"); +Cu.import("resource://testing-common/services/sync/rotaryengine.js"); +Cu.import("resource://testing-common/services/sync/utils.js"); + +add_test(function test_processIncoming_abort() { + _("An abort exception, raised in applyIncoming, will abort _processIncoming."); + let engine = new RotaryEngine(Service); + + let collection = new ServerCollection(); + let id = Utils.makeGUID(); + let payload = encryptPayload({id: id, denomination: "Record No. " + id}); + collection.insert(id, payload); + + let server = sync_httpd_setup({ + "/1.1/foo/storage/rotary": collection.handler() + }); + + new SyncTestingInfrastructure(server); + generateNewKeys(Service.collectionKeys); + + _("Create some server data."); + let meta_global = Service.recordManager.set(engine.metaURL, + new WBORecord(engine.metaURL)); + meta_global.payload.engines = {rotary: {version: engine.version, + syncID: engine.syncID}}; + _("Fake applyIncoming to abort."); + engine._store.applyIncoming = function (record) { + let ex = {code: Engine.prototype.eEngineAbortApplyIncoming, + cause: "Nooo"}; + _("Throwing: " + JSON.stringify(ex)); + throw ex; + }; + + _("Trying _processIncoming. It will throw after aborting."); + let err; + try { + engine._syncStartup(); + engine._processIncoming(); + } catch (ex) { + err = ex; + } + + do_check_eq(err, "Nooo"); + err = undefined; + + _("Trying engine.sync(). It will abort without error."); + try { + // This will quietly fail. + engine.sync(); + } catch (ex) { + err = ex; + } + + do_check_eq(err, undefined); + + server.stop(run_next_test); + Svc.Prefs.resetBranch(""); + Service.recordManager.clearCache(); +}); + +function run_test() { + run_next_test(); +} diff --git a/services/sync/tests/unit/test_enginemanager.js b/services/sync/tests/unit/test_enginemanager.js new file mode 100644 index 0000000000..8917cc5bc0 --- /dev/null +++ b/services/sync/tests/unit/test_enginemanager.js @@ -0,0 +1,114 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +Cu.import("resource://services-sync/engines.js"); +Cu.import("resource://services-sync/service.js"); + +function run_test() { + run_next_test(); +} + +function PetrolEngine() {} +PetrolEngine.prototype.name = "petrol"; + +function DieselEngine() {} +DieselEngine.prototype.name = "diesel"; + +function DummyEngine() {} +DummyEngine.prototype.name = "dummy"; + +function ActualEngine() {} +ActualEngine.prototype = {__proto__: Engine.prototype, + name: 'actual'}; + +add_test(function test_basics() { + _("We start out with a clean slate"); + + let manager = new EngineManager(Service); + + let engines = manager.getAll(); + do_check_eq(engines.length, 0); + do_check_eq(manager.get('dummy'), undefined); + + _("Register an engine"); + manager.register(DummyEngine); + let dummy = manager.get('dummy'); + do_check_true(dummy instanceof DummyEngine); + + engines = manager.getAll(); + do_check_eq(engines.length, 1); + do_check_eq(engines[0], dummy); + + _("Register an already registered engine is ignored"); + manager.register(DummyEngine); + do_check_eq(manager.get('dummy'), dummy); + + _("Register multiple engines in one go"); + manager.register([PetrolEngine, DieselEngine]); + let petrol = manager.get('petrol'); + let diesel = manager.get('diesel'); + do_check_true(petrol instanceof PetrolEngine); + do_check_true(diesel instanceof DieselEngine); + + engines = manager.getAll(); + do_check_eq(engines.length, 3); + do_check_neq(engines.indexOf(petrol), -1); + do_check_neq(engines.indexOf(diesel), -1); + + _("Retrieve multiple engines in one go"); + engines = manager.get(["dummy", "diesel"]); + do_check_eq(engines.length, 2); + do_check_neq(engines.indexOf(dummy), -1); + do_check_neq(engines.indexOf(diesel), -1); + + _("getEnabled() only returns enabled engines"); + engines = manager.getEnabled(); + do_check_eq(engines.length, 0); + + petrol.enabled = true; + engines = manager.getEnabled(); + do_check_eq(engines.length, 1); + do_check_eq(engines[0], petrol); + + dummy.enabled = true; + diesel.enabled = true; + engines = manager.getEnabled(); + do_check_eq(engines.length, 3); + + _("getEnabled() returns enabled engines in sorted order"); + petrol.syncPriority = 1; + dummy.syncPriority = 2; + diesel.syncPriority = 3; + + engines = manager.getEnabled(); + + do_check_array_eq(engines, [petrol, dummy, diesel]); + + _("Changing the priorities should change the order in getEnabled()"); + + dummy.syncPriority = 4; + + engines = manager.getEnabled(); + + do_check_array_eq(engines, [petrol, diesel, dummy]); + + _("Unregister an engine by name"); + manager.unregister('dummy'); + do_check_eq(manager.get('dummy'), undefined); + engines = manager.getAll(); + do_check_eq(engines.length, 2); + do_check_eq(engines.indexOf(dummy), -1); + + _("Unregister an engine by value"); + // manager.unregister() checks for instanceof Engine, so let's make one: + manager.register(ActualEngine); + let actual = manager.get('actual'); + do_check_true(actual instanceof ActualEngine); + do_check_true(actual instanceof Engine); + + manager.unregister(actual); + do_check_eq(manager.get('actual'), undefined); + + run_next_test(); +}); + diff --git a/services/sync/tests/unit/test_errorhandler_1.js b/services/sync/tests/unit/test_errorhandler_1.js new file mode 100644 index 0000000000..ea2070b480 --- /dev/null +++ b/services/sync/tests/unit/test_errorhandler_1.js @@ -0,0 +1,913 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +Cu.import("resource://services-sync/engines/clients.js"); +Cu.import("resource://services-sync/constants.js"); +Cu.import("resource://services-sync/engines.js"); +Cu.import("resource://services-sync/keys.js"); +Cu.import("resource://services-sync/policies.js"); +Cu.import("resource://services-sync/service.js"); +Cu.import("resource://services-sync/status.js"); +Cu.import("resource://services-sync/util.js"); +Cu.import("resource://testing-common/services/sync/utils.js"); +Cu.import("resource://gre/modules/FileUtils.jsm"); + +var fakeServer = new SyncServer(); +fakeServer.start(); + +do_register_cleanup(function() { + return new Promise(resolve => { + fakeServer.stop(resolve); + }); +}); + +var fakeServerUrl = "http://localhost:" + fakeServer.port; + +const logsdir = FileUtils.getDir("ProfD", ["weave", "logs"], true); + +const PROLONGED_ERROR_DURATION = + (Svc.Prefs.get('errorhandler.networkFailureReportTimeout') * 2) * 1000; + +const NON_PROLONGED_ERROR_DURATION = + (Svc.Prefs.get('errorhandler.networkFailureReportTimeout') / 2) * 1000; + +Service.engineManager.clear(); + +function setLastSync(lastSyncValue) { + Svc.Prefs.set("lastSync", (new Date(Date.now() - lastSyncValue)).toString()); +} + +var engineManager = Service.engineManager; +engineManager.register(EHTestsCommon.CatapultEngine); + +// This relies on Service/ErrorHandler being a singleton. Fixing this will take +// a lot of work. +var errorHandler = Service.errorHandler; + +function run_test() { + initTestLogging("Trace"); + + Log.repository.getLogger("Sync.Service").level = Log.Level.Trace; + Log.repository.getLogger("Sync.SyncScheduler").level = Log.Level.Trace; + Log.repository.getLogger("Sync.ErrorHandler").level = Log.Level.Trace; + + ensureLegacyIdentityManager(); + + run_next_test(); +} + + +function clean() { + Service.startOver(); + Status.resetSync(); + Status.resetBackoff(); + errorHandler.didReportProlongedError = false; +} + +add_identity_test(this, function* test_401_logout() { + let server = EHTestsCommon.sync_httpd_setup(); + yield EHTestsCommon.setUp(server); + + // By calling sync, we ensure we're logged in. + yield sync_and_validate_telem(); + do_check_eq(Status.sync, SYNC_SUCCEEDED); + do_check_true(Service.isLoggedIn); + + let deferred = Promise.defer(); + Svc.Obs.add("weave:service:sync:error", onSyncError); + function onSyncError() { + _("Got weave:service:sync:error in first sync."); + Svc.Obs.remove("weave:service:sync:error", onSyncError); + + // Wait for the automatic next sync. + function onLoginError() { + _("Got weave:service:login:error in second sync."); + Svc.Obs.remove("weave:service:login:error", onLoginError); + + let expected = isConfiguredWithLegacyIdentity() ? + LOGIN_FAILED_LOGIN_REJECTED : LOGIN_FAILED_NETWORK_ERROR; + + do_check_eq(Status.login, expected); + do_check_false(Service.isLoggedIn); + + // Clean up. + Utils.nextTick(function () { + Service.startOver(); + server.stop(deferred.resolve); + }); + } + Svc.Obs.add("weave:service:login:error", onLoginError); + } + + // Make sync fail due to login rejected. + yield configureIdentity({username: "janedoe"}); + Service._updateCachedURLs(); + + _("Starting first sync."); + let ping = yield sync_and_validate_telem(true); + deepEqual(ping.failureReason, { name: "httperror", code: 401 }); + _("First sync done."); + yield deferred.promise; +}); + +add_identity_test(this, function* test_credentials_changed_logout() { + let server = EHTestsCommon.sync_httpd_setup(); + yield EHTestsCommon.setUp(server); + + // By calling sync, we ensure we're logged in. + yield sync_and_validate_telem(); + do_check_eq(Status.sync, SYNC_SUCCEEDED); + do_check_true(Service.isLoggedIn); + + EHTestsCommon.generateCredentialsChangedFailure(); + + let ping = yield sync_and_validate_telem(true); + equal(ping.status.sync, CREDENTIALS_CHANGED); + deepEqual(ping.failureReason, { + name: "unexpectederror", + error: "Error: Aborting sync, remote setup failed" + }); + + do_check_eq(Status.sync, CREDENTIALS_CHANGED); + do_check_false(Service.isLoggedIn); + + // Clean up. + Service.startOver(); + let deferred = Promise.defer(); + server.stop(deferred.resolve); + yield deferred.promise; +}); + +add_identity_test(this, function test_no_lastSync_pref() { + // Test reported error. + Status.resetSync(); + errorHandler.dontIgnoreErrors = true; + Status.sync = CREDENTIALS_CHANGED; + do_check_true(errorHandler.shouldReportError()); + + // Test unreported error. + Status.resetSync(); + errorHandler.dontIgnoreErrors = true; + Status.login = LOGIN_FAILED_NETWORK_ERROR; + do_check_true(errorHandler.shouldReportError()); + +}); + +add_identity_test(this, function test_shouldReportError() { + Status.login = MASTER_PASSWORD_LOCKED; + do_check_false(errorHandler.shouldReportError()); + + // Give ourselves a clusterURL so that the temporary 401 no-error situation + // doesn't come into play. + Service.serverURL = fakeServerUrl; + Service.clusterURL = fakeServerUrl; + + // Test dontIgnoreErrors, non-network, non-prolonged, login error reported + Status.resetSync(); + setLastSync(NON_PROLONGED_ERROR_DURATION); + errorHandler.dontIgnoreErrors = true; + Status.login = LOGIN_FAILED_NO_PASSWORD; + do_check_true(errorHandler.shouldReportError()); + + // Test dontIgnoreErrors, non-network, non-prolonged, sync error reported + Status.resetSync(); + setLastSync(NON_PROLONGED_ERROR_DURATION); + errorHandler.dontIgnoreErrors = true; + Status.sync = CREDENTIALS_CHANGED; + do_check_true(errorHandler.shouldReportError()); + + // Test dontIgnoreErrors, non-network, prolonged, login error reported + Status.resetSync(); + setLastSync(PROLONGED_ERROR_DURATION); + errorHandler.dontIgnoreErrors = true; + Status.login = LOGIN_FAILED_NO_PASSWORD; + do_check_true(errorHandler.shouldReportError()); + + // Test dontIgnoreErrors, non-network, prolonged, sync error reported + Status.resetSync(); + setLastSync(PROLONGED_ERROR_DURATION); + errorHandler.dontIgnoreErrors = true; + Status.sync = CREDENTIALS_CHANGED; + do_check_true(errorHandler.shouldReportError()); + + // Test dontIgnoreErrors, network, non-prolonged, login error reported + Status.resetSync(); + setLastSync(NON_PROLONGED_ERROR_DURATION); + errorHandler.dontIgnoreErrors = true; + Status.login = LOGIN_FAILED_NETWORK_ERROR; + do_check_true(errorHandler.shouldReportError()); + + // Test dontIgnoreErrors, network, non-prolonged, sync error reported + Status.resetSync(); + setLastSync(NON_PROLONGED_ERROR_DURATION); + errorHandler.dontIgnoreErrors = true; + Status.sync = LOGIN_FAILED_NETWORK_ERROR; + do_check_true(errorHandler.shouldReportError()); + + // Test dontIgnoreErrors, network, prolonged, login error reported + Status.resetSync(); + setLastSync(PROLONGED_ERROR_DURATION); + errorHandler.dontIgnoreErrors = true; + Status.login = LOGIN_FAILED_NETWORK_ERROR; + do_check_true(errorHandler.shouldReportError()); + + // Test dontIgnoreErrors, network, prolonged, sync error reported + Status.resetSync(); + setLastSync(PROLONGED_ERROR_DURATION); + errorHandler.dontIgnoreErrors = true; + Status.sync = LOGIN_FAILED_NETWORK_ERROR; + do_check_true(errorHandler.shouldReportError()); + + // Test non-network, prolonged, login error reported + do_check_false(errorHandler.didReportProlongedError); + Status.resetSync(); + setLastSync(PROLONGED_ERROR_DURATION); + errorHandler.dontIgnoreErrors = false; + Status.login = LOGIN_FAILED_NO_PASSWORD; + do_check_true(errorHandler.shouldReportError()); + do_check_true(errorHandler.didReportProlongedError); + + // Second time with prolonged error and without resetting + // didReportProlongedError, sync error should not be reported. + Status.resetSync(); + setLastSync(PROLONGED_ERROR_DURATION); + errorHandler.dontIgnoreErrors = false; + Status.login = LOGIN_FAILED_NO_PASSWORD; + do_check_false(errorHandler.shouldReportError()); + do_check_true(errorHandler.didReportProlongedError); + + // Test non-network, prolonged, sync error reported + Status.resetSync(); + setLastSync(PROLONGED_ERROR_DURATION); + errorHandler.dontIgnoreErrors = false; + errorHandler.didReportProlongedError = false; + Status.sync = CREDENTIALS_CHANGED; + do_check_true(errorHandler.shouldReportError()); + do_check_true(errorHandler.didReportProlongedError); + errorHandler.didReportProlongedError = false; + + // Test network, prolonged, login error reported + Status.resetSync(); + setLastSync(PROLONGED_ERROR_DURATION); + errorHandler.dontIgnoreErrors = false; + Status.login = LOGIN_FAILED_NETWORK_ERROR; + do_check_true(errorHandler.shouldReportError()); + do_check_true(errorHandler.didReportProlongedError); + errorHandler.didReportProlongedError = false; + + // Test network, prolonged, sync error reported + Status.resetSync(); + setLastSync(PROLONGED_ERROR_DURATION); + errorHandler.dontIgnoreErrors = false; + Status.sync = LOGIN_FAILED_NETWORK_ERROR; + do_check_true(errorHandler.shouldReportError()); + do_check_true(errorHandler.didReportProlongedError); + errorHandler.didReportProlongedError = false; + + // Test non-network, non-prolonged, login error reported + Status.resetSync(); + setLastSync(NON_PROLONGED_ERROR_DURATION); + errorHandler.dontIgnoreErrors = false; + Status.login = LOGIN_FAILED_NO_PASSWORD; + do_check_true(errorHandler.shouldReportError()); + do_check_false(errorHandler.didReportProlongedError); + + // Test non-network, non-prolonged, sync error reported + Status.resetSync(); + setLastSync(NON_PROLONGED_ERROR_DURATION); + errorHandler.dontIgnoreErrors = false; + Status.sync = CREDENTIALS_CHANGED; + do_check_true(errorHandler.shouldReportError()); + do_check_false(errorHandler.didReportProlongedError); + + // Test network, non-prolonged, login error reported + Status.resetSync(); + setLastSync(NON_PROLONGED_ERROR_DURATION); + errorHandler.dontIgnoreErrors = false; + Status.login = LOGIN_FAILED_NETWORK_ERROR; + do_check_false(errorHandler.shouldReportError()); + do_check_false(errorHandler.didReportProlongedError); + + // Test network, non-prolonged, sync error reported + Status.resetSync(); + setLastSync(NON_PROLONGED_ERROR_DURATION); + errorHandler.dontIgnoreErrors = false; + Status.sync = LOGIN_FAILED_NETWORK_ERROR; + do_check_false(errorHandler.shouldReportError()); + do_check_false(errorHandler.didReportProlongedError); + + // Test server maintenance, sync errors are not reported + Status.resetSync(); + setLastSync(NON_PROLONGED_ERROR_DURATION); + errorHandler.dontIgnoreErrors = false; + Status.sync = SERVER_MAINTENANCE; + do_check_false(errorHandler.shouldReportError()); + do_check_false(errorHandler.didReportProlongedError); + + // Test server maintenance, login errors are not reported + Status.resetSync(); + setLastSync(NON_PROLONGED_ERROR_DURATION); + errorHandler.dontIgnoreErrors = false; + Status.login = SERVER_MAINTENANCE; + do_check_false(errorHandler.shouldReportError()); + do_check_false(errorHandler.didReportProlongedError); + + // Test prolonged, server maintenance, sync errors are reported + Status.resetSync(); + setLastSync(PROLONGED_ERROR_DURATION); + errorHandler.dontIgnoreErrors = false; + Status.sync = SERVER_MAINTENANCE; + do_check_true(errorHandler.shouldReportError()); + do_check_true(errorHandler.didReportProlongedError); + errorHandler.didReportProlongedError = false; + + // Test prolonged, server maintenance, login errors are reported + Status.resetSync(); + setLastSync(PROLONGED_ERROR_DURATION); + errorHandler.dontIgnoreErrors = false; + Status.login = SERVER_MAINTENANCE; + do_check_true(errorHandler.shouldReportError()); + do_check_true(errorHandler.didReportProlongedError); + errorHandler.didReportProlongedError = false; + + // Test dontIgnoreErrors, server maintenance, sync errors are reported + Status.resetSync(); + setLastSync(NON_PROLONGED_ERROR_DURATION); + errorHandler.dontIgnoreErrors = true; + Status.sync = SERVER_MAINTENANCE; + do_check_true(errorHandler.shouldReportError()); + // dontIgnoreErrors means we don't set didReportProlongedError + do_check_false(errorHandler.didReportProlongedError); + + // Test dontIgnoreErrors, server maintenance, login errors are reported + Status.resetSync(); + setLastSync(NON_PROLONGED_ERROR_DURATION); + errorHandler.dontIgnoreErrors = true; + Status.login = SERVER_MAINTENANCE; + do_check_true(errorHandler.shouldReportError()); + do_check_false(errorHandler.didReportProlongedError); + + // Test dontIgnoreErrors, prolonged, server maintenance, + // sync errors are reported + Status.resetSync(); + setLastSync(PROLONGED_ERROR_DURATION); + errorHandler.dontIgnoreErrors = true; + Status.sync = SERVER_MAINTENANCE; + do_check_true(errorHandler.shouldReportError()); + do_check_false(errorHandler.didReportProlongedError); + + // Test dontIgnoreErrors, prolonged, server maintenance, + // login errors are reported + Status.resetSync(); + setLastSync(PROLONGED_ERROR_DURATION); + errorHandler.dontIgnoreErrors = true; + Status.login = SERVER_MAINTENANCE; + do_check_true(errorHandler.shouldReportError()); + do_check_false(errorHandler.didReportProlongedError); +}); + +add_identity_test(this, function* test_shouldReportError_master_password() { + _("Test error ignored due to locked master password"); + let server = EHTestsCommon.sync_httpd_setup(); + yield EHTestsCommon.setUp(server); + + // Monkey patch Service.verifyLogin to imitate + // master password being locked. + Service._verifyLogin = Service.verifyLogin; + Service.verifyLogin = function () { + Status.login = MASTER_PASSWORD_LOCKED; + return false; + }; + + setLastSync(NON_PROLONGED_ERROR_DURATION); + Service.sync(); + do_check_false(errorHandler.shouldReportError()); + + // Clean up. + Service.verifyLogin = Service._verifyLogin; + clean(); + let deferred = Promise.defer(); + server.stop(deferred.resolve); + yield deferred.promise; +}); + +// Test that even if we don't have a cluster URL, a login failure due to +// authentication errors is always reported. +add_identity_test(this, function test_shouldReportLoginFailureWithNoCluster() { + // Ensure no clusterURL - any error not specific to login should not be reported. + Service.serverURL = ""; + Service.clusterURL = ""; + + // Test explicit "login rejected" state. + Status.resetSync(); + // If we have a LOGIN_REJECTED state, we always report the error. + Status.login = LOGIN_FAILED_LOGIN_REJECTED; + do_check_true(errorHandler.shouldReportError()); + // But any other status with a missing clusterURL is treated as a mid-sync + // 401 (ie, should be treated as a node reassignment) + Status.login = LOGIN_SUCCEEDED; + do_check_false(errorHandler.shouldReportError()); +}); + +// XXX - how to arrange for 'Service.identity.basicPassword = null;' in +// an fxaccounts environment? +add_task(function* test_login_syncAndReportErrors_non_network_error() { + // Test non-network errors are reported + // when calling syncAndReportErrors + let server = EHTestsCommon.sync_httpd_setup(); + yield EHTestsCommon.setUp(server); + Service.identity.basicPassword = null; + + let deferred = Promise.defer(); + Svc.Obs.add("weave:ui:login:error", function onSyncError() { + Svc.Obs.remove("weave:ui:login:error", onSyncError); + do_check_eq(Status.login, LOGIN_FAILED_NO_PASSWORD); + + clean(); + server.stop(deferred.resolve); + }); + + setLastSync(NON_PROLONGED_ERROR_DURATION); + errorHandler.syncAndReportErrors(); + yield deferred.promise; +}); + +add_identity_test(this, function* test_sync_syncAndReportErrors_non_network_error() { + // Test non-network errors are reported + // when calling syncAndReportErrors + let server = EHTestsCommon.sync_httpd_setup(); + yield EHTestsCommon.setUp(server); + + // By calling sync, we ensure we're logged in. + Service.sync(); + do_check_eq(Status.sync, SYNC_SUCCEEDED); + do_check_true(Service.isLoggedIn); + + EHTestsCommon.generateCredentialsChangedFailure(); + + let deferred = Promise.defer(); + Svc.Obs.add("weave:ui:sync:error", function onSyncError() { + Svc.Obs.remove("weave:ui:sync:error", onSyncError); + do_check_eq(Status.sync, CREDENTIALS_CHANGED); + // If we clean this tick, telemetry won't get the right error + server.stop(() => { + clean(); + deferred.resolve(); + }); + }); + + setLastSync(NON_PROLONGED_ERROR_DURATION); + let ping = yield wait_for_ping(() => errorHandler.syncAndReportErrors(), true); + equal(ping.status.sync, CREDENTIALS_CHANGED); + deepEqual(ping.failureReason, { + name: "unexpectederror", + error: "Error: Aborting sync, remote setup failed" + }); + yield deferred.promise; +}); + +// XXX - how to arrange for 'Service.identity.basicPassword = null;' in +// an fxaccounts environment? +add_task(function* test_login_syncAndReportErrors_prolonged_non_network_error() { + // Test prolonged, non-network errors are + // reported when calling syncAndReportErrors. + let server = EHTestsCommon.sync_httpd_setup(); + yield EHTestsCommon.setUp(server); + Service.identity.basicPassword = null; + + let deferred = Promise.defer(); + Svc.Obs.add("weave:ui:login:error", function onSyncError() { + Svc.Obs.remove("weave:ui:login:error", onSyncError); + do_check_eq(Status.login, LOGIN_FAILED_NO_PASSWORD); + + clean(); + server.stop(deferred.resolve); + }); + + setLastSync(PROLONGED_ERROR_DURATION); + errorHandler.syncAndReportErrors(); + yield deferred.promise; +}); + +add_identity_test(this, function* test_sync_syncAndReportErrors_prolonged_non_network_error() { + // Test prolonged, non-network errors are + // reported when calling syncAndReportErrors. + let server = EHTestsCommon.sync_httpd_setup(); + yield EHTestsCommon.setUp(server); + + // By calling sync, we ensure we're logged in. + Service.sync(); + do_check_eq(Status.sync, SYNC_SUCCEEDED); + do_check_true(Service.isLoggedIn); + + EHTestsCommon.generateCredentialsChangedFailure(); + + let deferred = Promise.defer(); + Svc.Obs.add("weave:ui:sync:error", function onSyncError() { + Svc.Obs.remove("weave:ui:sync:error", onSyncError); + do_check_eq(Status.sync, CREDENTIALS_CHANGED); + // If we clean this tick, telemetry won't get the right error + server.stop(() => { + clean(); + deferred.resolve(); + }); + }); + + setLastSync(PROLONGED_ERROR_DURATION); + let ping = yield wait_for_ping(() => errorHandler.syncAndReportErrors(), true); + equal(ping.status.sync, CREDENTIALS_CHANGED); + deepEqual(ping.failureReason, { + name: "unexpectederror", + error: "Error: Aborting sync, remote setup failed" + }); + yield deferred.promise; +}); + +add_identity_test(this, function* test_login_syncAndReportErrors_network_error() { + // Test network errors are reported when calling syncAndReportErrors. + yield configureIdentity({username: "broken.wipe"}); + Service.serverURL = fakeServerUrl; + Service.clusterURL = fakeServerUrl; + + let deferred = Promise.defer(); + Svc.Obs.add("weave:ui:login:error", function onSyncError() { + Svc.Obs.remove("weave:ui:login:error", onSyncError); + do_check_eq(Status.login, LOGIN_FAILED_NETWORK_ERROR); + + clean(); + deferred.resolve(); + }); + + setLastSync(NON_PROLONGED_ERROR_DURATION); + errorHandler.syncAndReportErrors(); + yield deferred.promise; +}); + + +add_test(function test_sync_syncAndReportErrors_network_error() { + // Test network errors are reported when calling syncAndReportErrors. + Services.io.offline = true; + + Svc.Obs.add("weave:ui:sync:error", function onSyncError() { + Svc.Obs.remove("weave:ui:sync:error", onSyncError); + do_check_eq(Status.sync, LOGIN_FAILED_NETWORK_ERROR); + + Services.io.offline = false; + clean(); + run_next_test(); + }); + + setLastSync(NON_PROLONGED_ERROR_DURATION); + errorHandler.syncAndReportErrors(); +}); + +add_identity_test(this, function* test_login_syncAndReportErrors_prolonged_network_error() { + // Test prolonged, network errors are reported + // when calling syncAndReportErrors. + yield configureIdentity({username: "johndoe"}); + + Service.serverURL = fakeServerUrl; + Service.clusterURL = fakeServerUrl; + + let deferred = Promise.defer(); + Svc.Obs.add("weave:ui:login:error", function onSyncError() { + Svc.Obs.remove("weave:ui:login:error", onSyncError); + do_check_eq(Status.login, LOGIN_FAILED_NETWORK_ERROR); + + clean(); + deferred.resolve(); + }); + + setLastSync(PROLONGED_ERROR_DURATION); + errorHandler.syncAndReportErrors(); + yield deferred.promise; +}); + +add_test(function test_sync_syncAndReportErrors_prolonged_network_error() { + // Test prolonged, network errors are reported + // when calling syncAndReportErrors. + Services.io.offline = true; + + Svc.Obs.add("weave:ui:sync:error", function onSyncError() { + Svc.Obs.remove("weave:ui:sync:error", onSyncError); + do_check_eq(Status.sync, LOGIN_FAILED_NETWORK_ERROR); + + Services.io.offline = false; + clean(); + run_next_test(); + }); + + setLastSync(PROLONGED_ERROR_DURATION); + errorHandler.syncAndReportErrors(); +}); + +add_task(function* test_login_prolonged_non_network_error() { + // Test prolonged, non-network errors are reported + let server = EHTestsCommon.sync_httpd_setup(); + yield EHTestsCommon.setUp(server); + Service.identity.basicPassword = null; + + let deferred = Promise.defer(); + Svc.Obs.add("weave:ui:login:error", function onSyncError() { + Svc.Obs.remove("weave:ui:login:error", onSyncError); + do_check_eq(Status.sync, PROLONGED_SYNC_FAILURE); + do_check_true(errorHandler.didReportProlongedError); + + clean(); + server.stop(deferred.resolve); + }); + + setLastSync(PROLONGED_ERROR_DURATION); + Service.sync(); + yield deferred.promise; +}); + +add_task(function* test_sync_prolonged_non_network_error() { + // Test prolonged, non-network errors are reported + let server = EHTestsCommon.sync_httpd_setup(); + yield EHTestsCommon.setUp(server); + + // By calling sync, we ensure we're logged in. + Service.sync(); + do_check_eq(Status.sync, SYNC_SUCCEEDED); + do_check_true(Service.isLoggedIn); + + EHTestsCommon.generateCredentialsChangedFailure(); + + let deferred = Promise.defer(); + Svc.Obs.add("weave:ui:sync:error", function onSyncError() { + Svc.Obs.remove("weave:ui:sync:error", onSyncError); + do_check_eq(Status.sync, PROLONGED_SYNC_FAILURE); + do_check_true(errorHandler.didReportProlongedError); + server.stop(() => { + clean(); + deferred.resolve(); + }); + }); + + setLastSync(PROLONGED_ERROR_DURATION); + + let ping = yield sync_and_validate_telem(true); + equal(ping.status.sync, PROLONGED_SYNC_FAILURE); + deepEqual(ping.failureReason, { + name: "unexpectederror", + error: "Error: Aborting sync, remote setup failed" + }); + yield deferred.promise; +}); + +add_identity_test(this, function* test_login_prolonged_network_error() { + // Test prolonged, network errors are reported + yield configureIdentity({username: "johndoe"}); + Service.serverURL = fakeServerUrl; + Service.clusterURL = fakeServerUrl; + + let deferred = Promise.defer(); + Svc.Obs.add("weave:ui:login:error", function onSyncError() { + Svc.Obs.remove("weave:ui:login:error", onSyncError); + do_check_eq(Status.sync, PROLONGED_SYNC_FAILURE); + do_check_true(errorHandler.didReportProlongedError); + + clean(); + deferred.resolve(); + }); + + setLastSync(PROLONGED_ERROR_DURATION); + Service.sync(); + yield deferred.promise; +}); + +add_test(function test_sync_prolonged_network_error() { + // Test prolonged, network errors are reported + Services.io.offline = true; + + Svc.Obs.add("weave:ui:sync:error", function onSyncError() { + Svc.Obs.remove("weave:ui:sync:error", onSyncError); + do_check_eq(Status.sync, PROLONGED_SYNC_FAILURE); + do_check_true(errorHandler.didReportProlongedError); + + Services.io.offline = false; + clean(); + run_next_test(); + }); + + setLastSync(PROLONGED_ERROR_DURATION); + Service.sync(); +}); + +add_task(function* test_login_non_network_error() { + // Test non-network errors are reported + let server = EHTestsCommon.sync_httpd_setup(); + yield EHTestsCommon.setUp(server); + Service.identity.basicPassword = null; + + let deferred = Promise.defer(); + Svc.Obs.add("weave:ui:login:error", function onSyncError() { + Svc.Obs.remove("weave:ui:login:error", onSyncError); + do_check_eq(Status.login, LOGIN_FAILED_NO_PASSWORD); + do_check_false(errorHandler.didReportProlongedError); + + clean(); + server.stop(deferred.resolve); + }); + + setLastSync(NON_PROLONGED_ERROR_DURATION); + Service.sync(); + yield deferred.promise; +}); + +add_task(function* test_sync_non_network_error() { + // Test non-network errors are reported + let server = EHTestsCommon.sync_httpd_setup(); + yield EHTestsCommon.setUp(server); + + // By calling sync, we ensure we're logged in. + Service.sync(); + do_check_eq(Status.sync, SYNC_SUCCEEDED); + do_check_true(Service.isLoggedIn); + + EHTestsCommon.generateCredentialsChangedFailure(); + + let deferred = Promise.defer(); + Svc.Obs.add("weave:ui:sync:error", function onSyncError() { + Svc.Obs.remove("weave:ui:sync:error", onSyncError); + do_check_eq(Status.sync, CREDENTIALS_CHANGED); + do_check_false(errorHandler.didReportProlongedError); + + clean(); + server.stop(deferred.resolve); + }); + + setLastSync(NON_PROLONGED_ERROR_DURATION); + Service.sync(); + yield deferred.promise; +}); + +add_identity_test(this, function* test_login_network_error() { + yield configureIdentity({username: "johndoe"}); + Service.serverURL = fakeServerUrl; + Service.clusterURL = fakeServerUrl; + + let deferred = Promise.defer(); + // Test network errors are not reported. + Svc.Obs.add("weave:ui:clear-error", function onClearError() { + Svc.Obs.remove("weave:ui:clear-error", onClearError); + + do_check_eq(Status.login, LOGIN_FAILED_NETWORK_ERROR); + do_check_false(errorHandler.didReportProlongedError); + + Services.io.offline = false; + clean(); + deferred.resolve() + }); + + setLastSync(NON_PROLONGED_ERROR_DURATION); + Service.sync(); + yield deferred.promise; +}); + +add_test(function test_sync_network_error() { + // Test network errors are not reported. + Services.io.offline = true; + + Svc.Obs.add("weave:ui:sync:finish", function onUIUpdate() { + Svc.Obs.remove("weave:ui:sync:finish", onUIUpdate); + do_check_eq(Status.sync, LOGIN_FAILED_NETWORK_ERROR); + do_check_false(errorHandler.didReportProlongedError); + + Services.io.offline = false; + clean(); + run_next_test(); + }); + + setLastSync(NON_PROLONGED_ERROR_DURATION); + Service.sync(); +}); + +add_identity_test(this, function* test_sync_server_maintenance_error() { + // Test server maintenance errors are not reported. + let server = EHTestsCommon.sync_httpd_setup(); + yield EHTestsCommon.setUp(server); + + const BACKOFF = 42; + let engine = engineManager.get("catapult"); + engine.enabled = true; + engine.exception = {status: 503, + headers: {"retry-after": BACKOFF}}; + + function onSyncError() { + do_throw("Shouldn't get here!"); + } + Svc.Obs.add("weave:ui:sync:error", onSyncError); + + do_check_eq(Status.service, STATUS_OK); + + let deferred = Promise.defer(); + Svc.Obs.add("weave:ui:sync:finish", function onSyncFinish() { + Svc.Obs.remove("weave:ui:sync:finish", onSyncFinish); + + do_check_eq(Status.service, SYNC_FAILED_PARTIAL); + do_check_eq(Status.sync, SERVER_MAINTENANCE); + do_check_false(errorHandler.didReportProlongedError); + + Svc.Obs.remove("weave:ui:sync:error", onSyncError); + server.stop(() => { + clean(); + deferred.resolve(); + }) + }); + + setLastSync(NON_PROLONGED_ERROR_DURATION); + let ping = yield sync_and_validate_telem(true); + equal(ping.status.sync, SERVER_MAINTENANCE); + deepEqual(ping.engines.find(e => e.failureReason).failureReason, { name: "httperror", code: 503 }) + + yield deferred.promise; +}); + +add_identity_test(this, function* test_info_collections_login_server_maintenance_error() { + // Test info/collections server maintenance errors are not reported. + let server = EHTestsCommon.sync_httpd_setup(); + yield EHTestsCommon.setUp(server); + + Service.username = "broken.info"; + yield configureIdentity({username: "broken.info"}); + Service.serverURL = server.baseURI + "/maintenance/"; + Service.clusterURL = server.baseURI + "/maintenance/"; + + let backoffInterval; + Svc.Obs.add("weave:service:backoff:interval", function observe(subject, data) { + Svc.Obs.remove("weave:service:backoff:interval", observe); + backoffInterval = subject; + }); + + function onUIUpdate() { + do_throw("Shouldn't experience UI update!"); + } + Svc.Obs.add("weave:ui:login:error", onUIUpdate); + + do_check_false(Status.enforceBackoff); + do_check_eq(Status.service, STATUS_OK); + + let deferred = Promise.defer(); + Svc.Obs.add("weave:ui:clear-error", function onLoginFinish() { + Svc.Obs.remove("weave:ui:clear-error", onLoginFinish); + + do_check_true(Status.enforceBackoff); + do_check_eq(backoffInterval, 42); + do_check_eq(Status.service, LOGIN_FAILED); + do_check_eq(Status.login, SERVER_MAINTENANCE); + do_check_false(errorHandler.didReportProlongedError); + + Svc.Obs.remove("weave:ui:login:error", onUIUpdate); + clean(); + server.stop(deferred.resolve); + }); + + setLastSync(NON_PROLONGED_ERROR_DURATION); + Service.sync(); + yield deferred.promise; +}); + +add_identity_test(this, function* test_meta_global_login_server_maintenance_error() { + // Test meta/global server maintenance errors are not reported. + let server = EHTestsCommon.sync_httpd_setup(); + yield EHTestsCommon.setUp(server); + + yield configureIdentity({username: "broken.meta"}); + Service.serverURL = server.baseURI + "/maintenance/"; + Service.clusterURL = server.baseURI + "/maintenance/"; + + let backoffInterval; + Svc.Obs.add("weave:service:backoff:interval", function observe(subject, data) { + Svc.Obs.remove("weave:service:backoff:interval", observe); + backoffInterval = subject; + }); + + function onUIUpdate() { + do_throw("Shouldn't get here!"); + } + Svc.Obs.add("weave:ui:login:error", onUIUpdate); + + do_check_false(Status.enforceBackoff); + do_check_eq(Status.service, STATUS_OK); + + let deferred = Promise.defer(); + Svc.Obs.add("weave:ui:clear-error", function onLoginFinish() { + Svc.Obs.remove("weave:ui:clear-error", onLoginFinish); + + do_check_true(Status.enforceBackoff); + do_check_eq(backoffInterval, 42); + do_check_eq(Status.service, LOGIN_FAILED); + do_check_eq(Status.login, SERVER_MAINTENANCE); + do_check_false(errorHandler.didReportProlongedError); + + Svc.Obs.remove("weave:ui:login:error", onUIUpdate); + clean(); + server.stop(deferred.resolve); + }); + + setLastSync(NON_PROLONGED_ERROR_DURATION); + Service.sync(); + yield deferred.promise; +}); diff --git a/services/sync/tests/unit/test_errorhandler_2.js b/services/sync/tests/unit/test_errorhandler_2.js new file mode 100644 index 0000000000..41f8ee7276 --- /dev/null +++ b/services/sync/tests/unit/test_errorhandler_2.js @@ -0,0 +1,1012 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +Cu.import("resource://services-sync/engines/clients.js"); +Cu.import("resource://services-sync/constants.js"); +Cu.import("resource://services-sync/engines.js"); +Cu.import("resource://services-sync/keys.js"); +Cu.import("resource://services-sync/policies.js"); +Cu.import("resource://services-sync/service.js"); +Cu.import("resource://services-sync/status.js"); +Cu.import("resource://services-sync/util.js"); +Cu.import("resource://testing-common/services/sync/utils.js"); +Cu.import("resource://gre/modules/FileUtils.jsm"); + +var fakeServer = new SyncServer(); +fakeServer.start(); + +do_register_cleanup(function() { + return new Promise(resolve => { + fakeServer.stop(resolve); + }); +}); + +var fakeServerUrl = "http://localhost:" + fakeServer.port; + +const logsdir = FileUtils.getDir("ProfD", ["weave", "logs"], true); + +const PROLONGED_ERROR_DURATION = + (Svc.Prefs.get('errorhandler.networkFailureReportTimeout') * 2) * 1000; + +const NON_PROLONGED_ERROR_DURATION = + (Svc.Prefs.get('errorhandler.networkFailureReportTimeout') / 2) * 1000; + +Service.engineManager.clear(); + +function setLastSync(lastSyncValue) { + Svc.Prefs.set("lastSync", (new Date(Date.now() - lastSyncValue)).toString()); +} + +var engineManager = Service.engineManager; +engineManager.register(EHTestsCommon.CatapultEngine); + +// This relies on Service/ErrorHandler being a singleton. Fixing this will take +// a lot of work. +var errorHandler = Service.errorHandler; + +function run_test() { + initTestLogging("Trace"); + + Log.repository.getLogger("Sync.Service").level = Log.Level.Trace; + Log.repository.getLogger("Sync.SyncScheduler").level = Log.Level.Trace; + Log.repository.getLogger("Sync.ErrorHandler").level = Log.Level.Trace; + + ensureLegacyIdentityManager(); + + run_next_test(); +} + + +function clean() { + Service.startOver(); + Status.resetSync(); + Status.resetBackoff(); + errorHandler.didReportProlongedError = false; +} + +add_identity_test(this, function* test_crypto_keys_login_server_maintenance_error() { + Status.resetSync(); + // Test crypto/keys server maintenance errors are not reported. + let server = EHTestsCommon.sync_httpd_setup(); + yield EHTestsCommon.setUp(server); + + yield configureIdentity({username: "broken.keys"}); + Service.serverURL = server.baseURI + "/maintenance/"; + Service.clusterURL = server.baseURI + "/maintenance/"; + + // Force re-download of keys + Service.collectionKeys.clear(); + + let backoffInterval; + Svc.Obs.add("weave:service:backoff:interval", function observe(subject, data) { + Svc.Obs.remove("weave:service:backoff:interval", observe); + backoffInterval = subject; + }); + + function onUIUpdate() { + do_throw("Shouldn't get here!"); + } + Svc.Obs.add("weave:ui:login:error", onUIUpdate); + + do_check_false(Status.enforceBackoff); + do_check_eq(Status.service, STATUS_OK); + + let deferred = Promise.defer(); + Svc.Obs.add("weave:ui:clear-error", function onLoginFinish() { + Svc.Obs.remove("weave:ui:clear-error", onLoginFinish); + + do_check_true(Status.enforceBackoff); + do_check_eq(backoffInterval, 42); + do_check_eq(Status.service, LOGIN_FAILED); + do_check_eq(Status.login, SERVER_MAINTENANCE); + do_check_false(errorHandler.didReportProlongedError); + + Svc.Obs.remove("weave:ui:login:error", onUIUpdate); + clean(); + server.stop(deferred.resolve); + }); + + setLastSync(NON_PROLONGED_ERROR_DURATION); + Service.sync(); + yield deferred.promise; +}); + +add_task(function* test_sync_prolonged_server_maintenance_error() { + // Test prolonged server maintenance errors are reported. + let server = EHTestsCommon.sync_httpd_setup(); + yield EHTestsCommon.setUp(server); + + const BACKOFF = 42; + let engine = engineManager.get("catapult"); + engine.enabled = true; + engine.exception = {status: 503, + headers: {"retry-after": BACKOFF}}; + + let deferred = Promise.defer(); + Svc.Obs.add("weave:ui:sync:error", function onUIUpdate() { + Svc.Obs.remove("weave:ui:sync:error", onUIUpdate); + do_check_eq(Status.service, SYNC_FAILED); + do_check_eq(Status.sync, PROLONGED_SYNC_FAILURE); + do_check_true(errorHandler.didReportProlongedError); + + server.stop(() => { + clean(); + deferred.resolve(); + }); + }); + + do_check_eq(Status.service, STATUS_OK); + + setLastSync(PROLONGED_ERROR_DURATION); + let ping = yield sync_and_validate_telem(true); + deepEqual(ping.status.sync, PROLONGED_SYNC_FAILURE); + deepEqual(ping.engines.find(e => e.failureReason).failureReason, + { name: "httperror", code: 503 }); + yield deferred.promise; +}); + +add_identity_test(this, function* test_info_collections_login_prolonged_server_maintenance_error(){ + // Test info/collections prolonged server maintenance errors are reported. + let server = EHTestsCommon.sync_httpd_setup(); + yield EHTestsCommon.setUp(server); + + yield configureIdentity({username: "broken.info"}); + Service.serverURL = server.baseURI + "/maintenance/"; + Service.clusterURL = server.baseURI + "/maintenance/"; + + let backoffInterval; + Svc.Obs.add("weave:service:backoff:interval", function observe(subject, data) { + Svc.Obs.remove("weave:service:backoff:interval", observe); + backoffInterval = subject; + }); + + let deferred = Promise.defer(); + Svc.Obs.add("weave:ui:login:error", function onUIUpdate() { + Svc.Obs.remove("weave:ui:login:error", onUIUpdate); + do_check_true(Status.enforceBackoff); + do_check_eq(backoffInterval, 42); + do_check_eq(Status.service, SYNC_FAILED); + do_check_eq(Status.sync, PROLONGED_SYNC_FAILURE); + do_check_true(errorHandler.didReportProlongedError); + + clean(); + server.stop(deferred.resolve); + }); + + do_check_false(Status.enforceBackoff); + do_check_eq(Status.service, STATUS_OK); + + setLastSync(PROLONGED_ERROR_DURATION); + Service.sync(); + yield deferred.promise; +}); + +add_identity_test(this, function* test_meta_global_login_prolonged_server_maintenance_error(){ + // Test meta/global prolonged server maintenance errors are reported. + let server = EHTestsCommon.sync_httpd_setup(); + yield EHTestsCommon.setUp(server); + + yield configureIdentity({username: "broken.meta"}); + Service.serverURL = server.baseURI + "/maintenance/"; + Service.clusterURL = server.baseURI + "/maintenance/"; + + let backoffInterval; + Svc.Obs.add("weave:service:backoff:interval", function observe(subject, data) { + Svc.Obs.remove("weave:service:backoff:interval", observe); + backoffInterval = subject; + }); + + let deferred = Promise.defer(); + Svc.Obs.add("weave:ui:login:error", function onUIUpdate() { + Svc.Obs.remove("weave:ui:login:error", onUIUpdate); + do_check_true(Status.enforceBackoff); + do_check_eq(backoffInterval, 42); + do_check_eq(Status.service, SYNC_FAILED); + do_check_eq(Status.sync, PROLONGED_SYNC_FAILURE); + do_check_true(errorHandler.didReportProlongedError); + + clean(); + server.stop(deferred.resolve); + }); + + do_check_false(Status.enforceBackoff); + do_check_eq(Status.service, STATUS_OK); + + setLastSync(PROLONGED_ERROR_DURATION); + Service.sync(); + yield deferred.promise; +}); + +add_identity_test(this, function* test_download_crypto_keys_login_prolonged_server_maintenance_error(){ + // Test crypto/keys prolonged server maintenance errors are reported. + let server = EHTestsCommon.sync_httpd_setup(); + yield EHTestsCommon.setUp(server); + + yield configureIdentity({username: "broken.keys"}); + Service.serverURL = server.baseURI + "/maintenance/"; + Service.clusterURL = server.baseURI + "/maintenance/"; + // Force re-download of keys + Service.collectionKeys.clear(); + + let backoffInterval; + Svc.Obs.add("weave:service:backoff:interval", function observe(subject, data) { + Svc.Obs.remove("weave:service:backoff:interval", observe); + backoffInterval = subject; + }); + + let deferred = Promise.defer(); + Svc.Obs.add("weave:ui:login:error", function onUIUpdate() { + Svc.Obs.remove("weave:ui:login:error", onUIUpdate); + do_check_true(Status.enforceBackoff); + do_check_eq(backoffInterval, 42); + do_check_eq(Status.service, SYNC_FAILED); + do_check_eq(Status.sync, PROLONGED_SYNC_FAILURE); + do_check_true(errorHandler.didReportProlongedError); + + clean(); + server.stop(deferred.resolve); + }); + + do_check_false(Status.enforceBackoff); + do_check_eq(Status.service, STATUS_OK); + + setLastSync(PROLONGED_ERROR_DURATION); + Service.sync(); + yield deferred.promise; +}); + +add_identity_test(this, function* test_upload_crypto_keys_login_prolonged_server_maintenance_error(){ + // Test crypto/keys prolonged server maintenance errors are reported. + let server = EHTestsCommon.sync_httpd_setup(); + + // Start off with an empty account, do not upload a key. + yield configureIdentity({username: "broken.keys"}); + Service.serverURL = server.baseURI + "/maintenance/"; + Service.clusterURL = server.baseURI + "/maintenance/"; + + let backoffInterval; + Svc.Obs.add("weave:service:backoff:interval", function observe(subject, data) { + Svc.Obs.remove("weave:service:backoff:interval", observe); + backoffInterval = subject; + }); + + let deferred = Promise.defer(); + Svc.Obs.add("weave:ui:login:error", function onUIUpdate() { + Svc.Obs.remove("weave:ui:login:error", onUIUpdate); + do_check_true(Status.enforceBackoff); + do_check_eq(backoffInterval, 42); + do_check_eq(Status.service, SYNC_FAILED); + do_check_eq(Status.sync, PROLONGED_SYNC_FAILURE); + do_check_true(errorHandler.didReportProlongedError); + + clean(); + server.stop(deferred.resolve); + }); + + do_check_false(Status.enforceBackoff); + do_check_eq(Status.service, STATUS_OK); + + setLastSync(PROLONGED_ERROR_DURATION); + Service.sync(); + yield deferred.promise; +}); + +add_identity_test(this, function* test_wipeServer_login_prolonged_server_maintenance_error(){ + // Test that we report prolonged server maintenance errors that occur whilst + // wiping the server. + let server = EHTestsCommon.sync_httpd_setup(); + + // Start off with an empty account, do not upload a key. + yield configureIdentity({username: "broken.wipe"}); + Service.serverURL = server.baseURI + "/maintenance/"; + Service.clusterURL = server.baseURI + "/maintenance/"; + + let backoffInterval; + Svc.Obs.add("weave:service:backoff:interval", function observe(subject, data) { + Svc.Obs.remove("weave:service:backoff:interval", observe); + backoffInterval = subject; + }); + + let deferred = Promise.defer(); + Svc.Obs.add("weave:ui:login:error", function onUIUpdate() { + Svc.Obs.remove("weave:ui:login:error", onUIUpdate); + do_check_true(Status.enforceBackoff); + do_check_eq(backoffInterval, 42); + do_check_eq(Status.service, SYNC_FAILED); + do_check_eq(Status.sync, PROLONGED_SYNC_FAILURE); + do_check_true(errorHandler.didReportProlongedError); + + clean(); + server.stop(deferred.resolve); + }); + + do_check_false(Status.enforceBackoff); + do_check_eq(Status.service, STATUS_OK); + + setLastSync(PROLONGED_ERROR_DURATION); + Service.sync(); + yield deferred.promise; +}); + +add_identity_test(this, function* test_wipeRemote_prolonged_server_maintenance_error(){ + // Test that we report prolonged server maintenance errors that occur whilst + // wiping all remote devices. + let server = EHTestsCommon.sync_httpd_setup(); + + server.registerPathHandler("/1.1/broken.wipe/storage/catapult", EHTestsCommon.service_unavailable); + yield configureIdentity({username: "broken.wipe"}); + Service.serverURL = server.baseURI + "/maintenance/"; + Service.clusterURL = server.baseURI + "/maintenance/"; + EHTestsCommon.generateAndUploadKeys(); + + let engine = engineManager.get("catapult"); + engine.exception = null; + engine.enabled = true; + + let backoffInterval; + Svc.Obs.add("weave:service:backoff:interval", function observe(subject, data) { + Svc.Obs.remove("weave:service:backoff:interval", observe); + backoffInterval = subject; + }); + + let deferred = Promise.defer(); + Svc.Obs.add("weave:ui:sync:error", function onUIUpdate() { + Svc.Obs.remove("weave:ui:sync:error", onUIUpdate); + do_check_true(Status.enforceBackoff); + do_check_eq(backoffInterval, 42); + do_check_eq(Status.service, SYNC_FAILED); + do_check_eq(Status.sync, PROLONGED_SYNC_FAILURE); + do_check_eq(Svc.Prefs.get("firstSync"), "wipeRemote"); + do_check_true(errorHandler.didReportProlongedError); + server.stop(() => { + clean(); + deferred.resolve(); + }); + }); + + do_check_false(Status.enforceBackoff); + do_check_eq(Status.service, STATUS_OK); + + Svc.Prefs.set("firstSync", "wipeRemote"); + setLastSync(PROLONGED_ERROR_DURATION); + let ping = yield sync_and_validate_telem(true); + deepEqual(ping.failureReason, { name: "httperror", code: 503 }); + yield deferred.promise; +}); + +add_task(function* test_sync_syncAndReportErrors_server_maintenance_error() { + // Test server maintenance errors are reported + // when calling syncAndReportErrors. + let server = EHTestsCommon.sync_httpd_setup(); + yield EHTestsCommon.setUp(server); + + const BACKOFF = 42; + let engine = engineManager.get("catapult"); + engine.enabled = true; + engine.exception = {status: 503, + headers: {"retry-after": BACKOFF}}; + + let deferred = Promise.defer(); + Svc.Obs.add("weave:ui:sync:error", function onUIUpdate() { + Svc.Obs.remove("weave:ui:sync:error", onUIUpdate); + do_check_eq(Status.service, SYNC_FAILED_PARTIAL); + do_check_eq(Status.sync, SERVER_MAINTENANCE); + do_check_false(errorHandler.didReportProlongedError); + + clean(); + server.stop(deferred.resolve); + }); + + do_check_eq(Status.service, STATUS_OK); + + setLastSync(NON_PROLONGED_ERROR_DURATION); + errorHandler.syncAndReportErrors(); + yield deferred.promise; +}); + +add_identity_test(this, function* test_info_collections_login_syncAndReportErrors_server_maintenance_error() { + // Test info/collections server maintenance errors are reported + // when calling syncAndReportErrors. + let server = EHTestsCommon.sync_httpd_setup(); + yield EHTestsCommon.setUp(server); + + yield configureIdentity({username: "broken.info"}); + Service.serverURL = server.baseURI + "/maintenance/"; + Service.clusterURL = server.baseURI + "/maintenance/"; + + let backoffInterval; + Svc.Obs.add("weave:service:backoff:interval", function observe(subject, data) { + Svc.Obs.remove("weave:service:backoff:interval", observe); + backoffInterval = subject; + }); + + let deferred = Promise.defer(); + Svc.Obs.add("weave:ui:login:error", function onUIUpdate() { + Svc.Obs.remove("weave:ui:login:error", onUIUpdate); + do_check_true(Status.enforceBackoff); + do_check_eq(backoffInterval, 42); + do_check_eq(Status.service, LOGIN_FAILED); + do_check_eq(Status.login, SERVER_MAINTENANCE); + do_check_false(errorHandler.didReportProlongedError); + + clean(); + server.stop(deferred.resolve); + }); + + do_check_false(Status.enforceBackoff); + do_check_eq(Status.service, STATUS_OK); + + setLastSync(NON_PROLONGED_ERROR_DURATION); + errorHandler.syncAndReportErrors(); + yield deferred.promise; +}); + +add_identity_test(this, function* test_meta_global_login_syncAndReportErrors_server_maintenance_error() { + // Test meta/global server maintenance errors are reported + // when calling syncAndReportErrors. + let server = EHTestsCommon.sync_httpd_setup(); + yield EHTestsCommon.setUp(server); + + yield configureIdentity({username: "broken.meta"}); + Service.serverURL = server.baseURI + "/maintenance/"; + Service.clusterURL = server.baseURI + "/maintenance/"; + + let backoffInterval; + Svc.Obs.add("weave:service:backoff:interval", function observe(subject, data) { + Svc.Obs.remove("weave:service:backoff:interval", observe); + backoffInterval = subject; + }); + + let deferred = Promise.defer(); + Svc.Obs.add("weave:ui:login:error", function onUIUpdate() { + Svc.Obs.remove("weave:ui:login:error", onUIUpdate); + do_check_true(Status.enforceBackoff); + do_check_eq(backoffInterval, 42); + do_check_eq(Status.service, LOGIN_FAILED); + do_check_eq(Status.login, SERVER_MAINTENANCE); + do_check_false(errorHandler.didReportProlongedError); + + clean(); + server.stop(deferred.resolve); + }); + + do_check_false(Status.enforceBackoff); + do_check_eq(Status.service, STATUS_OK); + + setLastSync(NON_PROLONGED_ERROR_DURATION); + errorHandler.syncAndReportErrors(); + yield deferred.promise; +}); + +add_identity_test(this, function* test_download_crypto_keys_login_syncAndReportErrors_server_maintenance_error() { + // Test crypto/keys server maintenance errors are reported + // when calling syncAndReportErrors. + let server = EHTestsCommon.sync_httpd_setup(); + yield EHTestsCommon.setUp(server); + + yield configureIdentity({username: "broken.keys"}); + Service.serverURL = server.baseURI + "/maintenance/"; + Service.clusterURL = server.baseURI + "/maintenance/"; + // Force re-download of keys + Service.collectionKeys.clear(); + + let backoffInterval; + Svc.Obs.add("weave:service:backoff:interval", function observe(subject, data) { + Svc.Obs.remove("weave:service:backoff:interval", observe); + backoffInterval = subject; + }); + + let deferred = Promise.defer(); + Svc.Obs.add("weave:ui:login:error", function onUIUpdate() { + Svc.Obs.remove("weave:ui:login:error", onUIUpdate); + do_check_true(Status.enforceBackoff); + do_check_eq(backoffInterval, 42); + do_check_eq(Status.service, LOGIN_FAILED); + do_check_eq(Status.login, SERVER_MAINTENANCE); + do_check_false(errorHandler.didReportProlongedError); + + clean(); + server.stop(deferred.resolve); + }); + + do_check_false(Status.enforceBackoff); + do_check_eq(Status.service, STATUS_OK); + + setLastSync(NON_PROLONGED_ERROR_DURATION); + errorHandler.syncAndReportErrors(); + yield deferred.promise; +}); + +add_identity_test(this, function* test_upload_crypto_keys_login_syncAndReportErrors_server_maintenance_error() { + // Test crypto/keys server maintenance errors are reported + // when calling syncAndReportErrors. + let server = EHTestsCommon.sync_httpd_setup(); + + // Start off with an empty account, do not upload a key. + yield configureIdentity({username: "broken.keys"}); + Service.serverURL = server.baseURI + "/maintenance/"; + Service.clusterURL = server.baseURI + "/maintenance/"; + + let backoffInterval; + Svc.Obs.add("weave:service:backoff:interval", function observe(subject, data) { + Svc.Obs.remove("weave:service:backoff:interval", observe); + backoffInterval = subject; + }); + + let deferred = Promise.defer(); + Svc.Obs.add("weave:ui:login:error", function onUIUpdate() { + Svc.Obs.remove("weave:ui:login:error", onUIUpdate); + do_check_true(Status.enforceBackoff); + do_check_eq(backoffInterval, 42); + do_check_eq(Status.service, LOGIN_FAILED); + do_check_eq(Status.login, SERVER_MAINTENANCE); + do_check_false(errorHandler.didReportProlongedError); + + clean(); + server.stop(deferred.resolve); + }); + + do_check_false(Status.enforceBackoff); + do_check_eq(Status.service, STATUS_OK); + + setLastSync(NON_PROLONGED_ERROR_DURATION); + errorHandler.syncAndReportErrors(); + yield deferred.promise; +}); + +add_identity_test(this, function* test_wipeServer_login_syncAndReportErrors_server_maintenance_error() { + // Test crypto/keys server maintenance errors are reported + // when calling syncAndReportErrors. + let server = EHTestsCommon.sync_httpd_setup(); + + // Start off with an empty account, do not upload a key. + yield configureIdentity({username: "broken.wipe"}); + Service.serverURL = server.baseURI + "/maintenance/"; + Service.clusterURL = server.baseURI + "/maintenance/"; + + let backoffInterval; + Svc.Obs.add("weave:service:backoff:interval", function observe(subject, data) { + Svc.Obs.remove("weave:service:backoff:interval", observe); + backoffInterval = subject; + }); + + let deferred = Promise.defer(); + Svc.Obs.add("weave:ui:login:error", function onUIUpdate() { + Svc.Obs.remove("weave:ui:login:error", onUIUpdate); + do_check_true(Status.enforceBackoff); + do_check_eq(backoffInterval, 42); + do_check_eq(Status.service, LOGIN_FAILED); + do_check_eq(Status.login, SERVER_MAINTENANCE); + do_check_false(errorHandler.didReportProlongedError); + + clean(); + server.stop(deferred.resolve); + }); + + do_check_false(Status.enforceBackoff); + do_check_eq(Status.service, STATUS_OK); + + setLastSync(NON_PROLONGED_ERROR_DURATION); + errorHandler.syncAndReportErrors(); + yield deferred.promise; +}); + +add_identity_test(this, function* test_wipeRemote_syncAndReportErrors_server_maintenance_error(){ + // Test that we report prolonged server maintenance errors that occur whilst + // wiping all remote devices. + let server = EHTestsCommon.sync_httpd_setup(); + + yield configureIdentity({username: "broken.wipe"}); + Service.serverURL = server.baseURI + "/maintenance/"; + Service.clusterURL = server.baseURI + "/maintenance/"; + EHTestsCommon.generateAndUploadKeys(); + + let engine = engineManager.get("catapult"); + engine.exception = null; + engine.enabled = true; + + let backoffInterval; + Svc.Obs.add("weave:service:backoff:interval", function observe(subject, data) { + Svc.Obs.remove("weave:service:backoff:interval", observe); + backoffInterval = subject; + }); + + let deferred = Promise.defer(); + Svc.Obs.add("weave:ui:sync:error", function onUIUpdate() { + Svc.Obs.remove("weave:ui:sync:error", onUIUpdate); + do_check_true(Status.enforceBackoff); + do_check_eq(backoffInterval, 42); + do_check_eq(Status.service, SYNC_FAILED); + do_check_eq(Status.sync, SERVER_MAINTENANCE); + do_check_eq(Svc.Prefs.get("firstSync"), "wipeRemote"); + do_check_false(errorHandler.didReportProlongedError); + + clean(); + server.stop(deferred.resolve); + }); + + do_check_false(Status.enforceBackoff); + do_check_eq(Status.service, STATUS_OK); + + Svc.Prefs.set("firstSync", "wipeRemote"); + setLastSync(NON_PROLONGED_ERROR_DURATION); + errorHandler.syncAndReportErrors(); + yield deferred.promise; +}); + +add_task(function* test_sync_syncAndReportErrors_prolonged_server_maintenance_error() { + // Test prolonged server maintenance errors are + // reported when calling syncAndReportErrors. + let server = EHTestsCommon.sync_httpd_setup(); + yield EHTestsCommon.setUp(server); + + const BACKOFF = 42; + let engine = engineManager.get("catapult"); + engine.enabled = true; + engine.exception = {status: 503, + headers: {"retry-after": BACKOFF}}; + + let deferred = Promise.defer(); + Svc.Obs.add("weave:ui:sync:error", function onUIUpdate() { + Svc.Obs.remove("weave:ui:sync:error", onUIUpdate); + do_check_eq(Status.service, SYNC_FAILED_PARTIAL); + do_check_eq(Status.sync, SERVER_MAINTENANCE); + // syncAndReportErrors means dontIgnoreErrors, which means + // didReportProlongedError not touched. + do_check_false(errorHandler.didReportProlongedError); + + clean(); + server.stop(deferred.resolve); + }); + + do_check_eq(Status.service, STATUS_OK); + + setLastSync(PROLONGED_ERROR_DURATION); + errorHandler.syncAndReportErrors(); + yield deferred.promise; +}); + +add_identity_test(this, function* test_info_collections_login_syncAndReportErrors_prolonged_server_maintenance_error() { + // Test info/collections server maintenance errors are reported + // when calling syncAndReportErrors. + let server = EHTestsCommon.sync_httpd_setup(); + yield EHTestsCommon.setUp(server); + + yield configureIdentity({username: "broken.info"}); + Service.serverURL = server.baseURI + "/maintenance/"; + Service.clusterURL = server.baseURI + "/maintenance/"; + + let backoffInterval; + Svc.Obs.add("weave:service:backoff:interval", function observe(subject, data) { + Svc.Obs.remove("weave:service:backoff:interval", observe); + backoffInterval = subject; + }); + + let deferred = Promise.defer(); + Svc.Obs.add("weave:ui:login:error", function onUIUpdate() { + Svc.Obs.remove("weave:ui:login:error", onUIUpdate); + do_check_true(Status.enforceBackoff); + do_check_eq(backoffInterval, 42); + do_check_eq(Status.service, LOGIN_FAILED); + do_check_eq(Status.login, SERVER_MAINTENANCE); + // syncAndReportErrors means dontIgnoreErrors, which means + // didReportProlongedError not touched. + do_check_false(errorHandler.didReportProlongedError); + + clean(); + server.stop(deferred.resolve); + }); + + do_check_false(Status.enforceBackoff); + do_check_eq(Status.service, STATUS_OK); + + setLastSync(PROLONGED_ERROR_DURATION); + errorHandler.syncAndReportErrors(); + yield deferred.promise; +}); + +add_identity_test(this, function* test_meta_global_login_syncAndReportErrors_prolonged_server_maintenance_error() { + // Test meta/global server maintenance errors are reported + // when calling syncAndReportErrors. + let server = EHTestsCommon.sync_httpd_setup(); + yield EHTestsCommon.setUp(server); + + yield configureIdentity({username: "broken.meta"}); + Service.serverURL = server.baseURI + "/maintenance/"; + Service.clusterURL = server.baseURI + "/maintenance/"; + + let backoffInterval; + Svc.Obs.add("weave:service:backoff:interval", function observe(subject, data) { + Svc.Obs.remove("weave:service:backoff:interval", observe); + backoffInterval = subject; + }); + + let deferred = Promise.defer(); + Svc.Obs.add("weave:ui:login:error", function onUIUpdate() { + Svc.Obs.remove("weave:ui:login:error", onUIUpdate); + do_check_true(Status.enforceBackoff); + do_check_eq(backoffInterval, 42); + do_check_eq(Status.service, LOGIN_FAILED); + do_check_eq(Status.login, SERVER_MAINTENANCE); + // syncAndReportErrors means dontIgnoreErrors, which means + // didReportProlongedError not touched. + do_check_false(errorHandler.didReportProlongedError); + + clean(); + server.stop(deferred.resolve); + }); + + do_check_false(Status.enforceBackoff); + do_check_eq(Status.service, STATUS_OK); + + setLastSync(PROLONGED_ERROR_DURATION); + errorHandler.syncAndReportErrors(); + yield deferred.promise; +}); + +add_identity_test(this, function* test_download_crypto_keys_login_syncAndReportErrors_prolonged_server_maintenance_error() { + // Test crypto/keys server maintenance errors are reported + // when calling syncAndReportErrors. + let server = EHTestsCommon.sync_httpd_setup(); + yield EHTestsCommon.setUp(server); + + yield configureIdentity({username: "broken.keys"}); + Service.serverURL = server.baseURI + "/maintenance/"; + Service.clusterURL = server.baseURI + "/maintenance/"; + // Force re-download of keys + Service.collectionKeys.clear(); + + let backoffInterval; + Svc.Obs.add("weave:service:backoff:interval", function observe(subject, data) { + Svc.Obs.remove("weave:service:backoff:interval", observe); + backoffInterval = subject; + }); + + let deferred = Promise.defer(); + Svc.Obs.add("weave:ui:login:error", function onUIUpdate() { + Svc.Obs.remove("weave:ui:login:error", onUIUpdate); + do_check_true(Status.enforceBackoff); + do_check_eq(backoffInterval, 42); + do_check_eq(Status.service, LOGIN_FAILED); + do_check_eq(Status.login, SERVER_MAINTENANCE); + // syncAndReportErrors means dontIgnoreErrors, which means + // didReportProlongedError not touched. + do_check_false(errorHandler.didReportProlongedError); + + clean(); + server.stop(deferred.resolve); + }); + + do_check_false(Status.enforceBackoff); + do_check_eq(Status.service, STATUS_OK); + + setLastSync(PROLONGED_ERROR_DURATION); + errorHandler.syncAndReportErrors(); + yield deferred.promise; +}); + +add_identity_test(this, function* test_upload_crypto_keys_login_syncAndReportErrors_prolonged_server_maintenance_error() { + // Test crypto/keys server maintenance errors are reported + // when calling syncAndReportErrors. + let server = EHTestsCommon.sync_httpd_setup(); + + // Start off with an empty account, do not upload a key. + yield configureIdentity({username: "broken.keys"}); + Service.serverURL = server.baseURI + "/maintenance/"; + Service.clusterURL = server.baseURI + "/maintenance/"; + + let backoffInterval; + Svc.Obs.add("weave:service:backoff:interval", function observe(subject, data) { + Svc.Obs.remove("weave:service:backoff:interval", observe); + backoffInterval = subject; + }); + + let deferred = Promise.defer(); + Svc.Obs.add("weave:ui:login:error", function onUIUpdate() { + Svc.Obs.remove("weave:ui:login:error", onUIUpdate); + do_check_true(Status.enforceBackoff); + do_check_eq(backoffInterval, 42); + do_check_eq(Status.service, LOGIN_FAILED); + do_check_eq(Status.login, SERVER_MAINTENANCE); + // syncAndReportErrors means dontIgnoreErrors, which means + // didReportProlongedError not touched. + do_check_false(errorHandler.didReportProlongedError); + + clean(); + server.stop(deferred.resolve); + }); + + do_check_false(Status.enforceBackoff); + do_check_eq(Status.service, STATUS_OK); + + setLastSync(PROLONGED_ERROR_DURATION); + errorHandler.syncAndReportErrors(); + yield deferred.promise; +}); + +add_identity_test(this, function* test_wipeServer_login_syncAndReportErrors_prolonged_server_maintenance_error() { + // Test crypto/keys server maintenance errors are reported + // when calling syncAndReportErrors. + let server = EHTestsCommon.sync_httpd_setup(); + + // Start off with an empty account, do not upload a key. + yield configureIdentity({username: "broken.wipe"}); + Service.serverURL = server.baseURI + "/maintenance/"; + Service.clusterURL = server.baseURI + "/maintenance/"; + + let backoffInterval; + Svc.Obs.add("weave:service:backoff:interval", function observe(subject, data) { + Svc.Obs.remove("weave:service:backoff:interval", observe); + backoffInterval = subject; + }); + + let deferred = Promise.defer(); + Svc.Obs.add("weave:ui:login:error", function onUIUpdate() { + Svc.Obs.remove("weave:ui:login:error", onUIUpdate); + do_check_true(Status.enforceBackoff); + do_check_eq(backoffInterval, 42); + do_check_eq(Status.service, LOGIN_FAILED); + do_check_eq(Status.login, SERVER_MAINTENANCE); + // syncAndReportErrors means dontIgnoreErrors, which means + // didReportProlongedError not touched. + do_check_false(errorHandler.didReportProlongedError); + + clean(); + server.stop(deferred.resolve); + }); + + do_check_false(Status.enforceBackoff); + do_check_eq(Status.service, STATUS_OK); + + setLastSync(PROLONGED_ERROR_DURATION); + errorHandler.syncAndReportErrors(); + yield deferred.promise; +}); + +add_task(function* test_sync_engine_generic_fail() { + let server = EHTestsCommon.sync_httpd_setup(); + +let engine = engineManager.get("catapult"); + engine.enabled = true; + engine.sync = function sync() { + Svc.Obs.notify("weave:engine:sync:error", ENGINE_UNKNOWN_FAIL, "catapult"); + }; + + let log = Log.repository.getLogger("Sync.ErrorHandler"); + Svc.Prefs.set("log.appender.file.logOnError", true); + + do_check_eq(Status.engines["catapult"], undefined); + + let deferred = Promise.defer(); + // Don't wait for reset-file-log until the sync is underway. + // This avoids us catching a delayed notification from an earlier test. + Svc.Obs.add("weave:engine:sync:finish", function onEngineFinish() { + Svc.Obs.remove("weave:engine:sync:finish", onEngineFinish); + + log.info("Adding reset-file-log observer."); + Svc.Obs.add("weave:service:reset-file-log", function onResetFileLog() { + Svc.Obs.remove("weave:service:reset-file-log", onResetFileLog); + + // Put these checks here, not after sync(), so that we aren't racing the + // log handler... which resets everything just a few lines below! + _("Status.engines: " + JSON.stringify(Status.engines)); + do_check_eq(Status.engines["catapult"], ENGINE_UNKNOWN_FAIL); + do_check_eq(Status.service, SYNC_FAILED_PARTIAL); + + // Test Error log was written on SYNC_FAILED_PARTIAL. + let entries = logsdir.directoryEntries; + do_check_true(entries.hasMoreElements()); + let logfile = entries.getNext().QueryInterface(Ci.nsILocalFile); + do_check_true(logfile.leafName.startsWith("error-sync-"), logfile.leafName); + + clean(); + + let syncErrors = sumHistogram("WEAVE_ENGINE_SYNC_ERRORS", { key: "catapult" }); + do_check_true(syncErrors, 1); + + server.stop(() => { + clean(); + deferred.resolve(); + }); + }); + }); + + do_check_true(yield EHTestsCommon.setUp(server)); + let ping = yield sync_and_validate_telem(true); + deepEqual(ping.status.service, SYNC_FAILED_PARTIAL); + deepEqual(ping.engines.find(e => e.status).status, ENGINE_UNKNOWN_FAIL); + + yield deferred.promise; +}); + +add_test(function test_logs_on_sync_error_despite_shouldReportError() { + _("Ensure that an error is still logged when weave:service:sync:error " + + "is notified, despite shouldReportError returning false."); + + let log = Log.repository.getLogger("Sync.ErrorHandler"); + Svc.Prefs.set("log.appender.file.logOnError", true); + log.info("TESTING"); + + // Ensure that we report no error. + Status.login = MASTER_PASSWORD_LOCKED; + do_check_false(errorHandler.shouldReportError()); + + Svc.Obs.add("weave:service:reset-file-log", function onResetFileLog() { + Svc.Obs.remove("weave:service:reset-file-log", onResetFileLog); + + // Test that error log was written. + let entries = logsdir.directoryEntries; + do_check_true(entries.hasMoreElements()); + let logfile = entries.getNext().QueryInterface(Ci.nsILocalFile); + do_check_true(logfile.leafName.startsWith("error-sync-"), logfile.leafName); + + clean(); + run_next_test(); + }); + Svc.Obs.notify("weave:service:sync:error", {}); +}); + +add_test(function test_logs_on_login_error_despite_shouldReportError() { + _("Ensure that an error is still logged when weave:service:login:error " + + "is notified, despite shouldReportError returning false."); + + let log = Log.repository.getLogger("Sync.ErrorHandler"); + Svc.Prefs.set("log.appender.file.logOnError", true); + log.info("TESTING"); + + // Ensure that we report no error. + Status.login = MASTER_PASSWORD_LOCKED; + do_check_false(errorHandler.shouldReportError()); + + Svc.Obs.add("weave:service:reset-file-log", function onResetFileLog() { + Svc.Obs.remove("weave:service:reset-file-log", onResetFileLog); + + // Test that error log was written. + let entries = logsdir.directoryEntries; + do_check_true(entries.hasMoreElements()); + let logfile = entries.getNext().QueryInterface(Ci.nsILocalFile); + do_check_true(logfile.leafName.startsWith("error-sync-"), logfile.leafName); + + clean(); + run_next_test(); + }); + Svc.Obs.notify("weave:service:login:error", {}); +}); + +// This test should be the last one since it monkeypatches the engine object +// and we should only have one engine object throughout the file (bug 629664). +add_task(function* test_engine_applyFailed() { + let server = EHTestsCommon.sync_httpd_setup(); + + let engine = engineManager.get("catapult"); + engine.enabled = true; + delete engine.exception; + engine.sync = function sync() { + Svc.Obs.notify("weave:engine:sync:applied", {newFailed:1}, "catapult"); + }; + + let log = Log.repository.getLogger("Sync.ErrorHandler"); + Svc.Prefs.set("log.appender.file.logOnError", true); + + let deferred = Promise.defer(); + Svc.Obs.add("weave:service:reset-file-log", function onResetFileLog() { + Svc.Obs.remove("weave:service:reset-file-log", onResetFileLog); + + do_check_eq(Status.engines["catapult"], ENGINE_APPLY_FAIL); + do_check_eq(Status.service, SYNC_FAILED_PARTIAL); + + // Test Error log was written on SYNC_FAILED_PARTIAL. + let entries = logsdir.directoryEntries; + do_check_true(entries.hasMoreElements()); + let logfile = entries.getNext().QueryInterface(Ci.nsILocalFile); + do_check_true(logfile.leafName.startsWith("error-sync-"), logfile.leafName); + + clean(); + server.stop(deferred.resolve); + }); + + do_check_eq(Status.engines["catapult"], undefined); + do_check_true(yield EHTestsCommon.setUp(server)); + Service.sync(); + yield deferred.promise; +}); diff --git a/services/sync/tests/unit/test_errorhandler_eol.js b/services/sync/tests/unit/test_errorhandler_eol.js new file mode 100644 index 0000000000..c8d2ff4be0 --- /dev/null +++ b/services/sync/tests/unit/test_errorhandler_eol.js @@ -0,0 +1,137 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +Cu.import("resource://services-sync/service.js"); +Cu.import("resource://services-sync/status.js"); +Cu.import("resource://services-sync/util.js"); + +Cu.import("resource://testing-common/services/sync/fakeservices.js"); +Cu.import("resource://testing-common/services/sync/utils.js"); + +function baseHandler(eolCode, request, response, statusCode, status, body) { + let alertBody = { + code: eolCode, + message: "Service is EOLed.", + url: "http://getfirefox.com", + }; + response.setHeader("X-Weave-Timestamp", "" + new_timestamp(), false); + response.setHeader("X-Weave-Alert", "" + JSON.stringify(alertBody), false); + response.setStatusLine(request.httpVersion, statusCode, status); + response.bodyOutputStream.write(body, body.length); +} + +function handler513(request, response) { + let statusCode = 513; + let status = "Upgrade Required"; + let body = "{}"; + baseHandler("hard-eol", request, response, statusCode, status, body); +} + +function handler200(eolCode) { + return function (request, response) { + let statusCode = 200; + let status = "OK"; + let body = "{\"meta\": 123456789010}"; + baseHandler(eolCode, request, response, statusCode, status, body); + }; +} + +function sync_httpd_setup(infoHandler) { + let handlers = { + "/1.1/johndoe/info/collections": infoHandler, + }; + return httpd_setup(handlers); +} + +function* setUp(server) { + yield configureIdentity({username: "johndoe"}); + Service.serverURL = server.baseURI + "/"; + Service.clusterURL = server.baseURI + "/"; + new FakeCryptoService(); +} + +function run_test() { + run_next_test(); +} + +function do_check_soft_eol(eh, start) { + // We subtract 1000 because the stored value is in second precision. + do_check_true(eh.earliestNextAlert >= (start + eh.MINIMUM_ALERT_INTERVAL_MSEC - 1000)); + do_check_eq("soft-eol", eh.currentAlertMode); +} +function do_check_hard_eol(eh, start) { + // We subtract 1000 because the stored value is in second precision. + do_check_true(eh.earliestNextAlert >= (start + eh.MINIMUM_ALERT_INTERVAL_MSEC - 1000)); + do_check_eq("hard-eol", eh.currentAlertMode); + do_check_true(Status.eol); +} + +add_identity_test(this, function* test_200_hard() { + let eh = Service.errorHandler; + let start = Date.now(); + let server = sync_httpd_setup(handler200("hard-eol")); + yield setUp(server); + + let deferred = Promise.defer(); + let obs = function (subject, topic, data) { + Svc.Obs.remove("weave:eol", obs); + do_check_eq("hard-eol", subject.code); + do_check_hard_eol(eh, start); + do_check_eq(Service.scheduler.eolInterval, Service.scheduler.syncInterval); + eh.clearServerAlerts(); + server.stop(deferred.resolve); + }; + + Svc.Obs.add("weave:eol", obs); + Service._fetchInfo(); + Service.scheduler.adjustSyncInterval(); // As if we failed or succeeded in syncing. + yield deferred.promise; +}); + +add_identity_test(this, function* test_513_hard() { + let eh = Service.errorHandler; + let start = Date.now(); + let server = sync_httpd_setup(handler513); + yield setUp(server); + + let deferred = Promise.defer(); + let obs = function (subject, topic, data) { + Svc.Obs.remove("weave:eol", obs); + do_check_eq("hard-eol", subject.code); + do_check_hard_eol(eh, start); + do_check_eq(Service.scheduler.eolInterval, Service.scheduler.syncInterval); + eh.clearServerAlerts(); + server.stop(deferred.resolve); + }; + + Svc.Obs.add("weave:eol", obs); + try { + Service._fetchInfo(); + Service.scheduler.adjustSyncInterval(); // As if we failed or succeeded in syncing. + } catch (ex) { + // Because fetchInfo will fail on a 513. + } + yield deferred.promise; +}); + +add_identity_test(this, function* test_200_soft() { + let eh = Service.errorHandler; + let start = Date.now(); + let server = sync_httpd_setup(handler200("soft-eol")); + yield setUp(server); + + let deferred = Promise.defer(); + let obs = function (subject, topic, data) { + Svc.Obs.remove("weave:eol", obs); + do_check_eq("soft-eol", subject.code); + do_check_soft_eol(eh, start); + do_check_eq(Service.scheduler.singleDeviceInterval, Service.scheduler.syncInterval); + eh.clearServerAlerts(); + server.stop(deferred.resolve); + }; + + Svc.Obs.add("weave:eol", obs); + Service._fetchInfo(); + Service.scheduler.adjustSyncInterval(); // As if we failed or succeeded in syncing. + yield deferred.promise; +}); diff --git a/services/sync/tests/unit/test_errorhandler_filelog.js b/services/sync/tests/unit/test_errorhandler_filelog.js new file mode 100644 index 0000000000..993a478fd6 --- /dev/null +++ b/services/sync/tests/unit/test_errorhandler_filelog.js @@ -0,0 +1,370 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +Cu.import("resource://gre/modules/Log.jsm"); +Cu.import("resource://services-common/utils.js"); +Cu.import("resource://services-sync/service.js"); +Cu.import("resource://services-sync/util.js"); +Cu.import("resource://gre/modules/FileUtils.jsm"); +Cu.import("resource://gre/modules/NetUtil.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); + +const logsdir = FileUtils.getDir("ProfD", ["weave", "logs"], true); + +// Delay to wait before cleanup, to allow files to age. +// This is so large because the file timestamp granularity is per-second, and +// so otherwise we can end up with all of our files -- the ones we want to +// keep, and the ones we want to clean up -- having the same modified time. +const CLEANUP_DELAY = 2000; +const DELAY_BUFFER = 500; // Buffer for timers on different OS platforms. + +const PROLONGED_ERROR_DURATION = + (Svc.Prefs.get('errorhandler.networkFailureReportTimeout') * 2) * 1000; + +var errorHandler = Service.errorHandler; + +function setLastSync(lastSyncValue) { + Svc.Prefs.set("lastSync", (new Date(Date.now() - lastSyncValue)).toString()); +} + +function run_test() { + initTestLogging("Trace"); + + Log.repository.getLogger("Sync.LogManager").level = Log.Level.Trace; + Log.repository.getLogger("Sync.Service").level = Log.Level.Trace; + Log.repository.getLogger("Sync.SyncScheduler").level = Log.Level.Trace; + Log.repository.getLogger("Sync.ErrorHandler").level = Log.Level.Trace; + + validate_all_future_pings(); + + run_next_test(); +} + +add_test(function test_noOutput() { + // Ensure that the log appender won't print anything. + errorHandler._logManager._fileAppender.level = Log.Level.Fatal + 1; + + // Clear log output from startup. + Svc.Prefs.set("log.appender.file.logOnSuccess", false); + Svc.Obs.notify("weave:service:sync:finish"); + Svc.Obs.add("weave:service:reset-file-log", function onResetFileLogOuter() { + Svc.Obs.remove("weave:service:reset-file-log", onResetFileLogOuter); + // Clear again without having issued any output. + Svc.Prefs.set("log.appender.file.logOnSuccess", true); + + Svc.Obs.add("weave:service:reset-file-log", function onResetFileLogInner() { + Svc.Obs.remove("weave:service:reset-file-log", onResetFileLogInner); + + errorHandler._logManager._fileAppender.level = Log.Level.Trace; + Svc.Prefs.resetBranch(""); + run_next_test(); + }); + + // Fake a successful sync. + Svc.Obs.notify("weave:service:sync:finish"); + }); +}); + +add_test(function test_logOnSuccess_false() { + Svc.Prefs.set("log.appender.file.logOnSuccess", false); + + let log = Log.repository.getLogger("Sync.Test.FileLog"); + log.info("this won't show up"); + + Svc.Obs.add("weave:service:reset-file-log", function onResetFileLog() { + Svc.Obs.remove("weave:service:reset-file-log", onResetFileLog); + // No log file was written. + do_check_false(logsdir.directoryEntries.hasMoreElements()); + + Svc.Prefs.resetBranch(""); + run_next_test(); + }); + + // Fake a successful sync. + Svc.Obs.notify("weave:service:sync:finish"); +}); + +function readFile(file, callback) { + NetUtil.asyncFetch({ + uri: NetUtil.newURI(file), + loadUsingSystemPrincipal: true + }, function (inputStream, statusCode, request) { + let data = NetUtil.readInputStreamToString(inputStream, + inputStream.available()); + callback(statusCode, data); + }); +} + +add_test(function test_logOnSuccess_true() { + Svc.Prefs.set("log.appender.file.logOnSuccess", true); + + let log = Log.repository.getLogger("Sync.Test.FileLog"); + const MESSAGE = "this WILL show up"; + log.info(MESSAGE); + + Svc.Obs.add("weave:service:reset-file-log", function onResetFileLog() { + Svc.Obs.remove("weave:service:reset-file-log", onResetFileLog); + + // Exactly one log file was written. + let entries = logsdir.directoryEntries; + do_check_true(entries.hasMoreElements()); + let logfile = entries.getNext().QueryInterface(Ci.nsILocalFile); + do_check_eq(logfile.leafName.slice(-4), ".txt"); + do_check_true(logfile.leafName.startsWith("success-sync-"), logfile.leafName); + do_check_false(entries.hasMoreElements()); + + // Ensure the log message was actually written to file. + readFile(logfile, function (error, data) { + do_check_true(Components.isSuccessCode(error)); + do_check_neq(data.indexOf(MESSAGE), -1); + + // Clean up. + try { + logfile.remove(false); + } catch(ex) { + dump("Couldn't delete file: " + ex + "\n"); + // Stupid Windows box. + } + + Svc.Prefs.resetBranch(""); + run_next_test(); + }); + }); + + // Fake a successful sync. + Svc.Obs.notify("weave:service:sync:finish"); +}); + +add_test(function test_sync_error_logOnError_false() { + Svc.Prefs.set("log.appender.file.logOnError", false); + + let log = Log.repository.getLogger("Sync.Test.FileLog"); + log.info("this won't show up"); + + Svc.Obs.add("weave:service:reset-file-log", function onResetFileLog() { + Svc.Obs.remove("weave:service:reset-file-log", onResetFileLog); + // No log file was written. + do_check_false(logsdir.directoryEntries.hasMoreElements()); + + Svc.Prefs.resetBranch(""); + run_next_test(); + }); + + // Fake an unsuccessful sync due to prolonged failure. + setLastSync(PROLONGED_ERROR_DURATION); + Svc.Obs.notify("weave:service:sync:error"); +}); + +add_test(function test_sync_error_logOnError_true() { + Svc.Prefs.set("log.appender.file.logOnError", true); + + let log = Log.repository.getLogger("Sync.Test.FileLog"); + const MESSAGE = "this WILL show up"; + log.info(MESSAGE); + + // We need to wait until the log cleanup started by this test is complete + // or the next test will fail as it is ongoing. + Svc.Obs.add("services-tests:common:log-manager:cleanup-logs", function onCleanupLogs() { + Svc.Obs.remove("services-tests:common:log-manager:cleanup-logs", onCleanupLogs); + run_next_test(); + }); + + Svc.Obs.add("weave:service:reset-file-log", function onResetFileLog() { + Svc.Obs.remove("weave:service:reset-file-log", onResetFileLog); + + // Exactly one log file was written. + let entries = logsdir.directoryEntries; + do_check_true(entries.hasMoreElements()); + let logfile = entries.getNext().QueryInterface(Ci.nsILocalFile); + do_check_eq(logfile.leafName.slice(-4), ".txt"); + do_check_true(logfile.leafName.startsWith("error-sync-"), logfile.leafName); + do_check_false(entries.hasMoreElements()); + + // Ensure the log message was actually written to file. + readFile(logfile, function (error, data) { + do_check_true(Components.isSuccessCode(error)); + do_check_neq(data.indexOf(MESSAGE), -1); + + // Clean up. + try { + logfile.remove(false); + } catch(ex) { + dump("Couldn't delete file: " + ex + "\n"); + // Stupid Windows box. + } + + Svc.Prefs.resetBranch(""); + }); + }); + + // Fake an unsuccessful sync due to prolonged failure. + setLastSync(PROLONGED_ERROR_DURATION); + Svc.Obs.notify("weave:service:sync:error"); +}); + +add_test(function test_login_error_logOnError_false() { + Svc.Prefs.set("log.appender.file.logOnError", false); + + let log = Log.repository.getLogger("Sync.Test.FileLog"); + log.info("this won't show up"); + + Svc.Obs.add("weave:service:reset-file-log", function onResetFileLog() { + Svc.Obs.remove("weave:service:reset-file-log", onResetFileLog); + // No log file was written. + do_check_false(logsdir.directoryEntries.hasMoreElements()); + + Svc.Prefs.resetBranch(""); + run_next_test(); + }); + + // Fake an unsuccessful login due to prolonged failure. + setLastSync(PROLONGED_ERROR_DURATION); + Svc.Obs.notify("weave:service:login:error"); +}); + +add_test(function test_login_error_logOnError_true() { + Svc.Prefs.set("log.appender.file.logOnError", true); + + let log = Log.repository.getLogger("Sync.Test.FileLog"); + const MESSAGE = "this WILL show up"; + log.info(MESSAGE); + + // We need to wait until the log cleanup started by this test is complete + // or the next test will fail as it is ongoing. + Svc.Obs.add("services-tests:common:log-manager:cleanup-logs", function onCleanupLogs() { + Svc.Obs.remove("services-tests:common:log-manager:cleanup-logs", onCleanupLogs); + run_next_test(); + }); + + Svc.Obs.add("weave:service:reset-file-log", function onResetFileLog() { + Svc.Obs.remove("weave:service:reset-file-log", onResetFileLog); + + // Exactly one log file was written. + let entries = logsdir.directoryEntries; + do_check_true(entries.hasMoreElements()); + let logfile = entries.getNext().QueryInterface(Ci.nsILocalFile); + do_check_eq(logfile.leafName.slice(-4), ".txt"); + do_check_true(logfile.leafName.startsWith("error-sync-"), logfile.leafName); + do_check_false(entries.hasMoreElements()); + + // Ensure the log message was actually written to file. + readFile(logfile, function (error, data) { + do_check_true(Components.isSuccessCode(error)); + do_check_neq(data.indexOf(MESSAGE), -1); + + // Clean up. + try { + logfile.remove(false); + } catch(ex) { + dump("Couldn't delete file: " + ex + "\n"); + // Stupid Windows box. + } + + Svc.Prefs.resetBranch(""); + }); + }); + + // Fake an unsuccessful login due to prolonged failure. + setLastSync(PROLONGED_ERROR_DURATION); + Svc.Obs.notify("weave:service:login:error"); +}); + + +add_test(function test_errorLog_dumpAddons() { + Svc.Prefs.set("log.appender.file.logOnError", true); + + let log = Log.repository.getLogger("Sync.Test.FileLog"); + + // We need to wait until the log cleanup started by this test is complete + // or the next test will fail as it is ongoing. + Svc.Obs.add("services-tests:common:log-manager:cleanup-logs", function onCleanupLogs() { + Svc.Obs.remove("services-tests:common:log-manager:cleanup-logs", onCleanupLogs); + run_next_test(); + }); + + Svc.Obs.add("weave:service:reset-file-log", function onResetFileLog() { + Svc.Obs.remove("weave:service:reset-file-log", onResetFileLog); + + let entries = logsdir.directoryEntries; + do_check_true(entries.hasMoreElements()); + let logfile = entries.getNext().QueryInterface(Ci.nsILocalFile); + do_check_eq(logfile.leafName.slice(-4), ".txt"); + do_check_true(logfile.leafName.startsWith("error-sync-"), logfile.leafName); + do_check_false(entries.hasMoreElements()); + + // Ensure we logged some addon list (which is probably empty) + readFile(logfile, function (error, data) { + do_check_true(Components.isSuccessCode(error)); + do_check_neq(data.indexOf("Addons installed"), -1); + + // Clean up. + try { + logfile.remove(false); + } catch(ex) { + dump("Couldn't delete file: " + ex + "\n"); + // Stupid Windows box. + } + + Svc.Prefs.resetBranch(""); + }); + }); + + // Fake an unsuccessful sync due to prolonged failure. + setLastSync(PROLONGED_ERROR_DURATION); + Svc.Obs.notify("weave:service:sync:error"); +}); + +// Check that error log files are deleted above an age threshold. +add_test(function test_logErrorCleanup_age() { + _("Beginning test_logErrorCleanup_age."); + let maxAge = CLEANUP_DELAY / 1000; + let oldLogs = []; + let numLogs = 10; + let errString = "some error log\n"; + + Svc.Prefs.set("log.appender.file.logOnError", true); + Svc.Prefs.set("log.appender.file.maxErrorAge", maxAge); + + _("Making some files."); + for (let i = 0; i < numLogs; i++) { + let now = Date.now(); + let filename = "error-sync-" + now + "" + i + ".txt"; + let newLog = FileUtils.getFile("ProfD", ["weave", "logs", filename]); + let foStream = FileUtils.openFileOutputStream(newLog); + foStream.write(errString, errString.length); + foStream.close(); + _(" > Created " + filename); + oldLogs.push(newLog.leafName); + } + + Svc.Obs.add("services-tests:common:log-manager:cleanup-logs", function onCleanupLogs() { + Svc.Obs.remove("services-tests:common:log-manager:cleanup-logs", onCleanupLogs); + + // Only the newest created log file remains. + let entries = logsdir.directoryEntries; + do_check_true(entries.hasMoreElements()); + let logfile = entries.getNext().QueryInterface(Ci.nsILocalFile); + do_check_true(oldLogs.every(function (e) { + return e != logfile.leafName; + })); + do_check_false(entries.hasMoreElements()); + + // Clean up. + try { + logfile.remove(false); + } catch(ex) { + dump("Couldn't delete file: " + ex + "\n"); + // Stupid Windows box. + } + + Svc.Prefs.resetBranch(""); + run_next_test(); + }); + + let delay = CLEANUP_DELAY + DELAY_BUFFER; + + _("Cleaning up logs after " + delay + "msec."); + CommonUtils.namedTimer(function onTimer() { + Svc.Obs.notify("weave:service:sync:error"); + }, delay, this, "cleanup-timer"); +}); diff --git a/services/sync/tests/unit/test_errorhandler_sync_checkServerError.js b/services/sync/tests/unit/test_errorhandler_sync_checkServerError.js new file mode 100644 index 0000000000..953f59fcba --- /dev/null +++ b/services/sync/tests/unit/test_errorhandler_sync_checkServerError.js @@ -0,0 +1,282 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +Cu.import("resource://services-sync/constants.js"); +Cu.import("resource://services-sync/engines.js"); +Cu.import("resource://services-sync/policies.js"); +Cu.import("resource://services-sync/record.js"); +Cu.import("resource://services-sync/service.js"); +Cu.import("resource://services-sync/status.js"); +Cu.import("resource://services-sync/util.js"); +Cu.import("resource://testing-common/services/sync/fakeservices.js"); +Cu.import("resource://testing-common/services/sync/utils.js"); + +initTestLogging("Trace"); + +var engineManager = Service.engineManager; +engineManager.clear(); + +function promiseStopServer(server) { + let deferred = Promise.defer(); + server.stop(deferred.resolve); + return deferred.promise; +} + +function CatapultEngine() { + SyncEngine.call(this, "Catapult", Service); +} +CatapultEngine.prototype = { + __proto__: SyncEngine.prototype, + exception: null, // tests fill this in + _sync: function _sync() { + throw this.exception; + } +}; + +function sync_httpd_setup() { + let collectionsHelper = track_collections_helper(); + let upd = collectionsHelper.with_updated_collection; + let collections = collectionsHelper.collections; + + let catapultEngine = engineManager.get("catapult"); + let engines = {catapult: {version: catapultEngine.version, + syncID: catapultEngine.syncID}}; + + // Track these using the collections helper, which keeps modified times + // up-to-date. + let clientsColl = new ServerCollection({}, true); + let keysWBO = new ServerWBO("keys"); + let globalWBO = new ServerWBO("global", {storageVersion: STORAGE_VERSION, + syncID: Utils.makeGUID(), + engines: engines}); + + let handlers = { + "/1.1/johndoe/info/collections": collectionsHelper.handler, + "/1.1/johndoe/storage/meta/global": upd("meta", globalWBO.handler()), + "/1.1/johndoe/storage/clients": upd("clients", clientsColl.handler()), + "/1.1/johndoe/storage/crypto/keys": upd("crypto", keysWBO.handler()) + }; + return httpd_setup(handlers); +} + +function* setUp(server) { + yield configureIdentity({username: "johndoe"}); + Service.serverURL = server.baseURI + "/"; + Service.clusterURL = server.baseURI + "/"; + new FakeCryptoService(); +} + +function generateAndUploadKeys(server) { + generateNewKeys(Service.collectionKeys); + let serverKeys = Service.collectionKeys.asWBO("crypto", "keys"); + serverKeys.encrypt(Service.identity.syncKeyBundle); + let res = Service.resource(server.baseURI + "/1.1/johndoe/storage/crypto/keys"); + return serverKeys.upload(res).success; +} + + +add_identity_test(this, function* test_backoff500() { + _("Test: HTTP 500 sets backoff status."); + let server = sync_httpd_setup(); + yield setUp(server); + + let engine = engineManager.get("catapult"); + engine.enabled = true; + engine.exception = {status: 500}; + + try { + do_check_false(Status.enforceBackoff); + + // Forcibly create and upload keys here -- otherwise we don't get to the 500! + do_check_true(generateAndUploadKeys(server)); + + Service.login(); + Service.sync(); + do_check_true(Status.enforceBackoff); + do_check_eq(Status.sync, SYNC_SUCCEEDED); + do_check_eq(Status.service, SYNC_FAILED_PARTIAL); + } finally { + Status.resetBackoff(); + Service.startOver(); + } + yield promiseStopServer(server); +}); + +add_identity_test(this, function* test_backoff503() { + _("Test: HTTP 503 with Retry-After header leads to backoff notification and sets backoff status."); + let server = sync_httpd_setup(); + yield setUp(server); + + const BACKOFF = 42; + let engine = engineManager.get("catapult"); + engine.enabled = true; + engine.exception = {status: 503, + headers: {"retry-after": BACKOFF}}; + + let backoffInterval; + Svc.Obs.add("weave:service:backoff:interval", function (subject) { + backoffInterval = subject; + }); + + try { + do_check_false(Status.enforceBackoff); + + do_check_true(generateAndUploadKeys(server)); + + Service.login(); + Service.sync(); + + do_check_true(Status.enforceBackoff); + do_check_eq(backoffInterval, BACKOFF); + do_check_eq(Status.service, SYNC_FAILED_PARTIAL); + do_check_eq(Status.sync, SERVER_MAINTENANCE); + } finally { + Status.resetBackoff(); + Status.resetSync(); + Service.startOver(); + } + yield promiseStopServer(server); +}); + +add_identity_test(this, function* test_overQuota() { + _("Test: HTTP 400 with body error code 14 means over quota."); + let server = sync_httpd_setup(); + yield setUp(server); + + let engine = engineManager.get("catapult"); + engine.enabled = true; + engine.exception = {status: 400, + toString() { + return "14"; + }}; + + try { + do_check_eq(Status.sync, SYNC_SUCCEEDED); + + do_check_true(generateAndUploadKeys(server)); + + Service.login(); + Service.sync(); + + do_check_eq(Status.sync, OVER_QUOTA); + do_check_eq(Status.service, SYNC_FAILED_PARTIAL); + } finally { + Status.resetSync(); + Service.startOver(); + } + yield promiseStopServer(server); +}); + +add_identity_test(this, function* test_service_networkError() { + _("Test: Connection refused error from Service.sync() leads to the right status code."); + let server = sync_httpd_setup(); + yield setUp(server); + let deferred = Promise.defer(); + server.stop(() => { + // Provoke connection refused. + Service.clusterURL = "http://localhost:12345/"; + + try { + do_check_eq(Status.sync, SYNC_SUCCEEDED); + + Service._loggedIn = true; + Service.sync(); + + do_check_eq(Status.sync, LOGIN_FAILED_NETWORK_ERROR); + do_check_eq(Status.service, SYNC_FAILED); + } finally { + Status.resetSync(); + Service.startOver(); + } + deferred.resolve(); + }); + yield deferred.promise; +}); + +add_identity_test(this, function* test_service_offline() { + _("Test: Wanting to sync in offline mode leads to the right status code but does not increment the ignorable error count."); + let server = sync_httpd_setup(); + yield setUp(server); + let deferred = Promise.defer(); + server.stop(() => { + Services.io.offline = true; + Services.prefs.setBoolPref("network.dns.offline-localhost", false); + + try { + do_check_eq(Status.sync, SYNC_SUCCEEDED); + + Service._loggedIn = true; + Service.sync(); + + do_check_eq(Status.sync, LOGIN_FAILED_NETWORK_ERROR); + do_check_eq(Status.service, SYNC_FAILED); + } finally { + Status.resetSync(); + Service.startOver(); + } + Services.io.offline = false; + Services.prefs.clearUserPref("network.dns.offline-localhost"); + deferred.resolve(); + }); + yield deferred.promise; +}); + +add_identity_test(this, function* test_engine_networkError() { + _("Test: Network related exceptions from engine.sync() lead to the right status code."); + let server = sync_httpd_setup(); + yield setUp(server); + + let engine = engineManager.get("catapult"); + engine.enabled = true; + engine.exception = Components.Exception("NS_ERROR_UNKNOWN_HOST", + Cr.NS_ERROR_UNKNOWN_HOST); + + try { + do_check_eq(Status.sync, SYNC_SUCCEEDED); + + do_check_true(generateAndUploadKeys(server)); + + Service.login(); + Service.sync(); + + do_check_eq(Status.sync, LOGIN_FAILED_NETWORK_ERROR); + do_check_eq(Status.service, SYNC_FAILED_PARTIAL); + } finally { + Status.resetSync(); + Service.startOver(); + } + yield promiseStopServer(server); +}); + +add_identity_test(this, function* test_resource_timeout() { + let server = sync_httpd_setup(); + yield setUp(server); + + let engine = engineManager.get("catapult"); + engine.enabled = true; + // Resource throws this when it encounters a timeout. + engine.exception = Components.Exception("Aborting due to channel inactivity.", + Cr.NS_ERROR_NET_TIMEOUT); + + try { + do_check_eq(Status.sync, SYNC_SUCCEEDED); + + do_check_true(generateAndUploadKeys(server)); + + Service.login(); + Service.sync(); + + do_check_eq(Status.sync, LOGIN_FAILED_NETWORK_ERROR); + do_check_eq(Status.service, SYNC_FAILED_PARTIAL); + } finally { + Status.resetSync(); + Service.startOver(); + } + yield promiseStopServer(server); +}); + +function run_test() { + validate_all_future_pings(); + engineManager.register(CatapultEngine); + run_next_test(); +} diff --git a/services/sync/tests/unit/test_extension_storage_crypto.js b/services/sync/tests/unit/test_extension_storage_crypto.js new file mode 100644 index 0000000000..f93e4970dc --- /dev/null +++ b/services/sync/tests/unit/test_extension_storage_crypto.js @@ -0,0 +1,93 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +Cu.import("resource://services-crypto/utils.js"); +Cu.import("resource://services-sync/engines/extension-storage.js"); +Cu.import("resource://services-sync/util.js"); + +/** + * Like Assert.throws, but for generators. + * + * @param {string | Object | function} constraint + * What to use to check the exception. + * @param {function} f + * The function to call. + */ +function* throwsGen(constraint, f) { + let threw = false; + let exception; + try { + yield* f(); + } + catch (e) { + threw = true; + exception = e; + } + + ok(threw, "did not throw an exception"); + + const debuggingMessage = `got ${exception}, expected ${constraint}`; + let message = exception; + if (typeof exception === "object") { + message = exception.message; + } + + if (typeof constraint === "function") { + ok(constraint(message), debuggingMessage); + } else { + ok(constraint === message, debuggingMessage); + } + +} + +/** + * An EncryptionRemoteTransformer that uses a fixed key bundle, + * suitable for testing. + */ +class StaticKeyEncryptionRemoteTransformer extends EncryptionRemoteTransformer { + constructor(keyBundle) { + super(); + this.keyBundle = keyBundle; + } + + getKeys() { + return Promise.resolve(this.keyBundle); + } +} +const BORING_KB = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; +const STRETCHED_KEY = CryptoUtils.hkdf(BORING_KB, undefined, `testing storage.sync encryption`, 2*32); +const KEY_BUNDLE = { + sha256HMACHasher: Utils.makeHMACHasher(Ci.nsICryptoHMAC.SHA256, Utils.makeHMACKey(STRETCHED_KEY.slice(0, 32))), + encryptionKeyB64: btoa(STRETCHED_KEY.slice(32, 64)), +}; +const transformer = new StaticKeyEncryptionRemoteTransformer(KEY_BUNDLE); + +add_task(function* test_encryption_transformer_roundtrip() { + const POSSIBLE_DATAS = [ + "string", + 2, // number + [1, 2, 3], // array + {key: "value"}, // object + ]; + + for (let data of POSSIBLE_DATAS) { + const record = {data: data, id: "key-some_2D_key", key: "some-key"}; + + deepEqual(record, yield transformer.decode(yield transformer.encode(record))); + } +}); + +add_task(function* test_refuses_to_decrypt_tampered() { + const encryptedRecord = yield transformer.encode({data: [1, 2, 3], id: "key-some_2D_key", key: "some-key"}); + const tamperedHMAC = Object.assign({}, encryptedRecord, {hmac: "0000000000000000000000000000000000000000000000000000000000000001"}); + yield* throwsGen(Utils.isHMACMismatch, function*() { + yield transformer.decode(tamperedHMAC); + }); + + const tamperedIV = Object.assign({}, encryptedRecord, {IV: "aaaaaaaaaaaaaaaaaaaaaa=="}); + yield* throwsGen(Utils.isHMACMismatch, function*() { + yield transformer.decode(tamperedIV); + }); +}); diff --git a/services/sync/tests/unit/test_extension_storage_engine.js b/services/sync/tests/unit/test_extension_storage_engine.js new file mode 100644 index 0000000000..1b27927033 --- /dev/null +++ b/services/sync/tests/unit/test_extension_storage_engine.js @@ -0,0 +1,62 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +Cu.import("resource://services-sync/engines.js"); +Cu.import("resource://services-sync/engines/extension-storage.js"); +Cu.import("resource://services-sync/service.js"); +Cu.import("resource://services-sync/util.js"); +Cu.import("resource://testing-common/services/sync/utils.js"); +Cu.import("resource://gre/modules/ExtensionStorageSync.jsm"); + +Service.engineManager.register(ExtensionStorageEngine); +const engine = Service.engineManager.get("extension-storage"); +do_get_profile(); // so we can use FxAccounts +loadWebExtensionTestFunctions(); + +function mock(options) { + let calls = []; + let ret = function() { + calls.push(arguments); + return options.returns; + } + Object.setPrototypeOf(ret, { + __proto__: Function.prototype, + get calls() { + return calls; + } + }); + return ret; +} + +add_task(function* test_calling_sync_calls__sync() { + let oldSync = ExtensionStorageEngine.prototype._sync; + let syncMock = ExtensionStorageEngine.prototype._sync = mock({returns: true}); + try { + // I wanted to call the main sync entry point for the entire + // package, but that fails because it tries to sync ClientEngine + // first, which fails. + yield engine.sync(); + } finally { + ExtensionStorageEngine.prototype._sync = oldSync; + } + equal(syncMock.calls.length, 1); +}); + +add_task(function* test_calling_sync_calls_ext_storage_sync() { + const extension = {id: "my-extension"}; + let oldSync = ExtensionStorageSync.syncAll; + let syncMock = ExtensionStorageSync.syncAll = mock({returns: Promise.resolve()}); + try { + yield* withSyncContext(function* (context) { + // Set something so that everyone knows that we're using storage.sync + yield ExtensionStorageSync.set(extension, {"a": "b"}, context); + + yield engine._sync(); + }); + } finally { + ExtensionStorageSync.syncAll = oldSync; + } + do_check_true(syncMock.calls.length >= 1); +}); diff --git a/services/sync/tests/unit/test_extension_storage_tracker.js b/services/sync/tests/unit/test_extension_storage_tracker.js new file mode 100644 index 0000000000..fac51a8977 --- /dev/null +++ b/services/sync/tests/unit/test_extension_storage_tracker.js @@ -0,0 +1,38 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +Cu.import("resource://services-sync/constants.js"); +Cu.import("resource://services-sync/engines.js"); +Cu.import("resource://services-sync/engines/extension-storage.js"); +Cu.import("resource://services-sync/service.js"); +Cu.import("resource://services-sync/util.js"); +Cu.import("resource://gre/modules/ExtensionStorageSync.jsm"); + +Service.engineManager.register(ExtensionStorageEngine); +const engine = Service.engineManager.get("extension-storage"); +do_get_profile(); // so we can use FxAccounts +loadWebExtensionTestFunctions(); + +add_task(function* test_changing_extension_storage_changes_score() { + const tracker = engine._tracker; + const extension = {id: "my-extension-id"}; + Svc.Obs.notify("weave:engine:start-tracking"); + yield* withSyncContext(function*(context) { + yield ExtensionStorageSync.set(extension, {"a": "b"}, context); + }); + do_check_eq(tracker.score, SCORE_INCREMENT_MEDIUM); + + tracker.resetScore(); + yield* withSyncContext(function*(context) { + yield ExtensionStorageSync.remove(extension, "a", context); + }); + do_check_eq(tracker.score, SCORE_INCREMENT_MEDIUM); + + Svc.Obs.notify("weave:engine:stop-tracking"); +}); + +function run_test() { + run_next_test(); +} diff --git a/services/sync/tests/unit/test_forms_store.js b/services/sync/tests/unit/test_forms_store.js new file mode 100644 index 0000000000..6963df1c0b --- /dev/null +++ b/services/sync/tests/unit/test_forms_store.js @@ -0,0 +1,151 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +_("Make sure the form store follows the Store api and correctly accesses the backend form storage"); +Cu.import("resource://services-sync/engines/forms.js"); +Cu.import("resource://services-sync/service.js"); +Cu.import("resource://services-sync/util.js"); +Cu.import("resource://gre/modules/Services.jsm"); + +function run_test() { + let baseuri = "http://fake/uri/"; + let engine = new FormEngine(Service); + let store = engine._store; + + function applyEnsureNoFailures(records) { + do_check_eq(store.applyIncomingBatch(records).length, 0); + } + + _("Remove any existing entries"); + store.wipe(); + for (let id in store.getAllIDs()) { + do_throw("Shouldn't get any ids!"); + } + + _("Add a form entry"); + applyEnsureNoFailures([{ + id: Utils.makeGUID(), + name: "name!!", + value: "value??" + }]); + + _("Should have 1 entry now"); + let id = ""; + for (let _id in store.getAllIDs()) { + if (id == "") + id = _id; + else + do_throw("Should have only gotten one!"); + } + do_check_true(store.itemExists(id)); + + _("Should be able to find this entry as a dupe"); + do_check_eq(engine._findDupe({name: "name!!", value: "value??"}), id); + + let rec = store.createRecord(id); + _("Got record for id", id, rec); + do_check_eq(rec.name, "name!!"); + do_check_eq(rec.value, "value??"); + + _("Create a non-existent id for delete"); + do_check_true(store.createRecord("deleted!!").deleted); + + _("Try updating.. doesn't do anything yet"); + store.update({}); + + _("Remove all entries"); + store.wipe(); + for (let id in store.getAllIDs()) { + do_throw("Shouldn't get any ids!"); + } + + _("Add another entry"); + applyEnsureNoFailures([{ + id: Utils.makeGUID(), + name: "another", + value: "entry" + }]); + id = ""; + for (let _id in store.getAllIDs()) { + if (id == "") + id = _id; + else + do_throw("Should have only gotten one!"); + } + + _("Change the id of the new entry to something else"); + store.changeItemID(id, "newid"); + + _("Make sure it's there"); + do_check_true(store.itemExists("newid")); + + _("Remove the entry"); + store.remove({ + id: "newid" + }); + for (let id in store.getAllIDs()) { + do_throw("Shouldn't get any ids!"); + } + + _("Removing the entry again shouldn't matter"); + store.remove({ + id: "newid" + }); + for (let id in store.getAllIDs()) { + do_throw("Shouldn't get any ids!"); + } + + _("Add another entry to delete using applyIncomingBatch"); + let toDelete = { + id: Utils.makeGUID(), + name: "todelete", + value: "entry" + }; + applyEnsureNoFailures([toDelete]); + id = ""; + for (let _id in store.getAllIDs()) { + if (id == "") + id = _id; + else + do_throw("Should have only gotten one!"); + } + do_check_true(store.itemExists(id)); + // mark entry as deleted + toDelete.id = id; + toDelete.deleted = true; + applyEnsureNoFailures([toDelete]); + for (let id in store.getAllIDs()) { + do_throw("Shouldn't get any ids!"); + } + + _("Add an entry to wipe"); + applyEnsureNoFailures([{ + id: Utils.makeGUID(), + name: "towipe", + value: "entry" + }]); + + store.wipe(); + + for (let id in store.getAllIDs()) { + do_throw("Shouldn't get any ids!"); + } + + _("Ensure we work if formfill is disabled."); + Services.prefs.setBoolPref("browser.formfill.enable", false); + try { + // a search + for (let id in store.getAllIDs()) { + do_throw("Shouldn't get any ids!"); + } + // an update. + applyEnsureNoFailures([{ + id: Utils.makeGUID(), + name: "some", + value: "entry" + }]); + } finally { + Services.prefs.clearUserPref("browser.formfill.enable"); + store.wipe(); + } +} diff --git a/services/sync/tests/unit/test_forms_tracker.js b/services/sync/tests/unit/test_forms_tracker.js new file mode 100644 index 0000000000..f14e208b38 --- /dev/null +++ b/services/sync/tests/unit/test_forms_tracker.js @@ -0,0 +1,72 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +Cu.import("resource://gre/modules/Log.jsm"); +Cu.import("resource://services-sync/engines/forms.js"); +Cu.import("resource://services-sync/service.js"); +Cu.import("resource://services-sync/util.js"); + +function run_test() { + _("Verify we've got an empty tracker to work with."); + let engine = new FormEngine(Service); + let tracker = engine._tracker; + // Don't do asynchronous writes. + tracker.persistChangedIDs = false; + + do_check_empty(tracker.changedIDs); + Log.repository.rootLogger.addAppender(new Log.DumpAppender()); + + function addEntry(name, value) { + engine._store.create({name: name, value: value}); + } + function removeEntry(name, value) { + guid = engine._findDupe({name: name, value: value}); + engine._store.remove({id: guid}); + } + + try { + _("Create an entry. Won't show because we haven't started tracking yet"); + addEntry("name", "John Doe"); + do_check_empty(tracker.changedIDs); + + _("Tell the tracker to start tracking changes."); + Svc.Obs.notify("weave:engine:start-tracking"); + removeEntry("name", "John Doe"); + addEntry("email", "john@doe.com"); + do_check_attribute_count(tracker.changedIDs, 2); + + _("Notifying twice won't do any harm."); + Svc.Obs.notify("weave:engine:start-tracking"); + addEntry("address", "Memory Lane"); + do_check_attribute_count(tracker.changedIDs, 3); + + + _("Check that ignoreAll is respected"); + tracker.clearChangedIDs(); + tracker.score = 0; + tracker.ignoreAll = true; + addEntry("username", "johndoe123"); + addEntry("favoritecolor", "green"); + removeEntry("name", "John Doe"); + tracker.ignoreAll = false; + do_check_empty(tracker.changedIDs); + equal(tracker.score, 0); + + _("Let's stop tracking again."); + tracker.clearChangedIDs(); + Svc.Obs.notify("weave:engine:stop-tracking"); + removeEntry("address", "Memory Lane"); + do_check_empty(tracker.changedIDs); + + _("Notifying twice won't do any harm."); + Svc.Obs.notify("weave:engine:stop-tracking"); + removeEntry("email", "john@doe.com"); + do_check_empty(tracker.changedIDs); + + + + } finally { + _("Clean up."); + engine._store.wipe(); + } +} diff --git a/services/sync/tests/unit/test_fxa_migration.js b/services/sync/tests/unit/test_fxa_migration.js new file mode 100644 index 0000000000..0ca770e284 --- /dev/null +++ b/services/sync/tests/unit/test_fxa_migration.js @@ -0,0 +1,117 @@ +// We change this pref before anything else initializes +Services.prefs.setCharPref("identity.fxaccounts.auth.uri", "http://localhost"); + +// Test the FxAMigration module +Cu.import("resource://services-sync/FxaMigrator.jsm"); +Cu.import("resource://gre/modules/Promise.jsm"); + +// Set our username pref early so sync initializes with the legacy provider. +Services.prefs.setCharPref("services.sync.username", "foo"); +// And ensure all debug messages end up being printed. +Services.prefs.setCharPref("services.sync.log.appender.dump", "Debug"); + +// Now import sync +Cu.import("resource://services-sync/service.js"); +Cu.import("resource://services-sync/record.js"); +Cu.import("resource://services-sync/util.js"); + +// And reset the username. +Services.prefs.clearUserPref("services.sync.username"); + +Cu.import("resource://testing-common/services/sync/utils.js"); +Cu.import("resource://testing-common/services/common/logging.js"); +Cu.import("resource://testing-common/services/sync/rotaryengine.js"); + +const FXA_USERNAME = "someone@somewhere"; + +// Utilities +function promiseOneObserver(topic) { + return new Promise((resolve, reject) => { + let observer = function(subject, topic, data) { + Services.obs.removeObserver(observer, topic); + resolve({ subject: subject, data: data }); + } + Services.obs.addObserver(observer, topic, false); + }); +} + +function promiseStopServer(server) { + return new Promise((resolve, reject) => { + server.stop(resolve); + }); +} + + +// Helpers +function configureLegacySync() { + let engine = new RotaryEngine(Service); + engine.enabled = true; + Svc.Prefs.set("registerEngines", engine.name); + Svc.Prefs.set("log.logger.engine.rotary", "Trace"); + + let contents = { + meta: {global: {engines: {rotary: {version: engine.version, + syncID: engine.syncID}}}}, + crypto: {}, + rotary: {} + }; + + const USER = "foo"; + const PASSPHRASE = "abcdeabcdeabcdeabcdeabcdea"; + + setBasicCredentials(USER, "password", PASSPHRASE); + + let onRequest = function(request, response) { + // ideally we'd only do this while a legacy user is configured, but WTH. + response.setHeader("x-weave-alert", JSON.stringify({code: "soft-eol"})); + } + let server = new SyncServer({onRequest: onRequest}); + server.registerUser(USER, "password"); + server.createContents(USER, contents); + server.start(); + + Service.serverURL = server.baseURI; + Service.clusterURL = server.baseURI; + Service.identity.username = USER; + Service._updateCachedURLs(); + + Service.engineManager._engines[engine.name] = engine; + + return [engine, server]; +} + +add_task(function *testMigrationUnlinks() { + + // when we do a .startOver we want the new provider. + let oldValue = Services.prefs.getBoolPref("services.sync-testing.startOverKeepIdentity"); + Services.prefs.setBoolPref("services.sync-testing.startOverKeepIdentity", false); + + do_register_cleanup(() => { + Services.prefs.setBoolPref("services.sync-testing.startOverKeepIdentity", oldValue) + }); + + // Arrange for a legacy sync user. + let [engine, server] = configureLegacySync(); + + // Start a sync - this will cause an EOL notification which the migrator's + // observer will notice. + let promiseMigration = promiseOneObserver("fxa-migration:state-changed"); + let promiseStartOver = promiseOneObserver("weave:service:start-over:finish"); + _("Starting sync"); + Service.sync(); + _("Finished sync"); + + yield promiseStartOver; + yield promiseMigration; + // We should have seen the observer and Sync should no longer be configured. + Assert.ok(!Services.prefs.prefHasUserValue("services.sync.username")); +}); + +function run_test() { + initTestLogging(); + do_register_cleanup(() => { + fxaMigrator.finalize(); + Svc.Prefs.resetBranch(""); + }); + run_next_test(); +} diff --git a/services/sync/tests/unit/test_fxa_node_reassignment.js b/services/sync/tests/unit/test_fxa_node_reassignment.js new file mode 100644 index 0000000000..3e4cefd534 --- /dev/null +++ b/services/sync/tests/unit/test_fxa_node_reassignment.js @@ -0,0 +1,368 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +_("Test that node reassignment happens correctly using the FxA identity mgr."); +// The node-reassignment logic is quite different for FxA than for the legacy +// provider. In particular, there's no special request necessary for +// reassignment - it comes from the token server - so we need to ensure the +// Fxa cluster manager grabs a new token. + +Cu.import("resource://gre/modules/Log.jsm"); +Cu.import("resource://services-common/rest.js"); +Cu.import("resource://services-sync/constants.js"); +Cu.import("resource://services-sync/service.js"); +Cu.import("resource://services-sync/status.js"); +Cu.import("resource://services-sync/util.js"); +Cu.import("resource://testing-common/services/sync/rotaryengine.js"); +Cu.import("resource://services-sync/browserid_identity.js"); +Cu.import("resource://testing-common/services/sync/utils.js"); + +Service.engineManager.clear(); + +function run_test() { + Log.repository.getLogger("Sync.AsyncResource").level = Log.Level.Trace; + Log.repository.getLogger("Sync.ErrorHandler").level = Log.Level.Trace; + Log.repository.getLogger("Sync.Resource").level = Log.Level.Trace; + Log.repository.getLogger("Sync.RESTRequest").level = Log.Level.Trace; + Log.repository.getLogger("Sync.Service").level = Log.Level.Trace; + Log.repository.getLogger("Sync.SyncScheduler").level = Log.Level.Trace; + initTestLogging(); + + Service.engineManager.register(RotaryEngine); + + // Setup the FxA identity manager and cluster manager. + Status.__authManager = Service.identity = new BrowserIDManager(); + Service._clusterManager = Service.identity.createClusterManager(Service); + + // None of the failures in this file should result in a UI error. + function onUIError() { + do_throw("Errors should not be presented in the UI."); + } + Svc.Obs.add("weave:ui:login:error", onUIError); + Svc.Obs.add("weave:ui:sync:error", onUIError); + + run_next_test(); +} + + +// API-compatible with SyncServer handler. Bind `handler` to something to use +// as a ServerCollection handler. +function handleReassign(handler, req, resp) { + resp.setStatusLine(req.httpVersion, 401, "Node reassignment"); + resp.setHeader("Content-Type", "application/json"); + let reassignBody = JSON.stringify({error: "401inator in place"}); + resp.bodyOutputStream.write(reassignBody, reassignBody.length); +} + +var numTokenRequests = 0; + +function prepareServer(cbAfterTokenFetch) { + let config = makeIdentityConfig({username: "johndoe"}); + // A server callback to ensure we don't accidentally hit the wrong endpoint + // after a node reassignment. + let callback = { + __proto__: SyncServerCallback, + onRequest(req, resp) { + let full = `${req.scheme}://${req.host}:${req.port}${req.path}`; + do_check_true(full.startsWith(config.fxaccount.token.endpoint), + `request made to ${full}`); + } + } + let server = new SyncServer(callback); + server.registerUser("johndoe"); + server.start(); + + // Set the token endpoint for the initial token request that's done implicitly + // via configureIdentity. + config.fxaccount.token.endpoint = server.baseURI + "1.1/johndoe/"; + // And future token fetches will do magic around numReassigns. + let numReassigns = 0; + return configureIdentity(config).then(() => { + Service.identity._tokenServerClient = { + getTokenFromBrowserIDAssertion: function(uri, assertion, cb) { + // Build a new URL with trailing zeros for the SYNC_VERSION part - this + // will still be seen as equivalent by the test server, but different + // by sync itself. + numReassigns += 1; + let trailingZeros = new Array(numReassigns + 1).join('0'); + let token = config.fxaccount.token; + token.endpoint = server.baseURI + "1.1" + trailingZeros + "/johndoe"; + token.uid = config.username; + numTokenRequests += 1; + cb(null, token); + if (cbAfterTokenFetch) { + cbAfterTokenFetch(); + } + }, + }; + return server; + }); +} + +function getReassigned() { + try { + return Services.prefs.getBoolPref("services.sync.lastSyncReassigned"); + } catch (ex) { + if (ex.result == Cr.NS_ERROR_UNEXPECTED) { + return false; + } + do_throw("Got exception retrieving lastSyncReassigned: " + + Log.exceptionStr(ex)); + } +} + +/** + * Make a test request to `url`, then watch the result of two syncs + * to ensure that a node request was made. + * Runs `between` between the two. This can be used to undo deliberate failure + * setup, detach observers, etc. + */ +function* syncAndExpectNodeReassignment(server, firstNotification, between, + secondNotification, url) { + _("Starting syncAndExpectNodeReassignment\n"); + let deferred = Promise.defer(); + function onwards() { + let numTokenRequestsBefore; + function onFirstSync() { + _("First sync completed."); + Svc.Obs.remove(firstNotification, onFirstSync); + Svc.Obs.add(secondNotification, onSecondSync); + + do_check_eq(Service.clusterURL, ""); + + // Track whether we fetched a new token. + numTokenRequestsBefore = numTokenRequests; + + // Allow for tests to clean up error conditions. + between(); + } + function onSecondSync() { + _("Second sync completed."); + Svc.Obs.remove(secondNotification, onSecondSync); + Service.scheduler.clearSyncTriggers(); + + // Make absolutely sure that any event listeners are done with their work + // before we proceed. + waitForZeroTimer(function () { + _("Second sync nextTick."); + do_check_eq(numTokenRequests, numTokenRequestsBefore + 1, "fetched a new token"); + Service.startOver(); + server.stop(deferred.resolve); + }); + } + + Svc.Obs.add(firstNotification, onFirstSync); + Service.sync(); + } + + // Make sure that we really do get a 401 (but we can only do that if we are + // already logged in, as the login process is what sets up the URLs) + if (Service.isLoggedIn) { + _("Making request to " + url + " which should 401"); + let request = new RESTRequest(url); + request.get(function () { + do_check_eq(request.response.status, 401); + Utils.nextTick(onwards); + }); + } else { + _("Skipping preliminary validation check for a 401 as we aren't logged in"); + Utils.nextTick(onwards); + } + yield deferred.promise; +} + +// Check that when we sync we don't request a new token by default - our +// test setup has configured the client with a valid token, and that token +// should be used to form the cluster URL. +add_task(function* test_single_token_fetch() { + _("Test a normal sync only fetches 1 token"); + + let numTokenFetches = 0; + + function afterTokenFetch() { + numTokenFetches++; + } + + // Set the cluster URL to an "old" version - this is to ensure we don't + // use that old cached version for the first sync but prefer the value + // we got from the token (and as above, we are also checking we don't grab + // a new token). If the test actually attempts to connect to this URL + // it will crash. + Service.clusterURL = "http://example.com/"; + + let server = yield prepareServer(afterTokenFetch); + + do_check_false(Service.isLoggedIn, "not already logged in"); + Service.sync(); + do_check_eq(Status.sync, SYNC_SUCCEEDED, "sync succeeded"); + do_check_eq(numTokenFetches, 0, "didn't fetch a new token"); + // A bit hacky, but given we know how prepareServer works we can deduce + // that clusterURL we expect. + let expectedClusterURL = server.baseURI + "1.1/johndoe/"; + do_check_eq(Service.clusterURL, expectedClusterURL); + yield new Promise(resolve => server.stop(resolve)); +}); + +add_task(function* test_momentary_401_engine() { + _("Test a failure for engine URLs that's resolved by reassignment."); + let server = yield prepareServer(); + let john = server.user("johndoe"); + + _("Enabling the Rotary engine."); + let engine = Service.engineManager.get("rotary"); + engine.enabled = true; + + // We need the server to be correctly set up prior to experimenting. Do this + // through a sync. + let global = {syncID: Service.syncID, + storageVersion: STORAGE_VERSION, + rotary: {version: engine.version, + syncID: engine.syncID}} + john.createCollection("meta").insert("global", global); + + _("First sync to prepare server contents."); + Service.sync(); + + _("Setting up Rotary collection to 401."); + let rotary = john.createCollection("rotary"); + let oldHandler = rotary.collectionHandler; + rotary.collectionHandler = handleReassign.bind(this, undefined); + + // We want to verify that the clusterURL pref has been cleared after a 401 + // inside a sync. Flag the Rotary engine to need syncing. + john.collection("rotary").timestamp += 1000; + + function between() { + _("Undoing test changes."); + rotary.collectionHandler = oldHandler; + + function onLoginStart() { + // lastSyncReassigned shouldn't be cleared until a sync has succeeded. + _("Ensuring that lastSyncReassigned is still set at next sync start."); + Svc.Obs.remove("weave:service:login:start", onLoginStart); + do_check_true(getReassigned()); + } + + _("Adding observer that lastSyncReassigned is still set on login."); + Svc.Obs.add("weave:service:login:start", onLoginStart); + } + + yield syncAndExpectNodeReassignment(server, + "weave:service:sync:finish", + between, + "weave:service:sync:finish", + Service.storageURL + "rotary"); +}); + +// This test ends up being a failing info fetch *after we're already logged in*. +add_task(function* test_momentary_401_info_collections_loggedin() { + _("Test a failure for info/collections after login that's resolved by reassignment."); + let server = yield prepareServer(); + + _("First sync to prepare server contents."); + Service.sync(); + + _("Arrange for info/collections to return a 401."); + let oldHandler = server.toplevelHandlers.info; + server.toplevelHandlers.info = handleReassign; + + function undo() { + _("Undoing test changes."); + server.toplevelHandlers.info = oldHandler; + } + + do_check_true(Service.isLoggedIn, "already logged in"); + + yield syncAndExpectNodeReassignment(server, + "weave:service:sync:error", + undo, + "weave:service:sync:finish", + Service.infoURL); +}); + +// This test ends up being a failing info fetch *before we're logged in*. +// In this case we expect to recover during the login phase - so the first +// sync succeeds. +add_task(function* test_momentary_401_info_collections_loggedout() { + _("Test a failure for info/collections before login that's resolved by reassignment."); + + let oldHandler; + let sawTokenFetch = false; + + function afterTokenFetch() { + // After a single token fetch, we undo our evil handleReassign hack, so + // the next /info request returns the collection instead of a 401 + server.toplevelHandlers.info = oldHandler; + sawTokenFetch = true; + } + + let server = yield prepareServer(afterTokenFetch); + + // Return a 401 for the next /info request - it will be reset immediately + // after a new token is fetched. + oldHandler = server.toplevelHandlers.info + server.toplevelHandlers.info = handleReassign; + + do_check_false(Service.isLoggedIn, "not already logged in"); + + Service.sync(); + do_check_eq(Status.sync, SYNC_SUCCEEDED, "sync succeeded"); + // sync was successful - check we grabbed a new token. + do_check_true(sawTokenFetch, "a new token was fetched by this test.") + // and we are done. + Service.startOver(); + let deferred = Promise.defer(); + server.stop(deferred.resolve); + yield deferred.promise; +}); + +// This test ends up being a failing meta/global fetch *after we're already logged in*. +add_task(function* test_momentary_401_storage_loggedin() { + _("Test a failure for any storage URL after login that's resolved by" + + "reassignment."); + let server = yield prepareServer(); + + _("First sync to prepare server contents."); + Service.sync(); + + _("Arrange for meta/global to return a 401."); + let oldHandler = server.toplevelHandlers.storage; + server.toplevelHandlers.storage = handleReassign; + + function undo() { + _("Undoing test changes."); + server.toplevelHandlers.storage = oldHandler; + } + + do_check_true(Service.isLoggedIn, "already logged in"); + + yield syncAndExpectNodeReassignment(server, + "weave:service:sync:error", + undo, + "weave:service:sync:finish", + Service.storageURL + "meta/global"); +}); + +// This test ends up being a failing meta/global fetch *before we've logged in*. +add_task(function* test_momentary_401_storage_loggedout() { + _("Test a failure for any storage URL before login, not just engine parts. " + + "Resolved by reassignment."); + let server = yield prepareServer(); + + // Return a 401 for all storage requests. + let oldHandler = server.toplevelHandlers.storage; + server.toplevelHandlers.storage = handleReassign; + + function undo() { + _("Undoing test changes."); + server.toplevelHandlers.storage = oldHandler; + } + + do_check_false(Service.isLoggedIn, "already logged in"); + + yield syncAndExpectNodeReassignment(server, + "weave:service:login:error", + undo, + "weave:service:sync:finish", + Service.storageURL + "meta/global"); +}); diff --git a/services/sync/tests/unit/test_fxa_service_cluster.js b/services/sync/tests/unit/test_fxa_service_cluster.js new file mode 100644 index 0000000000..b4f83a7fe6 --- /dev/null +++ b/services/sync/tests/unit/test_fxa_service_cluster.js @@ -0,0 +1,68 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +Cu.import("resource://services-sync/service.js"); +Cu.import("resource://services-sync/util.js"); +Cu.import("resource://testing-common/services/sync/fxa_utils.js"); +Cu.import("resource://testing-common/services/sync/utils.js"); + +add_task(function* test_findCluster() { + _("Test FxA _findCluster()"); + + _("_findCluster() throws on 500 errors."); + initializeIdentityWithTokenServerResponse({ + status: 500, + headers: [], + body: "", + }); + + yield Service.identity.initializeWithCurrentIdentity(); + yield Assert.rejects(Service.identity.whenReadyToAuthenticate.promise, + "should reject due to 500"); + + Assert.throws(function() { + Service._clusterManager._findCluster(); + }); + + _("_findCluster() returns null on authentication errors."); + initializeIdentityWithTokenServerResponse({ + status: 401, + headers: {"content-type": "application/json"}, + body: "{}", + }); + + yield Service.identity.initializeWithCurrentIdentity(); + yield Assert.rejects(Service.identity.whenReadyToAuthenticate.promise, + "should reject due to 401"); + + cluster = Service._clusterManager._findCluster(); + Assert.strictEqual(cluster, null); + + _("_findCluster() works with correct tokenserver response."); + let endpoint = "http://example.com/something"; + initializeIdentityWithTokenServerResponse({ + status: 200, + headers: {"content-type": "application/json"}, + body: + JSON.stringify({ + api_endpoint: endpoint, + duration: 300, + id: "id", + key: "key", + uid: "uid", + }) + }); + + yield Service.identity.initializeWithCurrentIdentity(); + yield Service.identity.whenReadyToAuthenticate.promise; + cluster = Service._clusterManager._findCluster(); + // The cluster manager ensures a trailing "/" + Assert.strictEqual(cluster, endpoint + "/"); + + Svc.Prefs.resetBranch(""); +}); + +function run_test() { + initTestLogging(); + run_next_test(); +} diff --git a/services/sync/tests/unit/test_fxa_startOver.js b/services/sync/tests/unit/test_fxa_startOver.js new file mode 100644 index 0000000000..6293796484 --- /dev/null +++ b/services/sync/tests/unit/test_fxa_startOver.js @@ -0,0 +1,63 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +Cu.import("resource://testing-common/services/sync/utils.js"); +Cu.import("resource://services-sync/identity.js"); +Cu.import("resource://services-sync/browserid_identity.js"); +Cu.import("resource://services-sync/service.js"); + +function run_test() { + initTestLogging("Trace"); + run_next_test(); +} + +add_task(function* test_startover() { + let oldValue = Services.prefs.getBoolPref("services.sync-testing.startOverKeepIdentity", true); + Services.prefs.setBoolPref("services.sync-testing.startOverKeepIdentity", false); + + ensureLegacyIdentityManager(); + yield configureIdentity({username: "johndoe"}); + + // The boolean flag on the xpcom service should reflect a legacy provider. + let xps = Cc["@mozilla.org/weave/service;1"] + .getService(Components.interfaces.nsISupports) + .wrappedJSObject; + do_check_false(xps.fxAccountsEnabled); + + // we expect the "legacy" provider (but can't instanceof that, as BrowserIDManager + // extends it) + do_check_false(Service.identity instanceof BrowserIDManager); + + Service.serverURL = "https://localhost/"; + Service.clusterURL = Service.serverURL; + + Service.login(); + // We should have a cluster URL + do_check_true(Service.clusterURL.length > 0); + + // remember some stuff so we can reset it after. + let oldIdentity = Service.identity; + let oldClusterManager = Service._clusterManager; + let deferred = Promise.defer(); + Services.obs.addObserver(function observeStartOverFinished() { + Services.obs.removeObserver(observeStartOverFinished, "weave:service:start-over:finish"); + deferred.resolve(); + }, "weave:service:start-over:finish", false); + + Service.startOver(); + yield deferred.promise; // wait for the observer to fire. + + // the xpcom service should indicate FxA is enabled. + do_check_true(xps.fxAccountsEnabled); + // should have swapped identities. + do_check_true(Service.identity instanceof BrowserIDManager); + // should have clobbered the cluster URL + do_check_eq(Service.clusterURL, ""); + + // we should have thrown away the old identity provider and cluster manager. + do_check_neq(oldIdentity, Service.identity); + do_check_neq(oldClusterManager, Service._clusterManager); + + // reset the world. + Services.prefs.setBoolPref("services.sync-testing.startOverKeepIdentity", oldValue); +}); diff --git a/services/sync/tests/unit/test_history_engine.js b/services/sync/tests/unit/test_history_engine.js new file mode 100644 index 0000000000..fd5067ce95 --- /dev/null +++ b/services/sync/tests/unit/test_history_engine.js @@ -0,0 +1,147 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +Cu.import("resource://services-sync/constants.js"); +Cu.import("resource://services-sync/engines/history.js"); +Cu.import("resource://services-sync/engines.js"); +Cu.import("resource://services-sync/identity.js"); +Cu.import("resource://services-sync/record.js"); +Cu.import("resource://services-sync/service.js"); +Cu.import("resource://services-sync/util.js"); +Cu.import("resource://testing-common/services/sync/utils.js"); + +Service.engineManager.clear(); + +add_test(function test_setup() { + PlacesTestUtils.clearHistory().then(run_next_test); +}); + +add_test(function test_processIncoming_mobile_history_batched() { + _("SyncEngine._processIncoming works on history engine."); + + let FAKE_DOWNLOAD_LIMIT = 100; + + Svc.Prefs.set("client.type", "mobile"); + Service.engineManager.register(HistoryEngine); + + // A collection that logs each GET + let collection = new ServerCollection(); + collection.get_log = []; + collection._get = collection.get; + collection.get = function (options) { + this.get_log.push(options); + return this._get(options); + }; + + let server = sync_httpd_setup({ + "/1.1/foo/storage/history": collection.handler() + }); + + new SyncTestingInfrastructure(server); + + // Let's create some 234 server side history records. They're all at least + // 10 minutes old. + let visitType = Ci.nsINavHistoryService.TRANSITION_LINK; + for (var i = 0; i < 234; i++) { + let id = 'record-no' + ("00" + i).slice(-3); + let modified = Date.now()/1000 - 60*(i+10); + let payload = encryptPayload({ + id: id, + histUri: "http://foo/bar?" + id, + title: id, + sortindex: i, + visits: [{date: (modified - 5) * 1000000, type: visitType}], + deleted: false}); + + let wbo = new ServerWBO(id, payload); + wbo.modified = modified; + collection.insertWBO(wbo); + } + + let engine = Service.engineManager.get("history"); + let meta_global = Service.recordManager.set(engine.metaURL, + new WBORecord(engine.metaURL)); + meta_global.payload.engines = {history: {version: engine.version, + syncID: engine.syncID}}; + + try { + + _("On a mobile client, we get new records from the server in batches of 50."); + engine._syncStartup(); + + // Fake a lower limit. + engine.downloadLimit = FAKE_DOWNLOAD_LIMIT; + _("Last modified: " + engine.lastModified); + _("Processing..."); + engine._processIncoming(); + + _("Last modified: " + engine.lastModified); + engine._syncFinish(); + + // Back to the normal limit. + _("Running again. Should fetch none, because of lastModified"); + engine.downloadLimit = MAX_HISTORY_DOWNLOAD; + _("Processing..."); + engine._processIncoming(); + + _("Last modified: " + engine.lastModified); + _("Running again. Expecting to pull everything"); + + engine.lastModified = undefined; + engine.lastSync = 0; + _("Processing..."); + engine._processIncoming(); + + _("Last modified: " + engine.lastModified); + + // Verify that the right number of GET requests with the right + // kind of parameters were made. + do_check_eq(collection.get_log.length, + // First try: + 1 + // First 50... + 1 + // 1 GUID fetch... + // 1 fetch... + Math.ceil((FAKE_DOWNLOAD_LIMIT - 50) / MOBILE_BATCH_SIZE) + + // Second try: none + // Third try: + 1 + // First 50... + 1 + // 1 GUID fetch... + // 4 fetch... + Math.ceil((234 - 50) / MOBILE_BATCH_SIZE)); + + // Check the structure of each HTTP request. + do_check_eq(collection.get_log[0].full, 1); + do_check_eq(collection.get_log[0].limit, MOBILE_BATCH_SIZE); + do_check_eq(collection.get_log[1].full, undefined); + do_check_eq(collection.get_log[1].sort, "index"); + do_check_eq(collection.get_log[1].limit, FAKE_DOWNLOAD_LIMIT); + do_check_eq(collection.get_log[2].full, 1); + do_check_eq(collection.get_log[3].full, 1); + do_check_eq(collection.get_log[3].limit, MOBILE_BATCH_SIZE); + do_check_eq(collection.get_log[4].full, undefined); + do_check_eq(collection.get_log[4].sort, "index"); + do_check_eq(collection.get_log[4].limit, MAX_HISTORY_DOWNLOAD); + for (let i = 0; i <= Math.floor((234 - 50) / MOBILE_BATCH_SIZE); i++) { + let j = i + 5; + do_check_eq(collection.get_log[j].full, 1); + do_check_eq(collection.get_log[j].limit, undefined); + if (i < Math.floor((234 - 50) / MOBILE_BATCH_SIZE)) + do_check_eq(collection.get_log[j].ids.length, MOBILE_BATCH_SIZE); + else + do_check_eq(collection.get_log[j].ids.length, 234 % MOBILE_BATCH_SIZE); + } + + } finally { + PlacesTestUtils.clearHistory().then(() => { + server.stop(do_test_finished); + Svc.Prefs.resetBranch(""); + Service.recordManager.clearCache(); + }); + } +}); + +function run_test() { + generateNewKeys(Service.collectionKeys); + + run_next_test(); +} diff --git a/services/sync/tests/unit/test_history_store.js b/services/sync/tests/unit/test_history_store.js new file mode 100644 index 0000000000..207b621e07 --- /dev/null +++ b/services/sync/tests/unit/test_history_store.js @@ -0,0 +1,297 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +Cu.import("resource://gre/modules/PlacesUtils.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://services-common/async.js"); +Cu.import("resource://services-sync/engines/history.js"); +Cu.import("resource://services-sync/service.js"); +Cu.import("resource://services-sync/util.js"); + +const TIMESTAMP1 = (Date.now() - 103406528) * 1000; +const TIMESTAMP2 = (Date.now() - 6592903) * 1000; +const TIMESTAMP3 = (Date.now() - 123894) * 1000; + +function queryPlaces(uri, options) { + let query = PlacesUtils.history.getNewQuery(); + query.uri = uri; + let res = PlacesUtils.history.executeQuery(query, options); + res.root.containerOpen = true; + + let results = []; + for (let i = 0; i < res.root.childCount; i++) + results.push(res.root.getChild(i)); + res.root.containerOpen = false; + return results; +} + +function queryHistoryVisits(uri) { + let options = PlacesUtils.history.getNewQueryOptions(); + options.queryType = Ci.nsINavHistoryQueryOptions.QUERY_TYPE_HISTORY; + options.resultType = Ci.nsINavHistoryQueryOptions.RESULTS_AS_VISIT; + options.sortingMode = Ci.nsINavHistoryQueryOptions.SORT_BY_DATE_ASCENDING; + return queryPlaces(uri, options); +} + +function onNextTitleChanged(callback) { + PlacesUtils.history.addObserver({ + onBeginUpdateBatch: function onBeginUpdateBatch() {}, + onEndUpdateBatch: function onEndUpdateBatch() {}, + onPageChanged: function onPageChanged() {}, + onTitleChanged: function onTitleChanged() { + PlacesUtils.history.removeObserver(this); + Utils.nextTick(callback); + }, + onVisit: function onVisit() {}, + onDeleteVisits: function onDeleteVisits() {}, + onPageExpired: function onPageExpired() {}, + onDeleteURI: function onDeleteURI() {}, + onClearHistory: function onClearHistory() {}, + QueryInterface: XPCOMUtils.generateQI([ + Ci.nsINavHistoryObserver, + Ci.nsINavHistoryObserver_MOZILLA_1_9_1_ADDITIONS, + Ci.nsISupportsWeakReference + ]) + }, true); +} + +// Ensure exceptions from inside callbacks leads to test failures while +// we still clean up properly. +function ensureThrows(func) { + return function() { + try { + func.apply(this, arguments); + } catch (ex) { + PlacesTestUtils.clearHistory(); + do_throw(ex); + } + }; +} + +var store = new HistoryEngine(Service)._store; +function applyEnsureNoFailures(records) { + do_check_eq(store.applyIncomingBatch(records).length, 0); +} + +var fxuri, fxguid, tburi, tbguid; + +function run_test() { + initTestLogging("Trace"); + run_next_test(); +} + +add_test(function test_store() { + _("Verify that we've got an empty store to work with."); + do_check_empty(store.getAllIDs()); + + _("Let's create an entry in the database."); + fxuri = Utils.makeURI("http://getfirefox.com/"); + + let place = { + uri: fxuri, + title: "Get Firefox!", + visits: [{ + visitDate: TIMESTAMP1, + transitionType: Ci.nsINavHistoryService.TRANSITION_LINK + }] + }; + PlacesUtils.asyncHistory.updatePlaces(place, { + handleError: function handleError() { + do_throw("Unexpected error in adding visit."); + }, + handleResult: function handleResult() {}, + handleCompletion: onVisitAdded + }); + + function onVisitAdded() { + _("Verify that the entry exists."); + let ids = Object.keys(store.getAllIDs()); + do_check_eq(ids.length, 1); + fxguid = ids[0]; + do_check_true(store.itemExists(fxguid)); + + _("If we query a non-existent record, it's marked as deleted."); + let record = store.createRecord("non-existent"); + do_check_true(record.deleted); + + _("Verify createRecord() returns a complete record."); + record = store.createRecord(fxguid); + do_check_eq(record.histUri, fxuri.spec); + do_check_eq(record.title, "Get Firefox!"); + do_check_eq(record.visits.length, 1); + do_check_eq(record.visits[0].date, TIMESTAMP1); + do_check_eq(record.visits[0].type, Ci.nsINavHistoryService.TRANSITION_LINK); + + _("Let's modify the record and have the store update the database."); + let secondvisit = {date: TIMESTAMP2, + type: Ci.nsINavHistoryService.TRANSITION_TYPED}; + onNextTitleChanged(ensureThrows(function() { + let queryres = queryHistoryVisits(fxuri); + do_check_eq(queryres.length, 2); + do_check_eq(queryres[0].time, TIMESTAMP1); + do_check_eq(queryres[0].title, "Hol Dir Firefox!"); + do_check_eq(queryres[1].time, TIMESTAMP2); + do_check_eq(queryres[1].title, "Hol Dir Firefox!"); + run_next_test(); + })); + applyEnsureNoFailures([ + {id: fxguid, + histUri: record.histUri, + title: "Hol Dir Firefox!", + visits: [record.visits[0], secondvisit]} + ]); + } +}); + +add_test(function test_store_create() { + _("Create a brand new record through the store."); + tbguid = Utils.makeGUID(); + tburi = Utils.makeURI("http://getthunderbird.com"); + onNextTitleChanged(ensureThrows(function() { + do_check_attribute_count(store.getAllIDs(), 2); + let queryres = queryHistoryVisits(tburi); + do_check_eq(queryres.length, 1); + do_check_eq(queryres[0].time, TIMESTAMP3); + do_check_eq(queryres[0].title, "The bird is the word!"); + run_next_test(); + })); + applyEnsureNoFailures([ + {id: tbguid, + histUri: tburi.spec, + title: "The bird is the word!", + visits: [{date: TIMESTAMP3, + type: Ci.nsINavHistoryService.TRANSITION_TYPED}]} + ]); +}); + +add_test(function test_null_title() { + _("Make sure we handle a null title gracefully (it can happen in some cases, e.g. for resource:// URLs)"); + let resguid = Utils.makeGUID(); + let resuri = Utils.makeURI("unknown://title"); + applyEnsureNoFailures([ + {id: resguid, + histUri: resuri.spec, + title: null, + visits: [{date: TIMESTAMP3, + type: Ci.nsINavHistoryService.TRANSITION_TYPED}]} + ]); + do_check_attribute_count(store.getAllIDs(), 3); + let queryres = queryHistoryVisits(resuri); + do_check_eq(queryres.length, 1); + do_check_eq(queryres[0].time, TIMESTAMP3); + run_next_test(); +}); + +add_test(function test_invalid_records() { + _("Make sure we handle invalid URLs in places databases gracefully."); + let connection = PlacesUtils.history + .QueryInterface(Ci.nsPIPlacesDatabase) + .DBConnection; + let stmt = connection.createAsyncStatement( + "INSERT INTO moz_places " + + "(url, url_hash, title, rev_host, visit_count, last_visit_date) " + + "VALUES ('invalid-uri', hash('invalid-uri'), 'Invalid URI', '.', 1, " + TIMESTAMP3 + ")" + ); + Async.querySpinningly(stmt); + stmt.finalize(); + // Add the corresponding visit to retain database coherence. + stmt = connection.createAsyncStatement( + "INSERT INTO moz_historyvisits " + + "(place_id, visit_date, visit_type, session) " + + "VALUES ((SELECT id FROM moz_places WHERE url_hash = hash('invalid-uri') AND url = 'invalid-uri'), " + + TIMESTAMP3 + ", " + Ci.nsINavHistoryService.TRANSITION_TYPED + ", 1)" + ); + Async.querySpinningly(stmt); + stmt.finalize(); + do_check_attribute_count(store.getAllIDs(), 4); + + _("Make sure we report records with invalid URIs."); + let invalid_uri_guid = Utils.makeGUID(); + let failed = store.applyIncomingBatch([{ + id: invalid_uri_guid, + histUri: ":::::::::::::::", + title: "Doesn't have a valid URI", + visits: [{date: TIMESTAMP3, + type: Ci.nsINavHistoryService.TRANSITION_EMBED}]} + ]); + do_check_eq(failed.length, 1); + do_check_eq(failed[0], invalid_uri_guid); + + _("Make sure we handle records with invalid GUIDs gracefully (ignore)."); + applyEnsureNoFailures([ + {id: "invalid", + histUri: "http://invalid.guid/", + title: "Doesn't have a valid GUID", + visits: [{date: TIMESTAMP3, + type: Ci.nsINavHistoryService.TRANSITION_EMBED}]} + ]); + + _("Make sure we handle records with invalid visit codes or visit dates, gracefully ignoring those visits."); + let no_date_visit_guid = Utils.makeGUID(); + let no_type_visit_guid = Utils.makeGUID(); + let invalid_type_visit_guid = Utils.makeGUID(); + let non_integer_visit_guid = Utils.makeGUID(); + failed = store.applyIncomingBatch([ + {id: no_date_visit_guid, + histUri: "http://no.date.visit/", + title: "Visit has no date", + visits: [{type: Ci.nsINavHistoryService.TRANSITION_EMBED}]}, + {id: no_type_visit_guid, + histUri: "http://no.type.visit/", + title: "Visit has no type", + visits: [{date: TIMESTAMP3}]}, + {id: invalid_type_visit_guid, + histUri: "http://invalid.type.visit/", + title: "Visit has invalid type", + visits: [{date: TIMESTAMP3, + type: Ci.nsINavHistoryService.TRANSITION_LINK - 1}]}, + {id: non_integer_visit_guid, + histUri: "http://non.integer.visit/", + title: "Visit has non-integer date", + visits: [{date: 1234.567, + type: Ci.nsINavHistoryService.TRANSITION_EMBED}]} + ]); + do_check_eq(failed.length, 0); + + _("Make sure we handle records with javascript: URLs gracefully."); + applyEnsureNoFailures([ + {id: Utils.makeGUID(), + histUri: "javascript:''", + title: "javascript:''", + visits: [{date: TIMESTAMP3, + type: Ci.nsINavHistoryService.TRANSITION_EMBED}]} + ]); + + _("Make sure we handle records without any visits gracefully."); + applyEnsureNoFailures([ + {id: Utils.makeGUID(), + histUri: "http://getfirebug.com", + title: "Get Firebug!", + visits: []} + ]); + + run_next_test(); +}); + +add_test(function test_remove() { + _("Remove an existent record and a non-existent from the store."); + applyEnsureNoFailures([{id: fxguid, deleted: true}, + {id: Utils.makeGUID(), deleted: true}]); + do_check_false(store.itemExists(fxguid)); + let queryres = queryHistoryVisits(fxuri); + do_check_eq(queryres.length, 0); + + _("Make sure wipe works."); + store.wipe(); + do_check_empty(store.getAllIDs()); + queryres = queryHistoryVisits(fxuri); + do_check_eq(queryres.length, 0); + queryres = queryHistoryVisits(tburi); + do_check_eq(queryres.length, 0); + run_next_test(); +}); + +add_test(function cleanup() { + _("Clean up."); + PlacesTestUtils.clearHistory().then(run_next_test); +}); diff --git a/services/sync/tests/unit/test_history_tracker.js b/services/sync/tests/unit/test_history_tracker.js new file mode 100644 index 0000000000..5ed022fb0a --- /dev/null +++ b/services/sync/tests/unit/test_history_tracker.js @@ -0,0 +1,203 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +Cu.import("resource://gre/modules/PlacesUtils.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://services-sync/engines.js"); +Cu.import("resource://services-sync/constants.js"); +Cu.import("resource://services-sync/engines/history.js"); +Cu.import("resource://services-sync/service.js"); +Cu.import("resource://services-sync/util.js"); + +function onScoreUpdated(callback) { + Svc.Obs.add("weave:engine:score:updated", function observer() { + Svc.Obs.remove("weave:engine:score:updated", observer); + try { + callback(); + } catch (ex) { + do_throw(ex); + } + }); +} + +Service.engineManager.clear(); +Service.engineManager.register(HistoryEngine); +var engine = Service.engineManager.get("history"); +var tracker = engine._tracker; + +// Don't write out by default. +tracker.persistChangedIDs = false; + +var _counter = 0; +function addVisit() { + let uriString = "http://getfirefox.com/" + _counter++; + let uri = Utils.makeURI(uriString); + _("Adding visit for URI " + uriString); + let place = { + uri: uri, + visits: [ { + visitDate: Date.now() * 1000, + transitionType: PlacesUtils.history.TRANSITION_LINK + } ] + }; + + let cb = Async.makeSpinningCallback(); + PlacesUtils.asyncHistory.updatePlaces(place, { + handleError: function () { + _("Error adding visit for " + uriString); + cb(new Error("Error adding history entry")); + }, + + handleResult: function () { + }, + + handleCompletion: function () { + _("Added visit for " + uriString); + cb(); + } + }); + + // Spin the event loop to embed this async call in a sync API. + cb.wait(); + return uri; +} + +function run_test() { + initTestLogging("Trace"); + Log.repository.getLogger("Sync.Tracker.History").level = Log.Level.Trace; + run_next_test(); +} + +add_test(function test_empty() { + _("Verify we've got an empty, disabled tracker to work with."); + do_check_empty(tracker.changedIDs); + do_check_eq(tracker.score, 0); + do_check_false(tracker._isTracking); + run_next_test(); +}); + +add_test(function test_not_tracking(next) { + _("Create history item. Won't show because we haven't started tracking yet"); + addVisit(); + Utils.nextTick(function() { + do_check_empty(tracker.changedIDs); + do_check_eq(tracker.score, 0); + run_next_test(); + }); +}); + +add_test(function test_start_tracking() { + _("Add hook for save completion."); + tracker.persistChangedIDs = true; + tracker.onSavedChangedIDs = function () { + _("changedIDs written to disk. Proceeding."); + // Turn this back off. + tracker.persistChangedIDs = false; + delete tracker.onSavedChangedIDs; + run_next_test(); + }; + + _("Tell the tracker to start tracking changes."); + onScoreUpdated(function() { + _("Score updated in test_start_tracking."); + do_check_attribute_count(tracker.changedIDs, 1); + do_check_eq(tracker.score, SCORE_INCREMENT_SMALL); + }); + + Svc.Obs.notify("weave:engine:start-tracking"); + addVisit(); +}); + +add_test(function test_start_tracking_twice() { + _("Verifying preconditions from test_start_tracking."); + do_check_attribute_count(tracker.changedIDs, 1); + do_check_eq(tracker.score, SCORE_INCREMENT_SMALL); + + _("Notifying twice won't do any harm."); + onScoreUpdated(function() { + _("Score updated in test_start_tracking_twice."); + do_check_attribute_count(tracker.changedIDs, 2); + do_check_eq(tracker.score, 2 * SCORE_INCREMENT_SMALL); + run_next_test(); + }); + + Svc.Obs.notify("weave:engine:start-tracking"); + addVisit(); +}); + +add_test(function test_track_delete() { + _("Deletions are tracked."); + + // This isn't present because we weren't tracking when it was visited. + let uri = Utils.makeURI("http://getfirefox.com/0"); + let guid = engine._store.GUIDForUri(uri); + do_check_false(guid in tracker.changedIDs); + + onScoreUpdated(function() { + do_check_true(guid in tracker.changedIDs); + do_check_attribute_count(tracker.changedIDs, 3); + do_check_eq(tracker.score, SCORE_INCREMENT_XLARGE + 2 * SCORE_INCREMENT_SMALL); + run_next_test(); + }); + + do_check_eq(tracker.score, 2 * SCORE_INCREMENT_SMALL); + PlacesUtils.history.removePage(uri); +}); + +add_test(function test_dont_track_expiration() { + _("Expirations are not tracked."); + let uriToExpire = addVisit(); + let guidToExpire = engine._store.GUIDForUri(uriToExpire); + let uriToRemove = addVisit(); + let guidToRemove = engine._store.GUIDForUri(uriToRemove); + + tracker.clearChangedIDs(); + do_check_false(guidToExpire in tracker.changedIDs); + do_check_false(guidToRemove in tracker.changedIDs); + + onScoreUpdated(function() { + do_check_false(guidToExpire in tracker.changedIDs); + do_check_true(guidToRemove in tracker.changedIDs); + do_check_attribute_count(tracker.changedIDs, 1); + run_next_test(); + }); + + // Observe expiration. + Services.obs.addObserver(function onExpiration(aSubject, aTopic, aData) { + Services.obs.removeObserver(onExpiration, aTopic); + // Remove the remaining page to update its score. + PlacesUtils.history.removePage(uriToRemove); + }, PlacesUtils.TOPIC_EXPIRATION_FINISHED, false); + + // Force expiration of 1 entry. + Services.prefs.setIntPref("places.history.expiration.max_pages", 0); + Cc["@mozilla.org/places/expiration;1"] + .getService(Ci.nsIObserver) + .observe(null, "places-debug-start-expiration", 1); +}); + +add_test(function test_stop_tracking() { + _("Let's stop tracking again."); + tracker.clearChangedIDs(); + Svc.Obs.notify("weave:engine:stop-tracking"); + addVisit(); + Utils.nextTick(function() { + do_check_empty(tracker.changedIDs); + run_next_test(); + }); +}); + +add_test(function test_stop_tracking_twice() { + _("Notifying twice won't do any harm."); + Svc.Obs.notify("weave:engine:stop-tracking"); + addVisit(); + Utils.nextTick(function() { + do_check_empty(tracker.changedIDs); + run_next_test(); + }); +}); + +add_test(function cleanup() { + _("Clean up."); + PlacesTestUtils.clearHistory().then(run_next_test); +}); diff --git a/services/sync/tests/unit/test_hmac_error.js b/services/sync/tests/unit/test_hmac_error.js new file mode 100644 index 0000000000..272c0de470 --- /dev/null +++ b/services/sync/tests/unit/test_hmac_error.js @@ -0,0 +1,248 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +Cu.import("resource://services-sync/engines.js"); +Cu.import("resource://services-sync/service.js"); +Cu.import("resource://services-sync/util.js"); +Cu.import("resource://testing-common/services/sync/rotaryengine.js"); +Cu.import("resource://testing-common/services/sync/utils.js"); + +// Track HMAC error counts. +var hmacErrorCount = 0; +(function () { + let hHE = Service.handleHMACEvent; + Service.handleHMACEvent = function () { + hmacErrorCount++; + return hHE.call(Service); + }; +})(); + +function shared_setup() { + hmacErrorCount = 0; + + // Do not instantiate SyncTestingInfrastructure; we need real crypto. + ensureLegacyIdentityManager(); + setBasicCredentials("foo", "foo", "aabcdeabcdeabcdeabcdeabcde"); + + // Make sure RotaryEngine is the only one we sync. + Service.engineManager._engines = {}; + Service.engineManager.register(RotaryEngine); + let engine = Service.engineManager.get("rotary"); + engine.enabled = true; + engine.lastSync = 123; // Needs to be non-zero so that tracker is queried. + engine._store.items = {flying: "LNER Class A3 4472", + scotsman: "Flying Scotsman"}; + engine._tracker.addChangedID('scotsman', 0); + do_check_eq(1, Service.engineManager.getEnabled().length); + + let engines = {rotary: {version: engine.version, + syncID: engine.syncID}, + clients: {version: Service.clientsEngine.version, + syncID: Service.clientsEngine.syncID}}; + + // Common server objects. + let global = new ServerWBO("global", {engines: engines}); + let keysWBO = new ServerWBO("keys"); + let rotaryColl = new ServerCollection({}, true); + let clientsColl = new ServerCollection({}, true); + + return [engine, rotaryColl, clientsColl, keysWBO, global]; +} + +add_task(function *hmac_error_during_404() { + _("Attempt to replicate the HMAC error setup."); + let [engine, rotaryColl, clientsColl, keysWBO, global] = shared_setup(); + + // Hand out 404s for crypto/keys. + let keysHandler = keysWBO.handler(); + let key404Counter = 0; + let keys404Handler = function (request, response) { + if (key404Counter > 0) { + let body = "Not Found"; + response.setStatusLine(request.httpVersion, 404, body); + response.bodyOutputStream.write(body, body.length); + key404Counter--; + return; + } + keysHandler(request, response); + }; + + let collectionsHelper = track_collections_helper(); + let upd = collectionsHelper.with_updated_collection; + let collections = collectionsHelper.collections; + let handlers = { + "/1.1/foo/info/collections": collectionsHelper.handler, + "/1.1/foo/storage/meta/global": upd("meta", global.handler()), + "/1.1/foo/storage/crypto/keys": upd("crypto", keys404Handler), + "/1.1/foo/storage/clients": upd("clients", clientsColl.handler()), + "/1.1/foo/storage/rotary": upd("rotary", rotaryColl.handler()) + }; + + let server = sync_httpd_setup(handlers); + Service.serverURL = server.baseURI; + + try { + _("Syncing."); + yield sync_and_validate_telem(); + + _("Partially resetting client, as if after a restart, and forcing redownload."); + Service.collectionKeys.clear(); + engine.lastSync = 0; // So that we redownload records. + key404Counter = 1; + _("---------------------------"); + yield sync_and_validate_telem(); + _("---------------------------"); + + // Two rotary items, one client record... no errors. + do_check_eq(hmacErrorCount, 0) + } finally { + Svc.Prefs.resetBranch(""); + Service.recordManager.clearCache(); + yield new Promise(resolve => server.stop(resolve)); + } +}); + +add_test(function hmac_error_during_node_reassignment() { + _("Attempt to replicate an HMAC error during node reassignment."); + let [engine, rotaryColl, clientsColl, keysWBO, global] = shared_setup(); + + let collectionsHelper = track_collections_helper(); + let upd = collectionsHelper.with_updated_collection; + + // We'll provide a 401 mid-way through the sync. This function + // simulates shifting to a node which has no data. + function on401() { + _("Deleting server data..."); + global.delete(); + rotaryColl.delete(); + keysWBO.delete(); + clientsColl.delete(); + delete collectionsHelper.collections.rotary; + delete collectionsHelper.collections.crypto; + delete collectionsHelper.collections.clients; + _("Deleted server data."); + } + + let should401 = false; + function upd401(coll, handler) { + return function (request, response) { + if (should401 && (request.method != "DELETE")) { + on401(); + should401 = false; + let body = "\"reassigned!\""; + response.setStatusLine(request.httpVersion, 401, "Node reassignment."); + response.bodyOutputStream.write(body, body.length); + return; + } + handler(request, response); + }; + } + + function sameNodeHandler(request, response) { + // Set this so that _setCluster will think we've really changed. + let url = Service.serverURL.replace("localhost", "LOCALHOST"); + _("Client requesting reassignment; pointing them to " + url); + response.setStatusLine(request.httpVersion, 200, "OK"); + response.bodyOutputStream.write(url, url.length); + } + + let handlers = { + "/user/1.0/foo/node/weave": sameNodeHandler, + "/1.1/foo/info/collections": collectionsHelper.handler, + "/1.1/foo/storage/meta/global": upd("meta", global.handler()), + "/1.1/foo/storage/crypto/keys": upd("crypto", keysWBO.handler()), + "/1.1/foo/storage/clients": upd401("clients", clientsColl.handler()), + "/1.1/foo/storage/rotary": upd("rotary", rotaryColl.handler()) + }; + + let server = sync_httpd_setup(handlers); + Service.serverURL = server.baseURI; + _("Syncing."); + // First hit of clients will 401. This will happen after meta/global and + // keys -- i.e., in the middle of the sync, but before RotaryEngine. + should401 = true; + + // Use observers to perform actions when our sync finishes. + // This allows us to observe the automatic next-tick sync that occurs after + // an abort. + function onSyncError() { + do_throw("Should not get a sync error!"); + } + function onSyncFinished() {} + let obs = { + observe: function observe(subject, topic, data) { + switch (topic) { + case "weave:service:sync:error": + onSyncError(); + break; + case "weave:service:sync:finish": + onSyncFinished(); + break; + } + } + }; + + Svc.Obs.add("weave:service:sync:finish", obs); + Svc.Obs.add("weave:service:sync:error", obs); + + // This kicks off the actual test. Split into a function here to allow this + // source file to broadly follow actual execution order. + function onwards() { + _("== Invoking first sync."); + Service.sync(); + _("We should not simultaneously have data but no keys on the server."); + let hasData = rotaryColl.wbo("flying") || + rotaryColl.wbo("scotsman"); + let hasKeys = keysWBO.modified; + + _("We correctly handle 401s by aborting the sync and starting again."); + do_check_true(!hasData == !hasKeys); + + _("Be prepared for the second (automatic) sync..."); + } + + _("Make sure that syncing again causes recovery."); + onSyncFinished = function() { + _("== First sync done."); + _("---------------------------"); + onSyncFinished = function() { + _("== Second (automatic) sync done."); + hasData = rotaryColl.wbo("flying") || + rotaryColl.wbo("scotsman"); + hasKeys = keysWBO.modified; + do_check_true(!hasData == !hasKeys); + + // Kick off another sync. Can't just call it, because we're inside the + // lock... + Utils.nextTick(function() { + _("Now a fresh sync will get no HMAC errors."); + _("Partially resetting client, as if after a restart, and forcing redownload."); + Service.collectionKeys.clear(); + engine.lastSync = 0; + hmacErrorCount = 0; + + onSyncFinished = function() { + // Two rotary items, one client record... no errors. + do_check_eq(hmacErrorCount, 0) + + Svc.Obs.remove("weave:service:sync:finish", obs); + Svc.Obs.remove("weave:service:sync:error", obs); + + Svc.Prefs.resetBranch(""); + Service.recordManager.clearCache(); + server.stop(run_next_test); + }; + + Service.sync(); + }, + this); + }; + }; + + onwards(); +}); + +function run_test() { + initTestLogging("Trace"); + run_next_test(); +} diff --git a/services/sync/tests/unit/test_httpd_sync_server.js b/services/sync/tests/unit/test_httpd_sync_server.js new file mode 100644 index 0000000000..943dbfd738 --- /dev/null +++ b/services/sync/tests/unit/test_httpd_sync_server.js @@ -0,0 +1,285 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +Cu.import("resource://services-sync/util.js"); + +function run_test() { + Log.repository.getLogger("Sync.Test.Server").level = Log.Level.Trace; + initTestLogging(); + run_next_test(); +} + +add_test(function test_creation() { + // Explicit callback for this one. + let server = new SyncServer({ + __proto__: SyncServerCallback, + }); + do_check_true(!!server); // Just so we have a check. + server.start(null, function () { + _("Started on " + server.port); + server.stop(run_next_test); + }); +}); + +add_test(function test_url_parsing() { + let server = new SyncServer(); + + // Check that we can parse a WBO URI. + let parts = server.pathRE.exec("/1.1/johnsmith/storage/crypto/keys"); + let [all, version, username, first, rest] = parts; + do_check_eq(all, "/1.1/johnsmith/storage/crypto/keys"); + do_check_eq(version, "1.1"); + do_check_eq(username, "johnsmith"); + do_check_eq(first, "storage"); + do_check_eq(rest, "crypto/keys"); + do_check_eq(null, server.pathRE.exec("/nothing/else")); + + // Check that we can parse a collection URI. + parts = server.pathRE.exec("/1.1/johnsmith/storage/crypto"); + [all, version, username, first, rest] = parts; + do_check_eq(all, "/1.1/johnsmith/storage/crypto"); + do_check_eq(version, "1.1"); + do_check_eq(username, "johnsmith"); + do_check_eq(first, "storage"); + do_check_eq(rest, "crypto"); + + // We don't allow trailing slash on storage URI. + parts = server.pathRE.exec("/1.1/johnsmith/storage/"); + do_check_eq(parts, undefined); + + // storage alone is a valid request. + parts = server.pathRE.exec("/1.1/johnsmith/storage"); + [all, version, username, first, rest] = parts; + do_check_eq(all, "/1.1/johnsmith/storage"); + do_check_eq(version, "1.1"); + do_check_eq(username, "johnsmith"); + do_check_eq(first, "storage"); + do_check_eq(rest, undefined); + + parts = server.storageRE.exec("storage"); + let storage, collection, id; + [all, storage, collection, id] = parts; + do_check_eq(all, "storage"); + do_check_eq(collection, undefined); + + run_next_test(); +}); + +Cu.import("resource://services-common/rest.js"); +function localRequest(server, path) { + _("localRequest: " + path); + let url = server.baseURI.substr(0, server.baseURI.length - 1) + path; + _("url: " + url); + return new RESTRequest(url); +} + +add_test(function test_basic_http() { + let server = new SyncServer(); + server.registerUser("john", "password"); + do_check_true(server.userExists("john")); + server.start(null, function () { + _("Started on " + server.port); + Utils.nextTick(function () { + let req = localRequest(server, "/1.1/john/storage/crypto/keys"); + _("req is " + req); + req.get(function (err) { + do_check_eq(null, err); + Utils.nextTick(function () { + server.stop(run_next_test); + }); + }); + }); + }); +}); + +add_test(function test_info_collections() { + let server = new SyncServer({ + __proto__: SyncServerCallback + }); + function responseHasCorrectHeaders(r) { + do_check_eq(r.status, 200); + do_check_eq(r.headers["content-type"], "application/json"); + do_check_true("x-weave-timestamp" in r.headers); + } + + server.registerUser("john", "password"); + server.start(null, function () { + Utils.nextTick(function () { + let req = localRequest(server, "/1.1/john/info/collections"); + req.get(function (err) { + // Initial info/collections fetch is empty. + do_check_eq(null, err); + responseHasCorrectHeaders(this.response); + + do_check_eq(this.response.body, "{}"); + Utils.nextTick(function () { + // When we PUT something to crypto/keys, "crypto" appears in the response. + function cb(err) { + do_check_eq(null, err); + responseHasCorrectHeaders(this.response); + let putResponseBody = this.response.body; + _("PUT response body: " + JSON.stringify(putResponseBody)); + + req = localRequest(server, "/1.1/john/info/collections"); + req.get(function (err) { + do_check_eq(null, err); + responseHasCorrectHeaders(this.response); + let expectedColl = server.getCollection("john", "crypto"); + do_check_true(!!expectedColl); + let modified = expectedColl.timestamp; + do_check_true(modified > 0); + do_check_eq(putResponseBody, modified); + do_check_eq(JSON.parse(this.response.body).crypto, modified); + Utils.nextTick(function () { + server.stop(run_next_test); + }); + }); + } + let payload = JSON.stringify({foo: "bar"}); + localRequest(server, "/1.1/john/storage/crypto/keys").put(payload, cb); + }); + }); + }); + }); +}); + +add_test(function test_storage_request() { + let keysURL = "/1.1/john/storage/crypto/keys?foo=bar"; + let foosURL = "/1.1/john/storage/crypto/foos"; + let storageURL = "/1.1/john/storage"; + + let server = new SyncServer(); + let creation = server.timestamp(); + server.registerUser("john", "password"); + + server.createContents("john", { + crypto: {foos: {foo: "bar"}} + }); + let coll = server.user("john").collection("crypto"); + do_check_true(!!coll); + + _("We're tracking timestamps."); + do_check_true(coll.timestamp >= creation); + + function retrieveWBONotExists(next) { + let req = localRequest(server, keysURL); + req.get(function (err) { + _("Body is " + this.response.body); + _("Modified is " + this.response.newModified); + do_check_eq(null, err); + do_check_eq(this.response.status, 404); + do_check_eq(this.response.body, "Not found"); + Utils.nextTick(next); + }); + } + function retrieveWBOExists(next) { + let req = localRequest(server, foosURL); + req.get(function (err) { + _("Body is " + this.response.body); + _("Modified is " + this.response.newModified); + let parsedBody = JSON.parse(this.response.body); + do_check_eq(parsedBody.id, "foos"); + do_check_eq(parsedBody.modified, coll.wbo("foos").modified); + do_check_eq(JSON.parse(parsedBody.payload).foo, "bar"); + Utils.nextTick(next); + }); + } + function deleteWBONotExists(next) { + let req = localRequest(server, keysURL); + server.callback.onItemDeleted = function (username, collection, wboID) { + do_throw("onItemDeleted should not have been called."); + }; + + req.delete(function (err) { + _("Body is " + this.response.body); + _("Modified is " + this.response.newModified); + do_check_eq(this.response.status, 200); + delete server.callback.onItemDeleted; + Utils.nextTick(next); + }); + } + function deleteWBOExists(next) { + let req = localRequest(server, foosURL); + server.callback.onItemDeleted = function (username, collection, wboID) { + _("onItemDeleted called for " + collection + "/" + wboID); + delete server.callback.onItemDeleted; + do_check_eq(username, "john"); + do_check_eq(collection, "crypto"); + do_check_eq(wboID, "foos"); + Utils.nextTick(next); + }; + + req.delete(function (err) { + _("Body is " + this.response.body); + _("Modified is " + this.response.newModified); + do_check_eq(this.response.status, 200); + }); + } + function deleteStorage(next) { + _("Testing DELETE on /storage."); + let now = server.timestamp(); + _("Timestamp: " + now); + let req = localRequest(server, storageURL); + req.delete(function (err) { + _("Body is " + this.response.body); + _("Modified is " + this.response.newModified); + let parsedBody = JSON.parse(this.response.body); + do_check_true(parsedBody >= now); + do_check_empty(server.users["john"].collections); + Utils.nextTick(next); + }); + } + function getStorageFails(next) { + _("Testing that GET on /storage fails."); + let req = localRequest(server, storageURL); + req.get(function (err) { + do_check_eq(this.response.status, 405); + do_check_eq(this.response.headers["allow"], "DELETE"); + Utils.nextTick(next); + }); + } + function getMissingCollectionWBO(next) { + _("Testing that fetching a WBO from an on-existent collection 404s."); + let req = localRequest(server, storageURL + "/foobar/baz"); + req.get(function (err) { + do_check_eq(this.response.status, 404); + Utils.nextTick(next); + }); + } + + server.start(null, + Async.chain( + retrieveWBONotExists, + retrieveWBOExists, + deleteWBOExists, + deleteWBONotExists, + getStorageFails, + getMissingCollectionWBO, + deleteStorage, + server.stop.bind(server), + run_next_test + )); +}); + +add_test(function test_x_weave_records() { + let server = new SyncServer(); + server.registerUser("john", "password"); + + server.createContents("john", { + crypto: {foos: {foo: "bar"}, + bars: {foo: "baz"}} + }); + server.start(null, function () { + let wbo = localRequest(server, "/1.1/john/storage/crypto/foos"); + wbo.get(function (err) { + // WBO fetches don't have one. + do_check_false("x-weave-records" in this.response.headers); + let col = localRequest(server, "/1.1/john/storage/crypto"); + col.get(function (err) { + // Collection fetches do. + do_check_eq(this.response.headers["x-weave-records"], "2"); + server.stop(run_next_test); + }); + }); + }); +}); diff --git a/services/sync/tests/unit/test_identity_manager.js b/services/sync/tests/unit/test_identity_manager.js new file mode 100644 index 0000000000..1ac198adee --- /dev/null +++ b/services/sync/tests/unit/test_identity_manager.js @@ -0,0 +1,284 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +Cu.import("resource://services-sync/constants.js"); +Cu.import("resource://services-sync/identity.js"); +Cu.import("resource://services-sync/util.js"); + +var identity = new IdentityManager(); + +function run_test() { + initTestLogging("Trace"); + Log.repository.getLogger("Sync.Identity").level = Log.Level.Trace; + + run_next_test(); +} + +add_test(function test_username_from_account() { + _("Ensure usernameFromAccount works properly."); + + do_check_eq(identity.usernameFromAccount(null), null); + do_check_eq(identity.usernameFromAccount("user"), "user"); + do_check_eq(identity.usernameFromAccount("User"), "user"); + do_check_eq(identity.usernameFromAccount("john@doe.com"), + "7wohs32cngzuqt466q3ge7indszva4of"); + + run_next_test(); +}); + +add_test(function test_account_username() { + _("Ensure the account and username attributes work properly."); + + _("Verify initial state"); + do_check_eq(Svc.Prefs.get("account"), undefined); + do_check_eq(Svc.Prefs.get("username"), undefined); + do_check_eq(identity.account, null); + do_check_eq(identity.username, null); + + _("The 'username' attribute is normalized to lower case, updates preferences and identities."); + identity.username = "TarZan"; + do_check_eq(identity.username, "tarzan"); + do_check_eq(Svc.Prefs.get("username"), "tarzan"); + do_check_eq(identity.username, "tarzan"); + + _("If not set, the 'account attribute' falls back to the username for backwards compatibility."); + do_check_eq(identity.account, "tarzan"); + + _("Setting 'username' to a non-truthy value resets the pref."); + identity.username = null; + do_check_eq(identity.username, null); + do_check_eq(identity.account, null); + const default_marker = {}; + do_check_eq(Svc.Prefs.get("username", default_marker), default_marker); + do_check_eq(identity.username, null); + + _("The 'account' attribute will set the 'username' if it doesn't contain characters that aren't allowed in the username."); + identity.account = "johndoe"; + do_check_eq(identity.account, "johndoe"); + do_check_eq(identity.username, "johndoe"); + do_check_eq(Svc.Prefs.get("username"), "johndoe"); + do_check_eq(identity.username, "johndoe"); + + _("If 'account' contains disallowed characters such as @, 'username' will the base32 encoded SHA1 hash of 'account'"); + identity.account = "John@Doe.com"; + do_check_eq(identity.account, "john@doe.com"); + do_check_eq(identity.username, "7wohs32cngzuqt466q3ge7indszva4of"); + + _("Setting 'account' to a non-truthy value resets the pref."); + identity.account = null; + do_check_eq(identity.account, null); + do_check_eq(Svc.Prefs.get("account", default_marker), default_marker); + do_check_eq(identity.username, null); + do_check_eq(Svc.Prefs.get("username", default_marker), default_marker); + + Svc.Prefs.resetBranch(""); + run_next_test(); +}); + +add_test(function test_basic_password() { + _("Ensure basic password setting works as expected."); + + identity.account = null; + do_check_eq(identity.currentAuthState, LOGIN_FAILED_NO_USERNAME); + let thrown = false; + try { + identity.basicPassword = "foobar"; + } catch (ex) { + thrown = true; + } + + do_check_true(thrown); + thrown = false; + + identity.account = "johndoe"; + do_check_eq(identity.currentAuthState, LOGIN_FAILED_NO_PASSWORD); + identity.basicPassword = "password"; + do_check_eq(identity.basicPassword, "password"); + do_check_eq(identity.currentAuthState, LOGIN_FAILED_NO_PASSPHRASE); + do_check_true(identity.hasBasicCredentials()); + + identity.account = null; + + run_next_test(); +}); + +add_test(function test_basic_password_persistence() { + _("Ensure credentials are saved and restored to the login manager properly."); + + // Just in case. + identity.account = null; + identity.deleteSyncCredentials(); + + identity.account = "janesmith"; + identity.basicPassword = "ilovejohn"; + identity.persistCredentials(); + + let im1 = new IdentityManager(); + do_check_eq(im1._basicPassword, null); + do_check_eq(im1.username, "janesmith"); + do_check_eq(im1.basicPassword, "ilovejohn"); + + let im2 = new IdentityManager(); + do_check_eq(im2._basicPassword, null); + + _("Now remove the password and ensure it is deleted from storage."); + identity.basicPassword = null; + identity.persistCredentials(); // This should nuke from storage. + do_check_eq(im2.basicPassword, null); + + _("Ensure that retrieving an unset but unpersisted removal returns null."); + identity.account = "janesmith"; + identity.basicPassword = "myotherpassword"; + identity.persistCredentials(); + + identity.basicPassword = null; + do_check_eq(identity.basicPassword, null); + + // Reset for next test. + identity.account = null; + identity.persistCredentials(); + + run_next_test(); +}); + +add_test(function test_sync_key() { + _("Ensure Sync Key works as advertised."); + + _("Ensure setting a Sync Key before an account throws."); + let thrown = false; + try { + identity.syncKey = "blahblah"; + } catch (ex) { + thrown = true; + } + do_check_true(thrown); + thrown = false; + + identity.account = "johnsmith"; + identity.basicPassword = "johnsmithpw"; + + do_check_eq(identity.syncKey, null); + do_check_eq(identity.syncKeyBundle, null); + + _("An invalid Sync Key is silently accepted for historical reasons."); + identity.syncKey = "synckey"; + do_check_eq(identity.syncKey, "synckey"); + + _("But the SyncKeyBundle should not be created from bad keys."); + do_check_eq(identity.syncKeyBundle, null); + + let syncKey = Utils.generatePassphrase(); + identity.syncKey = syncKey; + do_check_eq(identity.syncKey, syncKey); + do_check_neq(identity.syncKeyBundle, null); + + let im = new IdentityManager(); + im.account = "pseudojohn"; + do_check_eq(im.syncKey, null); + do_check_eq(im.syncKeyBundle, null); + + identity.account = null; + + run_next_test(); +}); + +add_test(function test_sync_key_changes() { + _("Ensure changes to Sync Key have appropriate side-effects."); + + let im = new IdentityManager(); + let sk1 = Utils.generatePassphrase(); + let sk2 = Utils.generatePassphrase(); + + im.account = "johndoe"; + do_check_eq(im.syncKey, null); + do_check_eq(im.syncKeyBundle, null); + + im.syncKey = sk1; + do_check_neq(im.syncKeyBundle, null); + + let ek1 = im.syncKeyBundle.encryptionKeyB64; + let hk1 = im.syncKeyBundle.hmacKeyB64; + + // Change the Sync Key and ensure the Sync Key Bundle is updated. + im.syncKey = sk2; + let ek2 = im.syncKeyBundle.encryptionKeyB64; + let hk2 = im.syncKeyBundle.hmacKeyB64; + + do_check_neq(ek1, ek2); + do_check_neq(hk1, hk2); + + im.account = null; + + run_next_test(); +}); + +add_test(function test_current_auth_state() { + _("Ensure current auth state is reported properly."); + + let im = new IdentityManager(); + do_check_eq(im.currentAuthState, LOGIN_FAILED_NO_USERNAME); + + im.account = "johndoe"; + do_check_eq(im.currentAuthState, LOGIN_FAILED_NO_PASSWORD); + + im.basicPassword = "ilovejane"; + do_check_eq(im.currentAuthState, LOGIN_FAILED_NO_PASSPHRASE); + + im.syncKey = "foobar"; + do_check_eq(im.currentAuthState, LOGIN_FAILED_INVALID_PASSPHRASE); + + im.syncKey = null; + do_check_eq(im.currentAuthState, LOGIN_FAILED_NO_PASSPHRASE); + + im.syncKey = Utils.generatePassphrase(); + do_check_eq(im.currentAuthState, STATUS_OK); + + im.account = null; + + run_next_test(); +}); + +add_test(function test_sync_key_persistence() { + _("Ensure Sync Key persistence works as expected."); + + identity.account = "pseudojohn"; + identity.password = "supersecret"; + + let syncKey = Utils.generatePassphrase(); + identity.syncKey = syncKey; + + identity.persistCredentials(); + + let im = new IdentityManager(); + im.account = "pseudojohn"; + do_check_eq(im.syncKey, syncKey); + do_check_neq(im.syncKeyBundle, null); + + let kb1 = identity.syncKeyBundle; + let kb2 = im.syncKeyBundle; + + do_check_eq(kb1.encryptionKeyB64, kb2.encryptionKeyB64); + do_check_eq(kb1.hmacKeyB64, kb2.hmacKeyB64); + + identity.account = null; + identity.persistCredentials(); + + let im2 = new IdentityManager(); + im2.account = "pseudojohn"; + do_check_eq(im2.syncKey, null); + + im2.account = null; + + _("Ensure deleted but not persisted value is retrieved."); + identity.account = "someoneelse"; + identity.syncKey = Utils.generatePassphrase(); + identity.persistCredentials(); + identity.syncKey = null; + do_check_eq(identity.syncKey, null); + + // Clean up. + identity.account = null; + identity.persistCredentials(); + + run_next_test(); +}); diff --git a/services/sync/tests/unit/test_interval_triggers.js b/services/sync/tests/unit/test_interval_triggers.js new file mode 100644 index 0000000000..eca5ec2897 --- /dev/null +++ b/services/sync/tests/unit/test_interval_triggers.js @@ -0,0 +1,450 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +Cu.import("resource://services-sync/constants.js"); +Cu.import("resource://services-sync/engines.js"); +Cu.import("resource://services-sync/engines/clients.js"); +Cu.import("resource://services-sync/util.js"); +Cu.import("resource://testing-common/services/sync/utils.js"); + +Svc.DefaultPrefs.set("registerEngines", ""); +Cu.import("resource://services-sync/service.js"); + +var scheduler = Service.scheduler; +var clientsEngine = Service.clientsEngine; + +// Don't remove stale clients when syncing. This is a test-only workaround +// that lets us add clients directly to the store, without losing them on +// the next sync. +clientsEngine._removeRemoteClient = id => {}; + +function promiseStopServer(server) { + let deferred = Promise.defer(); + server.stop(deferred.resolve); + return deferred.promise; +} + +function sync_httpd_setup() { + let global = new ServerWBO("global", { + syncID: Service.syncID, + storageVersion: STORAGE_VERSION, + engines: {clients: {version: clientsEngine.version, + syncID: clientsEngine.syncID}} + }); + let clientsColl = new ServerCollection({}, true); + + // Tracking info/collections. + let collectionsHelper = track_collections_helper(); + let upd = collectionsHelper.with_updated_collection; + + return httpd_setup({ + "/1.1/johndoe/storage/meta/global": upd("meta", global.handler()), + "/1.1/johndoe/info/collections": collectionsHelper.handler, + "/1.1/johndoe/storage/crypto/keys": + upd("crypto", (new ServerWBO("keys")).handler()), + "/1.1/johndoe/storage/clients": upd("clients", clientsColl.handler()) + }); +} + +function* setUp(server) { + yield configureIdentity({username: "johndoe"}); + Service.serverURL = server.baseURI + "/"; + Service.clusterURL = server.baseURI + "/"; + generateNewKeys(Service.collectionKeys); + let serverKeys = Service.collectionKeys.asWBO("crypto", "keys"); + serverKeys.encrypt(Service.identity.syncKeyBundle); + serverKeys.upload(Service.resource(Service.cryptoKeysURL)); +} + +function run_test() { + initTestLogging("Trace"); + + Log.repository.getLogger("Sync.Service").level = Log.Level.Trace; + Log.repository.getLogger("Sync.SyncScheduler").level = Log.Level.Trace; + + run_next_test(); +} + +add_identity_test(this, function* test_successful_sync_adjustSyncInterval() { + _("Test successful sync calling adjustSyncInterval"); + let syncSuccesses = 0; + function onSyncFinish() { + _("Sync success."); + syncSuccesses++; + }; + Svc.Obs.add("weave:service:sync:finish", onSyncFinish); + + let server = sync_httpd_setup(); + yield setUp(server); + + // Confirm defaults + do_check_false(scheduler.idle); + do_check_false(scheduler.numClients > 1); + do_check_eq(scheduler.syncInterval, scheduler.singleDeviceInterval); + do_check_false(scheduler.hasIncomingItems); + + _("Test as long as numClients <= 1 our sync interval is SINGLE_USER."); + // idle == true && numClients <= 1 && hasIncomingItems == false + scheduler.idle = true; + Service.sync(); + do_check_eq(syncSuccesses, 1); + do_check_true(scheduler.idle); + do_check_false(scheduler.numClients > 1); + do_check_false(scheduler.hasIncomingItems); + do_check_eq(scheduler.syncInterval, scheduler.singleDeviceInterval); + + // idle == false && numClients <= 1 && hasIncomingItems == false + scheduler.idle = false; + Service.sync(); + do_check_eq(syncSuccesses, 2); + do_check_false(scheduler.idle); + do_check_false(scheduler.numClients > 1); + do_check_false(scheduler.hasIncomingItems); + do_check_eq(scheduler.syncInterval, scheduler.singleDeviceInterval); + + // idle == false && numClients <= 1 && hasIncomingItems == true + scheduler.hasIncomingItems = true; + Service.sync(); + do_check_eq(syncSuccesses, 3); + do_check_false(scheduler.idle); + do_check_false(scheduler.numClients > 1); + do_check_true(scheduler.hasIncomingItems); + do_check_eq(scheduler.syncInterval, scheduler.singleDeviceInterval); + + // idle == true && numClients <= 1 && hasIncomingItems == true + scheduler.idle = true; + Service.sync(); + do_check_eq(syncSuccesses, 4); + do_check_true(scheduler.idle); + do_check_false(scheduler.numClients > 1); + do_check_true(scheduler.hasIncomingItems); + do_check_eq(scheduler.syncInterval, scheduler.singleDeviceInterval); + + _("Test as long as idle && numClients > 1 our sync interval is idleInterval."); + // idle == true && numClients > 1 && hasIncomingItems == true + Service.clientsEngine._store.create({id: "foo", cleartext: "bar"}); + Service.sync(); + do_check_eq(syncSuccesses, 5); + do_check_true(scheduler.idle); + do_check_true(scheduler.numClients > 1); + do_check_true(scheduler.hasIncomingItems); + do_check_eq(scheduler.syncInterval, scheduler.idleInterval); + + // idle == true && numClients > 1 && hasIncomingItems == false + scheduler.hasIncomingItems = false; + Service.sync(); + do_check_eq(syncSuccesses, 6); + do_check_true(scheduler.idle); + do_check_true(scheduler.numClients > 1); + do_check_false(scheduler.hasIncomingItems); + do_check_eq(scheduler.syncInterval, scheduler.idleInterval); + + _("Test non-idle, numClients > 1, no incoming items => activeInterval."); + // idle == false && numClients > 1 && hasIncomingItems == false + scheduler.idle = false; + Service.sync(); + do_check_eq(syncSuccesses, 7); + do_check_false(scheduler.idle); + do_check_true(scheduler.numClients > 1); + do_check_false(scheduler.hasIncomingItems); + do_check_eq(scheduler.syncInterval, scheduler.activeInterval); + + _("Test non-idle, numClients > 1, incoming items => immediateInterval."); + // idle == false && numClients > 1 && hasIncomingItems == true + scheduler.hasIncomingItems = true; + Service.sync(); + do_check_eq(syncSuccesses, 8); + do_check_false(scheduler.idle); + do_check_true(scheduler.numClients > 1); + do_check_false(scheduler.hasIncomingItems); //gets reset to false + do_check_eq(scheduler.syncInterval, scheduler.immediateInterval); + + Svc.Obs.remove("weave:service:sync:finish", onSyncFinish); + Service.startOver(); + yield promiseStopServer(server); +}); + +add_identity_test(this, function* test_unsuccessful_sync_adjustSyncInterval() { + _("Test unsuccessful sync calling adjustSyncInterval"); + + let syncFailures = 0; + function onSyncError() { + _("Sync error."); + syncFailures++; + } + Svc.Obs.add("weave:service:sync:error", onSyncError); + + _("Test unsuccessful sync calls adjustSyncInterval"); + // Force sync to fail. + Svc.Prefs.set("firstSync", "notReady"); + + let server = sync_httpd_setup(); + yield setUp(server); + + // Confirm defaults + do_check_false(scheduler.idle); + do_check_false(scheduler.numClients > 1); + do_check_eq(scheduler.syncInterval, scheduler.singleDeviceInterval); + do_check_false(scheduler.hasIncomingItems); + + _("Test as long as numClients <= 1 our sync interval is SINGLE_USER."); + // idle == true && numClients <= 1 && hasIncomingItems == false + scheduler.idle = true; + Service.sync(); + do_check_eq(syncFailures, 1); + do_check_true(scheduler.idle); + do_check_false(scheduler.numClients > 1); + do_check_false(scheduler.hasIncomingItems); + do_check_eq(scheduler.syncInterval, scheduler.singleDeviceInterval); + + // idle == false && numClients <= 1 && hasIncomingItems == false + scheduler.idle = false; + Service.sync(); + do_check_eq(syncFailures, 2); + do_check_false(scheduler.idle); + do_check_false(scheduler.numClients > 1); + do_check_false(scheduler.hasIncomingItems); + do_check_eq(scheduler.syncInterval, scheduler.singleDeviceInterval); + + // idle == false && numClients <= 1 && hasIncomingItems == true + scheduler.hasIncomingItems = true; + Service.sync(); + do_check_eq(syncFailures, 3); + do_check_false(scheduler.idle); + do_check_false(scheduler.numClients > 1); + do_check_true(scheduler.hasIncomingItems); + do_check_eq(scheduler.syncInterval, scheduler.singleDeviceInterval); + + // idle == true && numClients <= 1 && hasIncomingItems == true + scheduler.idle = true; + Service.sync(); + do_check_eq(syncFailures, 4); + do_check_true(scheduler.idle); + do_check_false(scheduler.numClients > 1); + do_check_true(scheduler.hasIncomingItems); + do_check_eq(scheduler.syncInterval, scheduler.singleDeviceInterval); + + _("Test as long as idle && numClients > 1 our sync interval is idleInterval."); + // idle == true && numClients > 1 && hasIncomingItems == true + Service.clientsEngine._store.create({id: "foo", cleartext: "bar"}); + + Service.sync(); + do_check_eq(syncFailures, 5); + do_check_true(scheduler.idle); + do_check_true(scheduler.numClients > 1); + do_check_true(scheduler.hasIncomingItems); + do_check_eq(scheduler.syncInterval, scheduler.idleInterval); + + // idle == true && numClients > 1 && hasIncomingItems == false + scheduler.hasIncomingItems = false; + Service.sync(); + do_check_eq(syncFailures, 6); + do_check_true(scheduler.idle); + do_check_true(scheduler.numClients > 1); + do_check_false(scheduler.hasIncomingItems); + do_check_eq(scheduler.syncInterval, scheduler.idleInterval); + + _("Test non-idle, numClients > 1, no incoming items => activeInterval."); + // idle == false && numClients > 1 && hasIncomingItems == false + scheduler.idle = false; + Service.sync(); + do_check_eq(syncFailures, 7); + do_check_false(scheduler.idle); + do_check_true(scheduler.numClients > 1); + do_check_false(scheduler.hasIncomingItems); + do_check_eq(scheduler.syncInterval, scheduler.activeInterval); + + _("Test non-idle, numClients > 1, incoming items => immediateInterval."); + // idle == false && numClients > 1 && hasIncomingItems == true + scheduler.hasIncomingItems = true; + Service.sync(); + do_check_eq(syncFailures, 8); + do_check_false(scheduler.idle); + do_check_true(scheduler.numClients > 1); + do_check_false(scheduler.hasIncomingItems); //gets reset to false + do_check_eq(scheduler.syncInterval, scheduler.immediateInterval); + + Service.startOver(); + Svc.Obs.remove("weave:service:sync:error", onSyncError); + yield promiseStopServer(server); +}); + +add_identity_test(this, function* test_back_triggers_sync() { + let server = sync_httpd_setup(); + yield setUp(server); + + // Single device: no sync triggered. + scheduler.idle = true; + scheduler.observe(null, "active", Svc.Prefs.get("scheduler.idleTime")); + do_check_false(scheduler.idle); + + // Multiple devices: sync is triggered. + clientsEngine._store.create({id: "foo", cleartext: "bar"}); + scheduler.updateClientMode(); + + let deferred = Promise.defer(); + Svc.Obs.add("weave:service:sync:finish", function onSyncFinish() { + Svc.Obs.remove("weave:service:sync:finish", onSyncFinish); + + Service.recordManager.clearCache(); + Svc.Prefs.resetBranch(""); + scheduler.setDefaults(); + clientsEngine.resetClient(); + + Service.startOver(); + server.stop(deferred.resolve); + }); + + scheduler.idle = true; + scheduler.observe(null, "active", Svc.Prefs.get("scheduler.idleTime")); + do_check_false(scheduler.idle); + yield deferred.promise; +}); + +add_identity_test(this, function* test_adjust_interval_on_sync_error() { + let server = sync_httpd_setup(); + yield setUp(server); + + let syncFailures = 0; + function onSyncError() { + _("Sync error."); + syncFailures++; + } + Svc.Obs.add("weave:service:sync:error", onSyncError); + + _("Test unsuccessful sync updates client mode & sync intervals"); + // Force a sync fail. + Svc.Prefs.set("firstSync", "notReady"); + + do_check_eq(syncFailures, 0); + do_check_false(scheduler.numClients > 1); + do_check_eq(scheduler.syncInterval, scheduler.singleDeviceInterval); + + clientsEngine._store.create({id: "foo", cleartext: "bar"}); + Service.sync(); + + do_check_eq(syncFailures, 1); + do_check_true(scheduler.numClients > 1); + do_check_eq(scheduler.syncInterval, scheduler.activeInterval); + + Svc.Obs.remove("weave:service:sync:error", onSyncError); + Service.startOver(); + yield promiseStopServer(server); +}); + +add_identity_test(this, function* test_bug671378_scenario() { + // Test scenario similar to bug 671378. This bug appeared when a score + // update occurred that wasn't large enough to trigger a sync so + // scheduleNextSync() was called without a time interval parameter, + // setting nextSync to a non-zero value and preventing the timer from + // being adjusted in the next call to scheduleNextSync(). + let server = sync_httpd_setup(); + yield setUp(server); + + let syncSuccesses = 0; + function onSyncFinish() { + _("Sync success."); + syncSuccesses++; + }; + Svc.Obs.add("weave:service:sync:finish", onSyncFinish); + + // After first sync call, syncInterval & syncTimer are singleDeviceInterval. + Service.sync(); + do_check_eq(syncSuccesses, 1); + do_check_false(scheduler.numClients > 1); + do_check_eq(scheduler.syncInterval, scheduler.singleDeviceInterval); + do_check_eq(scheduler.syncTimer.delay, scheduler.singleDeviceInterval); + + let deferred = Promise.defer(); + // Wrap scheduleNextSync so we are notified when it is finished. + scheduler._scheduleNextSync = scheduler.scheduleNextSync; + scheduler.scheduleNextSync = function() { + scheduler._scheduleNextSync(); + + // Check on sync:finish scheduleNextSync sets the appropriate + // syncInterval and syncTimer values. + if (syncSuccesses == 2) { + do_check_neq(scheduler.nextSync, 0); + do_check_eq(scheduler.syncInterval, scheduler.activeInterval); + do_check_true(scheduler.syncTimer.delay <= scheduler.activeInterval); + + scheduler.scheduleNextSync = scheduler._scheduleNextSync; + Svc.Obs.remove("weave:service:sync:finish", onSyncFinish); + Service.startOver(); + server.stop(deferred.resolve); + } + }; + + // Set nextSync != 0 + // syncInterval still hasn't been set by call to updateClientMode. + // Explicitly trying to invoke scheduleNextSync during a sync + // (to immitate a score update that isn't big enough to trigger a sync). + Svc.Obs.add("weave:service:sync:start", function onSyncStart() { + // Wait for other sync:start observers to be called so that + // nextSync is set to 0. + Utils.nextTick(function() { + Svc.Obs.remove("weave:service:sync:start", onSyncStart); + + scheduler.scheduleNextSync(); + do_check_neq(scheduler.nextSync, 0); + do_check_eq(scheduler.syncInterval, scheduler.singleDeviceInterval); + do_check_eq(scheduler.syncTimer.delay, scheduler.singleDeviceInterval); + }); + }); + + clientsEngine._store.create({id: "foo", cleartext: "bar"}); + Service.sync(); + yield deferred.promise; +}); + +add_test(function test_adjust_timer_larger_syncInterval() { + _("Test syncInterval > current timout period && nextSync != 0, syncInterval is NOT used."); + clientsEngine._store.create({id: "foo", cleartext: "bar"}); + scheduler.updateClientMode(); + do_check_eq(scheduler.syncInterval, scheduler.activeInterval); + + scheduler.scheduleNextSync(); + + // Ensure we have a small interval. + do_check_neq(scheduler.nextSync, 0); + do_check_eq(scheduler.syncTimer.delay, scheduler.activeInterval); + + // Make interval large again + clientsEngine._wipeClient(); + scheduler.updateClientMode(); + do_check_eq(scheduler.syncInterval, scheduler.singleDeviceInterval); + + scheduler.scheduleNextSync(); + + // Ensure timer delay remains as the small interval. + do_check_neq(scheduler.nextSync, 0); + do_check_true(scheduler.syncTimer.delay <= scheduler.activeInterval); + + //SyncSchedule. + Service.startOver(); + run_next_test(); +}); + +add_test(function test_adjust_timer_smaller_syncInterval() { + _("Test current timout > syncInterval period && nextSync != 0, syncInterval is used."); + scheduler.scheduleNextSync(); + + // Ensure we have a large interval. + do_check_neq(scheduler.nextSync, 0); + do_check_eq(scheduler.syncTimer.delay, scheduler.singleDeviceInterval); + + // Make interval smaller + clientsEngine._store.create({id: "foo", cleartext: "bar"}); + scheduler.updateClientMode(); + do_check_eq(scheduler.syncInterval, scheduler.activeInterval); + + scheduler.scheduleNextSync(); + + // Ensure smaller timer delay is used. + do_check_neq(scheduler.nextSync, 0); + do_check_true(scheduler.syncTimer.delay <= scheduler.activeInterval); + + //SyncSchedule. + Service.startOver(); + run_next_test(); +}); diff --git a/services/sync/tests/unit/test_jpakeclient.js b/services/sync/tests/unit/test_jpakeclient.js new file mode 100644 index 0000000000..783edb4603 --- /dev/null +++ b/services/sync/tests/unit/test_jpakeclient.js @@ -0,0 +1,562 @@ +Cu.import("resource://gre/modules/Log.jsm"); +Cu.import("resource://services-sync/identity.js"); +Cu.import("resource://services-sync/jpakeclient.js"); +Cu.import("resource://services-sync/constants.js"); +Cu.import("resource://services-sync/util.js"); +Cu.import("resource://testing-common/services/sync/utils.js"); + +const JPAKE_LENGTH_SECRET = 8; +const JPAKE_LENGTH_CLIENTID = 256; +const KEYEXCHANGE_VERSION = 3; + +/* + * Simple server. + */ + +const SERVER_MAX_GETS = 6; + +function check_headers(request) { + let stack = Components.stack.caller; + + // There shouldn't be any Basic auth + do_check_false(request.hasHeader("Authorization"), stack); + + // Ensure key exchange ID is set and the right length + do_check_true(request.hasHeader("X-KeyExchange-Id"), stack); + do_check_eq(request.getHeader("X-KeyExchange-Id").length, + JPAKE_LENGTH_CLIENTID, stack); +} + +function new_channel() { + // Create a new channel and register it with the server. + let cid = Math.floor(Math.random() * 10000); + while (channels[cid]) { + cid = Math.floor(Math.random() * 10000); + } + let channel = channels[cid] = new ServerChannel(); + server.registerPathHandler("/" + cid, channel.handler()); + return cid; +} + +var server; +var channels = {}; // Map channel -> ServerChannel object +function server_new_channel(request, response) { + check_headers(request); + let cid = new_channel(); + let body = JSON.stringify("" + cid); + response.setStatusLine(request.httpVersion, 200, "OK"); + response.bodyOutputStream.write(body, body.length); +} + +var error_report; +function server_report(request, response) { + check_headers(request); + + if (request.hasHeader("X-KeyExchange-Log")) { + error_report = request.getHeader("X-KeyExchange-Log"); + } + + if (request.hasHeader("X-KeyExchange-Cid")) { + let cid = request.getHeader("X-KeyExchange-Cid"); + let channel = channels[cid]; + if (channel) { + channel.clear(); + } + } + + response.setStatusLine(request.httpVersion, 200, "OK"); +} + +// Hook for test code. +var hooks = {}; +function initHooks() { + hooks.onGET = function onGET(request) {}; +} +initHooks(); + +function ServerChannel() { + this.data = ""; + this.etag = ""; + this.getCount = 0; +} +ServerChannel.prototype = { + + GET: function GET(request, response) { + if (!this.data) { + response.setStatusLine(request.httpVersion, 404, "Not Found"); + return; + } + + if (request.hasHeader("If-None-Match")) { + let etag = request.getHeader("If-None-Match"); + if (etag == this.etag) { + response.setStatusLine(request.httpVersion, 304, "Not Modified"); + hooks.onGET(request); + return; + } + } + response.setHeader("ETag", this.etag); + response.setStatusLine(request.httpVersion, 200, "OK"); + response.bodyOutputStream.write(this.data, this.data.length); + + // Automatically clear the channel after 6 successful GETs. + this.getCount += 1; + if (this.getCount == SERVER_MAX_GETS) { + this.clear(); + } + hooks.onGET(request); + }, + + PUT: function PUT(request, response) { + if (this.data) { + do_check_true(request.hasHeader("If-Match")); + let etag = request.getHeader("If-Match"); + if (etag != this.etag) { + response.setHeader("ETag", this.etag); + response.setStatusLine(request.httpVersion, 412, "Precondition Failed"); + return; + } + } else { + do_check_true(request.hasHeader("If-None-Match")); + do_check_eq(request.getHeader("If-None-Match"), "*"); + } + + this.data = readBytesFromInputStream(request.bodyInputStream); + this.etag = '"' + Utils.sha1(this.data) + '"'; + response.setHeader("ETag", this.etag); + response.setStatusLine(request.httpVersion, 200, "OK"); + }, + + clear: function clear() { + delete this.data; + }, + + handler: function handler() { + let self = this; + return function(request, response) { + check_headers(request); + let method = self[request.method]; + return method.apply(self, arguments); + }; + } + +}; + + +/** + * Controller that throws for everything. + */ +var BaseController = { + displayPIN: function displayPIN() { + do_throw("displayPIN() shouldn't have been called!"); + }, + onPairingStart: function onPairingStart() { + do_throw("onPairingStart shouldn't have been called!"); + }, + onAbort: function onAbort(error) { + do_throw("Shouldn't have aborted with " + error + "!"); + }, + onPaired: function onPaired() { + do_throw("onPaired() shouldn't have been called!"); + }, + onComplete: function onComplete(data) { + do_throw("Shouldn't have completed with " + data + "!"); + } +}; + + +const DATA = {"msg": "eggstreamly sekrit"}; +const POLLINTERVAL = 50; + +function run_test() { + server = httpd_setup({"/new_channel": server_new_channel, + "/report": server_report}); + Svc.Prefs.set("jpake.serverURL", server.baseURI + "/"); + Svc.Prefs.set("jpake.pollInterval", POLLINTERVAL); + Svc.Prefs.set("jpake.maxTries", 2); + Svc.Prefs.set("jpake.firstMsgMaxTries", 5); + Svc.Prefs.set("jpake.lastMsgMaxTries", 5); + // Ensure clean up + Svc.Obs.add("profile-before-change", function() { + Svc.Prefs.resetBranch(""); + }); + + // Ensure PSM is initialized. + Cc["@mozilla.org/psm;1"].getService(Ci.nsISupports); + + // Simulate Sync setup with credentials in place. We want to make + // sure the J-PAKE requests don't include those data. + ensureLegacyIdentityManager(); + setBasicCredentials("johndoe", "ilovejane"); + + initTestLogging("Trace"); + Log.repository.getLogger("Sync.JPAKEClient").level = Log.Level.Trace; + Log.repository.getLogger("Common.RESTRequest").level = + Log.Level.Trace; + run_next_test(); +} + + +add_test(function test_success_receiveNoPIN() { + _("Test a successful exchange started by receiveNoPIN()."); + + let snd = new JPAKEClient({ + __proto__: BaseController, + onPaired: function onPaired() { + _("Pairing successful, sending final payload."); + do_check_true(pairingStartCalledOnReceiver); + Utils.nextTick(function() { snd.sendAndComplete(DATA); }); + }, + onComplete: function onComplete() {} + }); + + let pairingStartCalledOnReceiver = false; + let rec = new JPAKEClient({ + __proto__: BaseController, + displayPIN: function displayPIN(pin) { + _("Received PIN " + pin + ". Entering it in the other computer..."); + this.cid = pin.slice(JPAKE_LENGTH_SECRET); + Utils.nextTick(function() { snd.pairWithPIN(pin, false); }); + }, + onPairingStart: function onPairingStart() { + pairingStartCalledOnReceiver = true; + }, + onComplete: function onComplete(data) { + do_check_true(Utils.deepEquals(DATA, data)); + // Ensure channel was cleared, no error report. + do_check_eq(channels[this.cid].data, undefined); + do_check_eq(error_report, undefined); + run_next_test(); + } + }); + rec.receiveNoPIN(); +}); + + +add_test(function test_firstMsgMaxTries_timeout() { + _("Test abort when sender doesn't upload anything."); + + let rec = new JPAKEClient({ + __proto__: BaseController, + displayPIN: function displayPIN(pin) { + _("Received PIN " + pin + ". Doing nothing..."); + this.cid = pin.slice(JPAKE_LENGTH_SECRET); + }, + onAbort: function onAbort(error) { + do_check_eq(error, JPAKE_ERROR_TIMEOUT); + // Ensure channel was cleared, error report was sent. + do_check_eq(channels[this.cid].data, undefined); + do_check_eq(error_report, JPAKE_ERROR_TIMEOUT); + error_report = undefined; + run_next_test(); + } + }); + rec.receiveNoPIN(); +}); + + +add_test(function test_firstMsgMaxTries() { + _("Test that receiver can wait longer for the first message."); + + let snd = new JPAKEClient({ + __proto__: BaseController, + onPaired: function onPaired() { + _("Pairing successful, sending final payload."); + Utils.nextTick(function() { snd.sendAndComplete(DATA); }); + }, + onComplete: function onComplete() {} + }); + + let rec = new JPAKEClient({ + __proto__: BaseController, + displayPIN: function displayPIN(pin) { + // For the purpose of the tests, the poll interval is 50ms and + // we're polling up to 5 times for the first exchange (as + // opposed to 2 times for most of the other exchanges). So let's + // pretend it took 150ms to enter the PIN on the sender, which should + // require 3 polls. + // Rather than using an imprecise timer, we hook into the channel's + // GET handler to know how long to wait. + _("Received PIN " + pin + ". Waiting for three polls before entering it into sender..."); + this.cid = pin.slice(JPAKE_LENGTH_SECRET); + let count = 0; + hooks.onGET = function onGET(request) { + if (++count == 3) { + _("Third GET. Triggering pair."); + Utils.nextTick(function() { snd.pairWithPIN(pin, false); }); + } + }; + }, + onPairingStart: function onPairingStart(pin) {}, + onComplete: function onComplete(data) { + do_check_true(Utils.deepEquals(DATA, data)); + // Ensure channel was cleared, no error report. + do_check_eq(channels[this.cid].data, undefined); + do_check_eq(error_report, undefined); + + // Clean up. + initHooks(); + run_next_test(); + } + }); + rec.receiveNoPIN(); +}); + + +add_test(function test_lastMsgMaxTries() { + _("Test that receiver can wait longer for the last message."); + + let snd = new JPAKEClient({ + __proto__: BaseController, + onPaired: function onPaired() { + // For the purpose of the tests, the poll interval is 50ms and + // we're polling up to 5 times for the last exchange (as opposed + // to 2 times for other exchanges). So let's pretend it took + // 150ms to come up with the final payload, which should require + // 3 polls. + // Rather than using an imprecise timer, we hook into the channel's + // GET handler to know how long to wait. + let count = 0; + hooks.onGET = function onGET(request) { + if (++count == 3) { + _("Third GET. Triggering send."); + Utils.nextTick(function() { snd.sendAndComplete(DATA); }); + } + }; + }, + onComplete: function onComplete() {} + }); + + let rec = new JPAKEClient({ + __proto__: BaseController, + displayPIN: function displayPIN(pin) { + _("Received PIN " + pin + ". Entering it in the other computer..."); + this.cid = pin.slice(JPAKE_LENGTH_SECRET); + Utils.nextTick(function() { snd.pairWithPIN(pin, false); }); + }, + onPairingStart: function onPairingStart(pin) {}, + onComplete: function onComplete(data) { + do_check_true(Utils.deepEquals(DATA, data)); + // Ensure channel was cleared, no error report. + do_check_eq(channels[this.cid].data, undefined); + do_check_eq(error_report, undefined); + + // Clean up. + initHooks(); + run_next_test(); + } + }); + + rec.receiveNoPIN(); +}); + + +add_test(function test_wrongPIN() { + _("Test abort when PINs don't match."); + + let snd = new JPAKEClient({ + __proto__: BaseController, + onAbort: function onAbort(error) { + do_check_eq(error, JPAKE_ERROR_KEYMISMATCH); + do_check_eq(error_report, JPAKE_ERROR_KEYMISMATCH); + error_report = undefined; + } + }); + + let pairingStartCalledOnReceiver = false; + let rec = new JPAKEClient({ + __proto__: BaseController, + displayPIN: function displayPIN(pin) { + this.cid = pin.slice(JPAKE_LENGTH_SECRET); + let secret = pin.slice(0, JPAKE_LENGTH_SECRET); + secret = Array.prototype.slice.call(secret).reverse().join(""); + let new_pin = secret + this.cid; + _("Received PIN " + pin + ", but I'm entering " + new_pin); + + Utils.nextTick(function() { snd.pairWithPIN(new_pin, false); }); + }, + onPairingStart: function onPairingStart() { + pairingStartCalledOnReceiver = true; + }, + onAbort: function onAbort(error) { + do_check_true(pairingStartCalledOnReceiver); + do_check_eq(error, JPAKE_ERROR_NODATA); + // Ensure channel was cleared. + do_check_eq(channels[this.cid].data, undefined); + run_next_test(); + } + }); + rec.receiveNoPIN(); +}); + + +add_test(function test_abort_receiver() { + _("Test user abort on receiving side."); + + let rec = new JPAKEClient({ + __proto__: BaseController, + onAbort: function onAbort(error) { + // Manual abort = userabort. + do_check_eq(error, JPAKE_ERROR_USERABORT); + // Ensure channel was cleared. + do_check_eq(channels[this.cid].data, undefined); + do_check_eq(error_report, JPAKE_ERROR_USERABORT); + error_report = undefined; + run_next_test(); + }, + displayPIN: function displayPIN(pin) { + this.cid = pin.slice(JPAKE_LENGTH_SECRET); + Utils.nextTick(function() { rec.abort(); }); + } + }); + rec.receiveNoPIN(); +}); + + +add_test(function test_abort_sender() { + _("Test user abort on sending side."); + + let snd = new JPAKEClient({ + __proto__: BaseController, + onAbort: function onAbort(error) { + // Manual abort == userabort. + do_check_eq(error, JPAKE_ERROR_USERABORT); + do_check_eq(error_report, JPAKE_ERROR_USERABORT); + error_report = undefined; + } + }); + + let rec = new JPAKEClient({ + __proto__: BaseController, + onAbort: function onAbort(error) { + do_check_eq(error, JPAKE_ERROR_NODATA); + // Ensure channel was cleared, no error report. + do_check_eq(channels[this.cid].data, undefined); + do_check_eq(error_report, undefined); + initHooks(); + run_next_test(); + }, + displayPIN: function displayPIN(pin) { + _("Received PIN " + pin + ". Entering it in the other computer..."); + this.cid = pin.slice(JPAKE_LENGTH_SECRET); + Utils.nextTick(function() { snd.pairWithPIN(pin, false); }); + + // Abort after the first poll. + let count = 0; + hooks.onGET = function onGET(request) { + if (++count >= 1) { + _("First GET. Aborting."); + Utils.nextTick(function() { snd.abort(); }); + } + }; + }, + onPairingStart: function onPairingStart(pin) {} + }); + rec.receiveNoPIN(); +}); + + +add_test(function test_wrongmessage() { + let cid = new_channel(); + let channel = channels[cid]; + channel.data = JSON.stringify({type: "receiver2", + version: KEYEXCHANGE_VERSION, + payload: {}}); + channel.etag = '"fake-etag"'; + let snd = new JPAKEClient({ + __proto__: BaseController, + onComplete: function onComplete(data) { + do_throw("onComplete shouldn't be called."); + }, + onAbort: function onAbort(error) { + do_check_eq(error, JPAKE_ERROR_WRONGMESSAGE); + run_next_test(); + } + }); + snd.pairWithPIN("01234567" + cid, false); +}); + + +add_test(function test_error_channel() { + let serverURL = Svc.Prefs.get("jpake.serverURL"); + Svc.Prefs.set("jpake.serverURL", "http://localhost:12345/"); + + let rec = new JPAKEClient({ + __proto__: BaseController, + onAbort: function onAbort(error) { + do_check_eq(error, JPAKE_ERROR_CHANNEL); + Svc.Prefs.set("jpake.serverURL", serverURL); + run_next_test(); + }, + onPairingStart: function onPairingStart(pin) {}, + displayPIN: function displayPIN(pin) {} + }); + rec.receiveNoPIN(); +}); + + +add_test(function test_error_network() { + let serverURL = Svc.Prefs.get("jpake.serverURL"); + Svc.Prefs.set("jpake.serverURL", "http://localhost:12345/"); + + let snd = new JPAKEClient({ + __proto__: BaseController, + onAbort: function onAbort(error) { + do_check_eq(error, JPAKE_ERROR_NETWORK); + Svc.Prefs.set("jpake.serverURL", serverURL); + run_next_test(); + } + }); + snd.pairWithPIN("0123456789ab", false); +}); + + +add_test(function test_error_server_noETag() { + let cid = new_channel(); + let channel = channels[cid]; + channel.data = JSON.stringify({type: "receiver1", + version: KEYEXCHANGE_VERSION, + payload: {}}); + // This naughty server doesn't supply ETag (well, it supplies empty one). + channel.etag = ""; + let snd = new JPAKEClient({ + __proto__: BaseController, + onAbort: function onAbort(error) { + do_check_eq(error, JPAKE_ERROR_SERVER); + run_next_test(); + } + }); + snd.pairWithPIN("01234567" + cid, false); +}); + + +add_test(function test_error_delayNotSupported() { + let cid = new_channel(); + let channel = channels[cid]; + channel.data = JSON.stringify({type: "receiver1", + version: 2, + payload: {}}); + channel.etag = '"fake-etag"'; + let snd = new JPAKEClient({ + __proto__: BaseController, + onAbort: function onAbort(error) { + do_check_eq(error, JPAKE_ERROR_DELAYUNSUPPORTED); + run_next_test(); + } + }); + snd.pairWithPIN("01234567" + cid, true); +}); + + +add_test(function test_sendAndComplete_notPaired() { + let snd = new JPAKEClient({__proto__: BaseController}); + do_check_throws(function () { + snd.sendAndComplete(DATA); + }); + run_next_test(); +}); + + +add_test(function tearDown() { + server.stop(run_next_test); +}); diff --git a/services/sync/tests/unit/test_keys.js b/services/sync/tests/unit/test_keys.js new file mode 100644 index 0000000000..a828b619cd --- /dev/null +++ b/services/sync/tests/unit/test_keys.js @@ -0,0 +1,326 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +Cu.import("resource://services-sync/constants.js"); +Cu.import("resource://services-sync/identity.js"); +Cu.import("resource://services-sync/keys.js"); +Cu.import("resource://services-sync/record.js"); +Cu.import("resource://services-sync/util.js"); + +var collectionKeys = new CollectionKeyManager(); + +function sha256HMAC(message, key) { + let h = Utils.makeHMACHasher(Ci.nsICryptoHMAC.SHA256, key); + return Utils.digestBytes(message, h); +} + +function do_check_keypair_eq(a, b) { + do_check_eq(2, a.length); + do_check_eq(2, b.length); + do_check_eq(a[0], b[0]); + do_check_eq(a[1], b[1]); +} + +function test_time_keyFromString(iterations) { + let k; + let o; + let b = new BulkKeyBundle("dummy"); + let d = Utils.decodeKeyBase32("ababcdefabcdefabcdefabcdef"); + b.generateRandom(); + + _("Running " + iterations + " iterations of hmacKeyObject + sha256HMAC."); + for (let i = 0; i < iterations; ++i) { + let k = b.hmacKeyObject; + o = sha256HMAC(d, k); + } + do_check_true(!!o); + _("Done."); +} + +add_test(function test_set_invalid_values() { + _("Ensure that setting invalid encryption and HMAC key values is caught."); + + let bundle = new BulkKeyBundle("foo"); + + let thrown = false; + try { + bundle.encryptionKey = null; + } catch (ex) { + thrown = true; + do_check_eq(ex.message.indexOf("Encryption key can only be set to"), 0); + } finally { + do_check_true(thrown); + thrown = false; + } + + try { + bundle.encryptionKey = ["trollololol"]; + } catch (ex) { + thrown = true; + do_check_eq(ex.message.indexOf("Encryption key can only be set to"), 0); + } finally { + do_check_true(thrown); + thrown = false; + } + + try { + bundle.hmacKey = Utils.generateRandomBytes(15); + } catch (ex) { + thrown = true; + do_check_eq(ex.message.indexOf("HMAC key must be at least 128"), 0); + } finally { + do_check_true(thrown); + thrown = false; + } + + try { + bundle.hmacKey = null; + } catch (ex) { + thrown = true; + do_check_eq(ex.message.indexOf("HMAC key can only be set to string"), 0); + } finally { + do_check_true(thrown); + thrown = false; + } + + try { + bundle.hmacKey = ["trollolol"]; + } catch (ex) { + thrown = true; + do_check_eq(ex.message.indexOf("HMAC key can only be set to"), 0); + } finally { + do_check_true(thrown); + thrown = false; + } + + try { + bundle.hmacKey = Utils.generateRandomBytes(15); + } catch (ex) { + thrown = true; + do_check_eq(ex.message.indexOf("HMAC key must be at least 128"), 0); + } finally { + do_check_true(thrown); + thrown = false; + } + + run_next_test(); +}); + +add_test(function test_repeated_hmac() { + let testKey = "ababcdefabcdefabcdefabcdef"; + let k = Utils.makeHMACKey("foo"); + let one = sha256HMAC(Utils.decodeKeyBase32(testKey), k); + let two = sha256HMAC(Utils.decodeKeyBase32(testKey), k); + do_check_eq(one, two); + + run_next_test(); +}); + +add_test(function test_sync_key_bundle_derivation() { + _("Ensure derivation from known values works."); + + // The known values in this test were originally verified against Firefox + // Home. + let bundle = new SyncKeyBundle("st3fan", "q7ynpwq7vsc9m34hankbyi3s3i"); + + // These should be compared to the results from Home, as they once were. + let e = "14b8c09fa84e92729ee695160af6e0385f8f6215a25d14906e1747bdaa2de426"; + let h = "370e3566245d79fe602a3adb5137e42439cd2a571235197e0469d7d541b07875"; + + let realE = Utils.bytesAsHex(bundle.encryptionKey); + let realH = Utils.bytesAsHex(bundle.hmacKey); + + _("Real E: " + realE); + _("Real H: " + realH); + do_check_eq(realH, h); + do_check_eq(realE, e); + + run_next_test(); +}); + +add_test(function test_keymanager() { + let testKey = "ababcdefabcdefabcdefabcdef"; + let username = "john@example.com"; + + // Decode the key here to mirror what generateEntry will do, + // but pass it encoded into the KeyBundle call below. + + let sha256inputE = "" + HMAC_INPUT + username + "\x01"; + let key = Utils.makeHMACKey(Utils.decodeKeyBase32(testKey)); + let encryptKey = sha256HMAC(sha256inputE, key); + + let sha256inputH = encryptKey + HMAC_INPUT + username + "\x02"; + let hmacKey = sha256HMAC(sha256inputH, key); + + // Encryption key is stored in base64 for WeaveCrypto convenience. + do_check_eq(encryptKey, new SyncKeyBundle(username, testKey).encryptionKey); + do_check_eq(hmacKey, new SyncKeyBundle(username, testKey).hmacKey); + + // Test with the same KeyBundle for both. + let obj = new SyncKeyBundle(username, testKey); + do_check_eq(hmacKey, obj.hmacKey); + do_check_eq(encryptKey, obj.encryptionKey); + + run_next_test(); +}); + +add_test(function test_collections_manager() { + let log = Log.repository.getLogger("Test"); + Log.repository.rootLogger.addAppender(new Log.DumpAppender()); + + let identity = new IdentityManager(); + + identity.account = "john@example.com"; + identity.syncKey = "a-bbbbb-ccccc-ddddd-eeeee-fffff"; + + let keyBundle = identity.syncKeyBundle; + + /* + * Build a test version of storage/crypto/keys. + * Encrypt it with the sync key. + * Pass it into the CollectionKeyManager. + */ + + log.info("Building storage keys..."); + let storage_keys = new CryptoWrapper("crypto", "keys"); + let default_key64 = Svc.Crypto.generateRandomKey(); + let default_hmac64 = Svc.Crypto.generateRandomKey(); + let bookmarks_key64 = Svc.Crypto.generateRandomKey(); + let bookmarks_hmac64 = Svc.Crypto.generateRandomKey(); + + storage_keys.cleartext = { + "default": [default_key64, default_hmac64], + "collections": {"bookmarks": [bookmarks_key64, bookmarks_hmac64]}, + }; + storage_keys.modified = Date.now()/1000; + storage_keys.id = "keys"; + + log.info("Encrypting storage keys..."); + + // Use passphrase (sync key) itself to encrypt the key bundle. + storage_keys.encrypt(keyBundle); + + // Sanity checking. + do_check_true(null == storage_keys.cleartext); + do_check_true(null != storage_keys.ciphertext); + + log.info("Updating collection keys."); + + // updateContents decrypts the object, releasing the payload for us to use. + // Returns true, because the default key has changed. + do_check_true(collectionKeys.updateContents(keyBundle, storage_keys)); + let payload = storage_keys.cleartext; + + _("CK: " + JSON.stringify(collectionKeys._collections)); + + // Test that the CollectionKeyManager returns a similar WBO. + let wbo = collectionKeys.asWBO("crypto", "keys"); + + _("WBO: " + JSON.stringify(wbo)); + _("WBO cleartext: " + JSON.stringify(wbo.cleartext)); + + // Check the individual contents. + do_check_eq(wbo.collection, "crypto"); + do_check_eq(wbo.id, "keys"); + do_check_eq(undefined, wbo.modified); + do_check_eq(collectionKeys.lastModified, storage_keys.modified); + do_check_true(!!wbo.cleartext.default); + do_check_keypair_eq(payload.default, wbo.cleartext.default); + do_check_keypair_eq(payload.collections.bookmarks, wbo.cleartext.collections.bookmarks); + + do_check_true('bookmarks' in collectionKeys._collections); + do_check_false('tabs' in collectionKeys._collections); + + _("Updating contents twice with the same data doesn't proceed."); + storage_keys.encrypt(keyBundle); + do_check_false(collectionKeys.updateContents(keyBundle, storage_keys)); + + /* + * Test that we get the right keys out when we ask for + * a collection's tokens. + */ + let b1 = new BulkKeyBundle("bookmarks"); + b1.keyPairB64 = [bookmarks_key64, bookmarks_hmac64]; + let b2 = collectionKeys.keyForCollection("bookmarks"); + do_check_keypair_eq(b1.keyPair, b2.keyPair); + + // Check key equality. + do_check_true(b1.equals(b2)); + do_check_true(b2.equals(b1)); + + b1 = new BulkKeyBundle("[default]"); + b1.keyPairB64 = [default_key64, default_hmac64]; + + do_check_false(b1.equals(b2)); + do_check_false(b2.equals(b1)); + + b2 = collectionKeys.keyForCollection(null); + do_check_keypair_eq(b1.keyPair, b2.keyPair); + + /* + * Checking for update times. + */ + let info_collections = {}; + do_check_true(collectionKeys.updateNeeded(info_collections)); + info_collections["crypto"] = 5000; + do_check_false(collectionKeys.updateNeeded(info_collections)); + info_collections["crypto"] = 1 + (Date.now()/1000); // Add one in case computers are fast! + do_check_true(collectionKeys.updateNeeded(info_collections)); + + collectionKeys.lastModified = null; + do_check_true(collectionKeys.updateNeeded({})); + + /* + * Check _compareKeyBundleCollections. + */ + function newBundle(name) { + let r = new BulkKeyBundle(name); + r.generateRandom(); + return r; + } + let k1 = newBundle("k1"); + let k2 = newBundle("k2"); + let k3 = newBundle("k3"); + let k4 = newBundle("k4"); + let k5 = newBundle("k5"); + let coll1 = {"foo": k1, "bar": k2}; + let coll2 = {"foo": k1, "bar": k2}; + let coll3 = {"foo": k1, "bar": k3}; + let coll4 = {"foo": k4}; + let coll5 = {"baz": k5, "bar": k2}; + let coll6 = {}; + + let d1 = collectionKeys._compareKeyBundleCollections(coll1, coll2); // [] + let d2 = collectionKeys._compareKeyBundleCollections(coll1, coll3); // ["bar"] + let d3 = collectionKeys._compareKeyBundleCollections(coll3, coll2); // ["bar"] + let d4 = collectionKeys._compareKeyBundleCollections(coll1, coll4); // ["bar", "foo"] + let d5 = collectionKeys._compareKeyBundleCollections(coll5, coll2); // ["baz", "foo"] + let d6 = collectionKeys._compareKeyBundleCollections(coll6, coll1); // ["bar", "foo"] + let d7 = collectionKeys._compareKeyBundleCollections(coll5, coll5); // [] + let d8 = collectionKeys._compareKeyBundleCollections(coll6, coll6); // [] + + do_check_true(d1.same); + do_check_false(d2.same); + do_check_false(d3.same); + do_check_false(d4.same); + do_check_false(d5.same); + do_check_false(d6.same); + do_check_true(d7.same); + do_check_true(d8.same); + + do_check_array_eq(d1.changed, []); + do_check_array_eq(d2.changed, ["bar"]); + do_check_array_eq(d3.changed, ["bar"]); + do_check_array_eq(d4.changed, ["bar", "foo"]); + do_check_array_eq(d5.changed, ["baz", "foo"]); + do_check_array_eq(d6.changed, ["bar", "foo"]); + + run_next_test(); +}); + +function run_test() { + // Only do 1,000 to avoid a 5-second pause in test runs. + test_time_keyFromString(1000); + + run_next_test(); +} diff --git a/services/sync/tests/unit/test_load_modules.js b/services/sync/tests/unit/test_load_modules.js new file mode 100644 index 0000000000..0b222520c6 --- /dev/null +++ b/services/sync/tests/unit/test_load_modules.js @@ -0,0 +1,55 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +const modules = [ + "addonutils.js", + "addonsreconciler.js", + "browserid_identity.js", + "constants.js", + "engines/addons.js", + "engines/bookmarks.js", + "engines/clients.js", + "engines/extension-storage.js", + "engines/forms.js", + "engines/history.js", + "engines/passwords.js", + "engines/prefs.js", + "engines/tabs.js", + "engines.js", + "identity.js", + "jpakeclient.js", + "keys.js", + "main.js", + "policies.js", + "record.js", + "resource.js", + "rest.js", + "service.js", + "stages/cluster.js", + "stages/declined.js", + "stages/enginesync.js", + "status.js", + "userapi.js", + "util.js", +]; + +const testingModules = [ + "fakeservices.js", + "rotaryengine.js", + "utils.js", + "fxa_utils.js", +]; + +function run_test() { + for (let m of modules) { + let res = "resource://services-sync/" + m; + _("Attempting to load " + res); + Cu.import(res, {}); + } + + for (let m of testingModules) { + let res = "resource://testing-common/services/sync/" + m; + _("Attempting to load " + res); + Cu.import(res, {}); + } +} diff --git a/services/sync/tests/unit/test_node_reassignment.js b/services/sync/tests/unit/test_node_reassignment.js new file mode 100644 index 0000000000..66d21b6f1f --- /dev/null +++ b/services/sync/tests/unit/test_node_reassignment.js @@ -0,0 +1,523 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +_("Test that node reassignment responses are respected on all kinds of " + + "requests."); + +Cu.import("resource://gre/modules/Log.jsm"); +Cu.import("resource://services-common/rest.js"); +Cu.import("resource://services-sync/constants.js"); +Cu.import("resource://services-sync/service.js"); +Cu.import("resource://services-sync/status.js"); +Cu.import("resource://services-sync/util.js"); +Cu.import("resource://testing-common/services/sync/rotaryengine.js"); +Cu.import("resource://testing-common/services/sync/utils.js"); + +Service.engineManager.clear(); + +function run_test() { + Log.repository.getLogger("Sync.AsyncResource").level = Log.Level.Trace; + Log.repository.getLogger("Sync.ErrorHandler").level = Log.Level.Trace; + Log.repository.getLogger("Sync.Resource").level = Log.Level.Trace; + Log.repository.getLogger("Sync.RESTRequest").level = Log.Level.Trace; + Log.repository.getLogger("Sync.Service").level = Log.Level.Trace; + Log.repository.getLogger("Sync.SyncScheduler").level = Log.Level.Trace; + initTestLogging(); + validate_all_future_pings(); + ensureLegacyIdentityManager(); + + Service.engineManager.register(RotaryEngine); + + // None of the failures in this file should result in a UI error. + function onUIError() { + do_throw("Errors should not be presented in the UI."); + } + Svc.Obs.add("weave:ui:login:error", onUIError); + Svc.Obs.add("weave:ui:sync:error", onUIError); + + run_next_test(); +} + +/** + * Emulate the following Zeus config: + * $draining = data.get($prefix . $host . " draining"); + * if ($draining == "drain.") { + * log.warn($log_host_db_status . " migrating=1 (node-reassignment)" . + * $log_suffix); + * http.sendResponse("401 Node reassignment", $content_type, + * '"server request: node reassignment"', ""); + * } + */ +const reassignBody = "\"server request: node reassignment\""; + +// API-compatible with SyncServer handler. Bind `handler` to something to use +// as a ServerCollection handler. +function handleReassign(handler, req, resp) { + resp.setStatusLine(req.httpVersion, 401, "Node reassignment"); + resp.setHeader("Content-Type", "application/json"); + resp.bodyOutputStream.write(reassignBody, reassignBody.length); +} + +/** + * A node assignment handler. + */ +function installNodeHandler(server, next) { + let newNodeBody = server.baseURI; + function handleNodeRequest(req, resp) { + _("Client made a request for a node reassignment."); + resp.setStatusLine(req.httpVersion, 200, "OK"); + resp.setHeader("Content-Type", "text/plain"); + resp.bodyOutputStream.write(newNodeBody, newNodeBody.length); + Utils.nextTick(next); + } + let nodePath = "/user/1.0/johndoe/node/weave"; + server.server.registerPathHandler(nodePath, handleNodeRequest); + _("Registered node handler at " + nodePath); +} + +function prepareServer() { + let deferred = Promise.defer(); + configureIdentity({username: "johndoe"}).then(() => { + let server = new SyncServer(); + server.registerUser("johndoe"); + server.start(); + Service.serverURL = server.baseURI; + Service.clusterURL = server.baseURI; + do_check_eq(Service.userAPIURI, server.baseURI + "user/1.0/"); + deferred.resolve(server); + }); + return deferred.promise; +} + +function getReassigned() { + try { + return Services.prefs.getBoolPref("services.sync.lastSyncReassigned"); + } catch (ex) { + if (ex.result == Cr.NS_ERROR_UNEXPECTED) { + return false; + } + do_throw("Got exception retrieving lastSyncReassigned: " + + Log.exceptionStr(ex)); + } +} + +/** + * Make a test request to `url`, then watch the result of two syncs + * to ensure that a node request was made. + * Runs `between` between the two. This can be used to undo deliberate failure + * setup, detach observers, etc. + */ +function* syncAndExpectNodeReassignment(server, firstNotification, between, + secondNotification, url) { + let deferred = Promise.defer(); + function onwards() { + let nodeFetched = false; + function onFirstSync() { + _("First sync completed."); + Svc.Obs.remove(firstNotification, onFirstSync); + Svc.Obs.add(secondNotification, onSecondSync); + + do_check_eq(Service.clusterURL, ""); + + // Track whether we fetched node/weave. We want to wait for the second + // sync to finish so that we're cleaned up for the next test, so don't + // run_next_test in the node handler. + nodeFetched = false; + + // Verify that the client requests a node reassignment. + // Install a node handler to watch for these requests. + installNodeHandler(server, function () { + nodeFetched = true; + }); + + // Allow for tests to clean up error conditions. + between(); + } + function onSecondSync() { + _("Second sync completed."); + Svc.Obs.remove(secondNotification, onSecondSync); + Service.scheduler.clearSyncTriggers(); + + // Make absolutely sure that any event listeners are done with their work + // before we proceed. + waitForZeroTimer(function () { + _("Second sync nextTick."); + do_check_true(nodeFetched); + Service.startOver(); + server.stop(deferred.resolve); + }); + } + + Svc.Obs.add(firstNotification, onFirstSync); + Service.sync(); + } + + // Make sure that it works! + let request = new RESTRequest(url); + request.get(function () { + do_check_eq(request.response.status, 401); + Utils.nextTick(onwards); + }); + yield deferred.promise; +} + +add_task(function* test_momentary_401_engine() { + _("Test a failure for engine URLs that's resolved by reassignment."); + let server = yield prepareServer(); + let john = server.user("johndoe"); + + _("Enabling the Rotary engine."); + let engine = Service.engineManager.get("rotary"); + engine.enabled = true; + + // We need the server to be correctly set up prior to experimenting. Do this + // through a sync. + let global = {syncID: Service.syncID, + storageVersion: STORAGE_VERSION, + rotary: {version: engine.version, + syncID: engine.syncID}} + john.createCollection("meta").insert("global", global); + + _("First sync to prepare server contents."); + Service.sync(); + + _("Setting up Rotary collection to 401."); + let rotary = john.createCollection("rotary"); + let oldHandler = rotary.collectionHandler; + rotary.collectionHandler = handleReassign.bind(this, undefined); + + // We want to verify that the clusterURL pref has been cleared after a 401 + // inside a sync. Flag the Rotary engine to need syncing. + john.collection("rotary").timestamp += 1000; + + function between() { + _("Undoing test changes."); + rotary.collectionHandler = oldHandler; + + function onLoginStart() { + // lastSyncReassigned shouldn't be cleared until a sync has succeeded. + _("Ensuring that lastSyncReassigned is still set at next sync start."); + Svc.Obs.remove("weave:service:login:start", onLoginStart); + do_check_true(getReassigned()); + } + + _("Adding observer that lastSyncReassigned is still set on login."); + Svc.Obs.add("weave:service:login:start", onLoginStart); + } + + yield syncAndExpectNodeReassignment(server, + "weave:service:sync:finish", + between, + "weave:service:sync:finish", + Service.storageURL + "rotary"); +}); + +// This test ends up being a failing fetch *after we're already logged in*. +add_task(function* test_momentary_401_info_collections() { + _("Test a failure for info/collections that's resolved by reassignment."); + let server = yield prepareServer(); + + _("First sync to prepare server contents."); + Service.sync(); + + // Return a 401 for info requests, particularly info/collections. + let oldHandler = server.toplevelHandlers.info; + server.toplevelHandlers.info = handleReassign; + + function undo() { + _("Undoing test changes."); + server.toplevelHandlers.info = oldHandler; + } + + yield syncAndExpectNodeReassignment(server, + "weave:service:sync:error", + undo, + "weave:service:sync:finish", + Service.infoURL); +}); + +add_task(function* test_momentary_401_storage_loggedin() { + _("Test a failure for any storage URL, not just engine parts. " + + "Resolved by reassignment."); + let server = yield prepareServer(); + + _("Performing initial sync to ensure we are logged in.") + Service.sync(); + + // Return a 401 for all storage requests. + let oldHandler = server.toplevelHandlers.storage; + server.toplevelHandlers.storage = handleReassign; + + function undo() { + _("Undoing test changes."); + server.toplevelHandlers.storage = oldHandler; + } + + do_check_true(Service.isLoggedIn, "already logged in"); + yield syncAndExpectNodeReassignment(server, + "weave:service:sync:error", + undo, + "weave:service:sync:finish", + Service.storageURL + "meta/global"); +}); + +add_task(function* test_momentary_401_storage_loggedout() { + _("Test a failure for any storage URL, not just engine parts. " + + "Resolved by reassignment."); + let server = yield prepareServer(); + + // Return a 401 for all storage requests. + let oldHandler = server.toplevelHandlers.storage; + server.toplevelHandlers.storage = handleReassign; + + function undo() { + _("Undoing test changes."); + server.toplevelHandlers.storage = oldHandler; + } + + do_check_false(Service.isLoggedIn, "not already logged in"); + yield syncAndExpectNodeReassignment(server, + "weave:service:login:error", + undo, + "weave:service:sync:finish", + Service.storageURL + "meta/global"); +}); + +add_task(function* test_loop_avoidance_storage() { + _("Test that a repeated failure doesn't result in a sync loop " + + "if node reassignment cannot resolve the failure."); + + let server = yield prepareServer(); + + // Return a 401 for all storage requests. + let oldHandler = server.toplevelHandlers.storage; + server.toplevelHandlers.storage = handleReassign; + + let firstNotification = "weave:service:login:error"; + let secondNotification = "weave:service:login:error"; + let thirdNotification = "weave:service:sync:finish"; + + let nodeFetched = false; + let deferred = Promise.defer(); + + // Track the time. We want to make sure the duration between the first and + // second sync is small, and then that the duration between second and third + // is set to be large. + let now; + + function onFirstSync() { + _("First sync completed."); + Svc.Obs.remove(firstNotification, onFirstSync); + Svc.Obs.add(secondNotification, onSecondSync); + + do_check_eq(Service.clusterURL, ""); + + // We got a 401 mid-sync, and set the pref accordingly. + do_check_true(Services.prefs.getBoolPref("services.sync.lastSyncReassigned")); + + // Track whether we fetched node/weave. We want to wait for the second + // sync to finish so that we're cleaned up for the next test, so don't + // run_next_test in the node handler. + nodeFetched = false; + + // Verify that the client requests a node reassignment. + // Install a node handler to watch for these requests. + installNodeHandler(server, function () { + nodeFetched = true; + }); + + // Update the timestamp. + now = Date.now(); + } + + function onSecondSync() { + _("Second sync completed."); + Svc.Obs.remove(secondNotification, onSecondSync); + Svc.Obs.add(thirdNotification, onThirdSync); + + // This sync occurred within the backoff interval. + let elapsedTime = Date.now() - now; + do_check_true(elapsedTime < MINIMUM_BACKOFF_INTERVAL); + + // This pref will be true until a sync completes successfully. + do_check_true(getReassigned()); + + // The timer will be set for some distant time. + // We store nextSync in prefs, which offers us only limited resolution. + // Include that logic here. + let expectedNextSync = 1000 * Math.floor((now + MINIMUM_BACKOFF_INTERVAL) / 1000); + _("Next sync scheduled for " + Service.scheduler.nextSync); + _("Expected to be slightly greater than " + expectedNextSync); + + do_check_true(Service.scheduler.nextSync >= expectedNextSync); + do_check_true(!!Service.scheduler.syncTimer); + + // Undo our evil scheme. + server.toplevelHandlers.storage = oldHandler; + + // Bring the timer forward to kick off a successful sync, so we can watch + // the pref get cleared. + Service.scheduler.scheduleNextSync(0); + } + function onThirdSync() { + Svc.Obs.remove(thirdNotification, onThirdSync); + + // That'll do for now; no more syncs. + Service.scheduler.clearSyncTriggers(); + + // Make absolutely sure that any event listeners are done with their work + // before we proceed. + waitForZeroTimer(function () { + _("Third sync nextTick."); + do_check_false(getReassigned()); + do_check_true(nodeFetched); + Service.startOver(); + server.stop(deferred.resolve); + }); + } + + Svc.Obs.add(firstNotification, onFirstSync); + + now = Date.now(); + Service.sync(); + yield deferred.promise; +}); + +add_task(function* test_loop_avoidance_engine() { + _("Test that a repeated 401 in an engine doesn't result in a sync loop " + + "if node reassignment cannot resolve the failure."); + let server = yield prepareServer(); + let john = server.user("johndoe"); + + _("Enabling the Rotary engine."); + let engine = Service.engineManager.get("rotary"); + engine.enabled = true; + let deferred = Promise.defer(); + + // We need the server to be correctly set up prior to experimenting. Do this + // through a sync. + let global = {syncID: Service.syncID, + storageVersion: STORAGE_VERSION, + rotary: {version: engine.version, + syncID: engine.syncID}} + john.createCollection("meta").insert("global", global); + + _("First sync to prepare server contents."); + Service.sync(); + + _("Setting up Rotary collection to 401."); + let rotary = john.createCollection("rotary"); + let oldHandler = rotary.collectionHandler; + rotary.collectionHandler = handleReassign.bind(this, undefined); + + // Flag the Rotary engine to need syncing. + john.collection("rotary").timestamp += 1000; + + function onLoginStart() { + // lastSyncReassigned shouldn't be cleared until a sync has succeeded. + _("Ensuring that lastSyncReassigned is still set at next sync start."); + do_check_true(getReassigned()); + } + + function beforeSuccessfulSync() { + _("Undoing test changes."); + rotary.collectionHandler = oldHandler; + } + + function afterSuccessfulSync() { + Svc.Obs.remove("weave:service:login:start", onLoginStart); + Service.startOver(); + server.stop(deferred.resolve); + } + + let firstNotification = "weave:service:sync:finish"; + let secondNotification = "weave:service:sync:finish"; + let thirdNotification = "weave:service:sync:finish"; + + let nodeFetched = false; + + // Track the time. We want to make sure the duration between the first and + // second sync is small, and then that the duration between second and third + // is set to be large. + let now; + + function onFirstSync() { + _("First sync completed."); + Svc.Obs.remove(firstNotification, onFirstSync); + Svc.Obs.add(secondNotification, onSecondSync); + + do_check_eq(Service.clusterURL, ""); + + _("Adding observer that lastSyncReassigned is still set on login."); + Svc.Obs.add("weave:service:login:start", onLoginStart); + + // We got a 401 mid-sync, and set the pref accordingly. + do_check_true(Services.prefs.getBoolPref("services.sync.lastSyncReassigned")); + + // Track whether we fetched node/weave. We want to wait for the second + // sync to finish so that we're cleaned up for the next test, so don't + // run_next_test in the node handler. + nodeFetched = false; + + // Verify that the client requests a node reassignment. + // Install a node handler to watch for these requests. + installNodeHandler(server, function () { + nodeFetched = true; + }); + + // Update the timestamp. + now = Date.now(); + } + + function onSecondSync() { + _("Second sync completed."); + Svc.Obs.remove(secondNotification, onSecondSync); + Svc.Obs.add(thirdNotification, onThirdSync); + + // This sync occurred within the backoff interval. + let elapsedTime = Date.now() - now; + do_check_true(elapsedTime < MINIMUM_BACKOFF_INTERVAL); + + // This pref will be true until a sync completes successfully. + do_check_true(getReassigned()); + + // The timer will be set for some distant time. + // We store nextSync in prefs, which offers us only limited resolution. + // Include that logic here. + let expectedNextSync = 1000 * Math.floor((now + MINIMUM_BACKOFF_INTERVAL) / 1000); + _("Next sync scheduled for " + Service.scheduler.nextSync); + _("Expected to be slightly greater than " + expectedNextSync); + + do_check_true(Service.scheduler.nextSync >= expectedNextSync); + do_check_true(!!Service.scheduler.syncTimer); + + // Undo our evil scheme. + beforeSuccessfulSync(); + + // Bring the timer forward to kick off a successful sync, so we can watch + // the pref get cleared. + Service.scheduler.scheduleNextSync(0); + } + + function onThirdSync() { + Svc.Obs.remove(thirdNotification, onThirdSync); + + // That'll do for now; no more syncs. + Service.scheduler.clearSyncTriggers(); + + // Make absolutely sure that any event listeners are done with their work + // before we proceed. + waitForZeroTimer(function () { + _("Third sync nextTick."); + do_check_false(getReassigned()); + do_check_true(nodeFetched); + afterSuccessfulSync(); + }); + } + + Svc.Obs.add(firstNotification, onFirstSync); + + now = Date.now(); + Service.sync(); + yield deferred.promise; +}); diff --git a/services/sync/tests/unit/test_password_store.js b/services/sync/tests/unit/test_password_store.js new file mode 100644 index 0000000000..d232d5e634 --- /dev/null +++ b/services/sync/tests/unit/test_password_store.js @@ -0,0 +1,199 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +Cu.import("resource://services-sync/engines/passwords.js"); +Cu.import("resource://services-sync/service.js"); +Cu.import("resource://services-sync/util.js"); + + +function checkRecord(name, record, expectedCount, timeCreated, + expectedTimeCreated, timePasswordChanged, + expectedTimePasswordChanged, recordIsUpdated) { + let engine = Service.engineManager.get("passwords"); + let store = engine._store; + + let count = {}; + let logins = Services.logins.findLogins(count, record.hostname, + record.formSubmitURL, null); + + _("Record" + name + ":" + JSON.stringify(logins)); + _("Count" + name + ":" + count.value); + + do_check_eq(count.value, expectedCount); + + if (expectedCount > 0) { + do_check_true(!!store.getAllIDs()[record.id]); + let stored_record = logins[0].QueryInterface(Ci.nsILoginMetaInfo); + + if (timeCreated !== undefined) { + do_check_eq(stored_record.timeCreated, expectedTimeCreated); + } + + if (timePasswordChanged !== undefined) { + if (recordIsUpdated) { + do_check_true(stored_record.timePasswordChanged >= expectedTimePasswordChanged); + } else { + do_check_eq(stored_record.timePasswordChanged, expectedTimePasswordChanged); + } + return stored_record.timePasswordChanged; + } + } else { + do_check_true(!store.getAllIDs()[record.id]); + } +} + + +function changePassword(name, hostname, password, expectedCount, timeCreated, + expectedTimeCreated, timePasswordChanged, + expectedTimePasswordChanged, insert, recordIsUpdated) { + + const BOGUS_GUID = "zzzzzz" + hostname; + + let record = {id: BOGUS_GUID, + hostname: hostname, + formSubmitURL: hostname, + username: "john", + password: password, + usernameField: "username", + passwordField: "password"}; + + if (timeCreated !== undefined) { + record.timeCreated = timeCreated; + } + + if (timePasswordChanged !== undefined) { + record.timePasswordChanged = timePasswordChanged; + } + + + let engine = Service.engineManager.get("passwords"); + let store = engine._store; + + if (insert) { + do_check_eq(store.applyIncomingBatch([record]).length, 0); + } + + return checkRecord(name, record, expectedCount, timeCreated, + expectedTimeCreated, timePasswordChanged, + expectedTimePasswordChanged, recordIsUpdated); + +} + + +function test_apply_records_with_times(hostname, timeCreated, timePasswordChanged) { + // The following record is going to be inserted in the store and it needs + // to be found there. Then its timestamps are going to be compared to + // the expected values. + changePassword(" ", hostname, "password", 1, timeCreated, timeCreated, + timePasswordChanged, timePasswordChanged, true); +} + + +function test_apply_multiple_records_with_times() { + // The following records are going to be inserted in the store and they need + // to be found there. Then their timestamps are going to be compared to + // the expected values. + changePassword("A", "http://foo.a.com", "password", 1, undefined, undefined, + undefined, undefined, true); + changePassword("B", "http://foo.b.com", "password", 1, 1000, 1000, undefined, + undefined, true); + changePassword("C", "http://foo.c.com", "password", 1, undefined, undefined, + 1000, 1000, true); + changePassword("D", "http://foo.d.com", "password", 1, 1000, 1000, 1000, + 1000, true); + + // The following records are not going to be inserted in the store and they + // are not going to be found there. + changePassword("NotInStoreA", "http://foo.aaaa.com", "password", 0, + undefined, undefined, undefined, undefined, false); + changePassword("NotInStoreB", "http://foo.bbbb.com", "password", 0, 1000, + 1000, undefined, undefined, false); + changePassword("NotInStoreC", "http://foo.cccc.com", "password", 0, + undefined, undefined, 1000, 1000, false); + changePassword("NotInStoreD", "http://foo.dddd.com", "password", 0, 1000, + 1000, 1000, 1000, false); +} + + +function test_apply_same_record_with_different_times() { + // The following record is going to be inserted multiple times in the store + // and it needs to be found there. Then its timestamps are going to be + // compared to the expected values. + var timePasswordChanged = 100; + timePasswordChanged = changePassword("A", "http://a.tn", "password", 1, 100, + 100, 100, timePasswordChanged, true); + timePasswordChanged = changePassword("A", "http://a.tn", "password", 1, 100, + 100, 800, timePasswordChanged, true, + true); + timePasswordChanged = changePassword("A", "http://a.tn", "password", 1, 500, + 100, 800, timePasswordChanged, true, + true); + timePasswordChanged = changePassword("A", "http://a.tn", "password2", 1, 500, + 100, 1536213005222, timePasswordChanged, + true, true); + timePasswordChanged = changePassword("A", "http://a.tn", "password2", 1, 500, + 100, 800, timePasswordChanged, true, true); +} + + +function run_test() { + initTestLogging("Trace"); + Log.repository.getLogger("Sync.Engine.Passwords").level = Log.Level.Trace; + Log.repository.getLogger("Sync.Store.Passwords").level = Log.Level.Trace; + + const BOGUS_GUID_A = "zzzzzzzzzzzz"; + const BOGUS_GUID_B = "yyyyyyyyyyyy"; + let recordA = {id: BOGUS_GUID_A, + hostname: "http://foo.bar.com", + formSubmitURL: "http://foo.bar.com/baz", + httpRealm: "secure", + username: "john", + password: "smith", + usernameField: "username", + passwordField: "password"}; + let recordB = {id: BOGUS_GUID_B, + hostname: "http://foo.baz.com", + formSubmitURL: "http://foo.baz.com/baz", + username: "john", + password: "smith", + usernameField: "username", + passwordField: "password"}; + + let engine = Service.engineManager.get("passwords"); + let store = engine._store; + + try { + do_check_eq(store.applyIncomingBatch([recordA, recordB]).length, 0); + + // Only the good record makes it to Services.logins. + let badCount = {}; + let goodCount = {}; + let badLogins = Services.logins.findLogins(badCount, recordA.hostname, + recordA.formSubmitURL, + recordA.httpRealm); + let goodLogins = Services.logins.findLogins(goodCount, recordB.hostname, + recordB.formSubmitURL, null); + + _("Bad: " + JSON.stringify(badLogins)); + _("Good: " + JSON.stringify(goodLogins)); + _("Count: " + badCount.value + ", " + goodCount.value); + + do_check_eq(goodCount.value, 1); + do_check_eq(badCount.value, 0); + + do_check_true(!!store.getAllIDs()[BOGUS_GUID_B]); + do_check_true(!store.getAllIDs()[BOGUS_GUID_A]); + + test_apply_records_with_times("http://afoo.baz.com", undefined, undefined); + test_apply_records_with_times("http://bfoo.baz.com", 1000, undefined); + test_apply_records_with_times("http://cfoo.baz.com", undefined, 2000); + test_apply_records_with_times("http://dfoo.baz.com", 1000, 2000); + + test_apply_multiple_records_with_times(); + + test_apply_same_record_with_different_times(); + + } finally { + store.wipe(); + } +}
\ No newline at end of file diff --git a/services/sync/tests/unit/test_password_tracker.js b/services/sync/tests/unit/test_password_tracker.js new file mode 100644 index 0000000000..09ca141a68 --- /dev/null +++ b/services/sync/tests/unit/test_password_tracker.js @@ -0,0 +1,101 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +Cu.import("resource://services-sync/constants.js"); +Cu.import("resource://services-sync/engines/passwords.js"); +Cu.import("resource://services-sync/engines.js"); +Cu.import("resource://services-sync/service.js"); +Cu.import("resource://services-sync/util.js"); + +Service.engineManager.register(PasswordEngine); +var engine = Service.engineManager.get("passwords"); +var store = engine._store; +var tracker = engine._tracker; + +// Don't do asynchronous writes. +tracker.persistChangedIDs = false; + +function run_test() { + initTestLogging("Trace"); + run_next_test(); +} + +add_test(function test_tracking() { + let recordNum = 0; + + _("Verify we've got an empty tracker to work with."); + do_check_empty(tracker.changedIDs); + + function createPassword() { + _("RECORD NUM: " + recordNum); + let record = {id: "GUID" + recordNum, + hostname: "http://foo.bar.com", + formSubmitURL: "http://foo.bar.com/baz", + username: "john" + recordNum, + password: "smith", + usernameField: "username", + passwordField: "password"}; + recordNum++; + let login = store._nsLoginInfoFromRecord(record); + Services.logins.addLogin(login); + } + + try { + _("Create a password record. Won't show because we haven't started tracking yet"); + createPassword(); + do_check_empty(tracker.changedIDs); + do_check_eq(tracker.score, 0); + + _("Tell the tracker to start tracking changes."); + Svc.Obs.notify("weave:engine:start-tracking"); + createPassword(); + do_check_attribute_count(tracker.changedIDs, 1); + do_check_eq(tracker.score, SCORE_INCREMENT_XLARGE); + + _("Notifying twice won't do any harm."); + Svc.Obs.notify("weave:engine:start-tracking"); + createPassword(); + do_check_attribute_count(tracker.changedIDs, 2); + do_check_eq(tracker.score, SCORE_INCREMENT_XLARGE * 2); + + _("Let's stop tracking again."); + tracker.clearChangedIDs(); + tracker.resetScore(); + Svc.Obs.notify("weave:engine:stop-tracking"); + createPassword(); + do_check_empty(tracker.changedIDs); + do_check_eq(tracker.score, 0); + + _("Notifying twice won't do any harm."); + Svc.Obs.notify("weave:engine:stop-tracking"); + createPassword(); + do_check_empty(tracker.changedIDs); + do_check_eq(tracker.score, 0); + + } finally { + _("Clean up."); + store.wipe(); + tracker.clearChangedIDs(); + tracker.resetScore(); + Svc.Obs.notify("weave:engine:stop-tracking"); + run_next_test(); + } +}); + +add_test(function test_onWipe() { + _("Verify we've got an empty tracker to work with."); + do_check_empty(tracker.changedIDs); + do_check_eq(tracker.score, 0); + + try { + _("A store wipe should increment the score"); + Svc.Obs.notify("weave:engine:start-tracking"); + store.wipe(); + + do_check_eq(tracker.score, SCORE_INCREMENT_XLARGE); + } finally { + tracker.resetScore(); + Svc.Obs.notify("weave:engine:stop-tracking"); + run_next_test(); + } +}); diff --git a/services/sync/tests/unit/test_password_validator.js b/services/sync/tests/unit/test_password_validator.js new file mode 100644 index 0000000000..a4a148fbe7 --- /dev/null +++ b/services/sync/tests/unit/test_password_validator.js @@ -0,0 +1,158 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +Components.utils.import("resource://services-sync/engines/passwords.js"); + +function getDummyServerAndClient() { + return { + server: [ + { + id: "11111", + guid: "11111", + hostname: "https://www.11111.com", + formSubmitURL: "https://www.11111.com/login", + password: "qwerty123", + passwordField: "pass", + username: "foobar", + usernameField: "user", + httpRealm: null, + }, + { + id: "22222", + guid: "22222", + hostname: "https://www.22222.org", + formSubmitURL: "https://www.22222.org/login", + password: "hunter2", + passwordField: "passwd", + username: "baz12345", + usernameField: "user", + httpRealm: null, + }, + { + id: "33333", + guid: "33333", + hostname: "https://www.33333.com", + formSubmitURL: "https://www.33333.com/login", + password: "p4ssw0rd", + passwordField: "passwad", + username: "quux", + usernameField: "user", + httpRealm: null, + }, + ], + client: [ + { + id: "11111", + guid: "11111", + hostname: "https://www.11111.com", + formSubmitURL: "https://www.11111.com/login", + password: "qwerty123", + passwordField: "pass", + username: "foobar", + usernameField: "user", + httpRealm: null, + }, + { + id: "22222", + guid: "22222", + hostname: "https://www.22222.org", + formSubmitURL: "https://www.22222.org/login", + password: "hunter2", + passwordField: "passwd", + username: "baz12345", + usernameField: "user", + httpRealm: null, + + }, + { + id: "33333", + guid: "33333", + hostname: "https://www.33333.com", + formSubmitURL: "https://www.33333.com/login", + password: "p4ssw0rd", + passwordField: "passwad", + username: "quux", + usernameField: "user", + httpRealm: null, + } + ] + }; +} + + +add_test(function test_valid() { + let { server, client } = getDummyServerAndClient(); + let validator = new PasswordValidator(); + let { problemData, clientRecords, records, deletedRecords } = + validator.compareClientWithServer(client, server); + equal(clientRecords.length, 3); + equal(records.length, 3) + equal(deletedRecords.length, 0); + deepEqual(problemData, validator.emptyProblemData()); + + run_next_test(); +}); + +add_test(function test_missing() { + let validator = new PasswordValidator(); + { + let { server, client } = getDummyServerAndClient(); + + client.pop(); + + let { problemData, clientRecords, records, deletedRecords } = + validator.compareClientWithServer(client, server); + + equal(clientRecords.length, 2); + equal(records.length, 3) + equal(deletedRecords.length, 0); + + let expected = validator.emptyProblemData(); + expected.clientMissing.push("33333"); + deepEqual(problemData, expected); + } + { + let { server, client } = getDummyServerAndClient(); + + server.pop(); + + let { problemData, clientRecords, records, deletedRecords } = + validator.compareClientWithServer(client, server); + + equal(clientRecords.length, 3); + equal(records.length, 2) + equal(deletedRecords.length, 0); + + let expected = validator.emptyProblemData(); + expected.serverMissing.push("33333"); + deepEqual(problemData, expected); + } + + run_next_test(); +}); + + +add_test(function test_deleted() { + let { server, client } = getDummyServerAndClient(); + let deletionRecord = { id: "444444", guid: "444444", deleted: true }; + + server.push(deletionRecord); + let validator = new PasswordValidator(); + + let { problemData, clientRecords, records, deletedRecords } = + validator.compareClientWithServer(client, server); + + equal(clientRecords.length, 3); + equal(records.length, 4); + deepEqual(deletedRecords, [deletionRecord]); + + let expected = validator.emptyProblemData(); + deepEqual(problemData, expected); + + run_next_test(); +}); + + +function run_test() { + run_next_test(); +} diff --git a/services/sync/tests/unit/test_places_guid_downgrade.js b/services/sync/tests/unit/test_places_guid_downgrade.js new file mode 100644 index 0000000000..2f99c4a93e --- /dev/null +++ b/services/sync/tests/unit/test_places_guid_downgrade.js @@ -0,0 +1,215 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +Cu.import("resource://gre/modules/PlacesUtils.jsm"); +Cu.import("resource://services-common/async.js"); +Cu.import("resource://services-sync/util.js"); +Cu.import("resource://services-sync/engines.js"); +Cu.import("resource://services-sync/engines/history.js"); +Cu.import("resource://services-sync/engines/bookmarks.js"); +Cu.import("resource://services-sync/service.js"); + +const kDBName = "places.sqlite"; +const storageSvc = Cc["@mozilla.org/storage/service;1"] + .getService(Ci.mozIStorageService); + +const fxuri = Utils.makeURI("http://getfirefox.com/"); +const tburi = Utils.makeURI("http://getthunderbird.com/"); + +function setPlacesDatabase(aFileName) { + removePlacesDatabase(); + _("Copying over places.sqlite."); + let file = do_get_file(aFileName); + file.copyTo(gSyncProfile, kDBName); +} + +function removePlacesDatabase() { + _("Removing places.sqlite."); + let file = gSyncProfile.clone(); + file.append(kDBName); + try { + file.remove(false); + } catch (ex) { + // Windows is awesome. NOT. + } +} + +Svc.Obs.add("places-shutdown", function () { + do_timeout(0, removePlacesDatabase); +}); + + +// Verify initial database state. Function borrowed from places tests. +add_test(function test_initial_state() { + _("Verify initial setup: v11 database is available"); + + // Mostly sanity checks our starting DB to make sure it's setup as we expect + // it to be. + let dbFile = gSyncProfile.clone(); + dbFile.append(kDBName); + let db = storageSvc.openUnsharedDatabase(dbFile); + + let stmt = db.createStatement("PRAGMA journal_mode"); + do_check_true(stmt.executeStep()); + // WAL journal mode should have been unset this database when it was migrated + // down to v10. + do_check_neq(stmt.getString(0).toLowerCase(), "wal"); + stmt.finalize(); + + do_check_true(db.indexExists("moz_bookmarks_guid_uniqueindex")); + do_check_true(db.indexExists("moz_places_guid_uniqueindex")); + + // There should be a non-zero amount of bookmarks without a guid. + stmt = db.createStatement( + "SELECT COUNT(1) " + + "FROM moz_bookmarks " + + "WHERE guid IS NULL " + ); + do_check_true(stmt.executeStep()); + do_check_neq(stmt.getInt32(0), 0); + stmt.finalize(); + + // There should be a non-zero amount of places without a guid. + stmt = db.createStatement( + "SELECT COUNT(1) " + + "FROM moz_places " + + "WHERE guid IS NULL " + ); + do_check_true(stmt.executeStep()); + do_check_neq(stmt.getInt32(0), 0); + stmt.finalize(); + + // Check our schema version to make sure it is actually at 10. + do_check_eq(db.schemaVersion, 10); + + db.close(); + + run_next_test(); +}); + +add_test(function test_history_guids() { + let engine = new HistoryEngine(Service); + let store = engine._store; + + let places = [ + { + uri: fxuri, + title: "Get Firefox!", + visits: [{ + visitDate: Date.now() * 1000, + transitionType: Ci.nsINavHistoryService.TRANSITION_LINK + }] + }, + { + uri: tburi, + title: "Get Thunderbird!", + visits: [{ + visitDate: Date.now() * 1000, + transitionType: Ci.nsINavHistoryService.TRANSITION_LINK + }] + } + ]; + PlacesUtils.asyncHistory.updatePlaces(places, { + handleError: function handleError() { + do_throw("Unexpected error in adding visit."); + }, + handleResult: function handleResult() {}, + handleCompletion: onVisitAdded + }); + + function onVisitAdded() { + let fxguid = store.GUIDForUri(fxuri, true); + let tbguid = store.GUIDForUri(tburi, true); + dump("fxguid: " + fxguid + "\n"); + dump("tbguid: " + tbguid + "\n"); + + _("History: Verify GUIDs are added to the guid column."); + let connection = PlacesUtils.history + .QueryInterface(Ci.nsPIPlacesDatabase) + .DBConnection; + let stmt = connection.createAsyncStatement( + "SELECT id FROM moz_places WHERE guid = :guid"); + + stmt.params.guid = fxguid; + let result = Async.querySpinningly(stmt, ["id"]); + do_check_eq(result.length, 1); + + stmt.params.guid = tbguid; + result = Async.querySpinningly(stmt, ["id"]); + do_check_eq(result.length, 1); + stmt.finalize(); + + _("History: Verify GUIDs weren't added to annotations."); + stmt = connection.createAsyncStatement( + "SELECT a.content AS guid FROM moz_annos a WHERE guid = :guid"); + + stmt.params.guid = fxguid; + result = Async.querySpinningly(stmt, ["guid"]); + do_check_eq(result.length, 0); + + stmt.params.guid = tbguid; + result = Async.querySpinningly(stmt, ["guid"]); + do_check_eq(result.length, 0); + stmt.finalize(); + + run_next_test(); + } +}); + +add_test(function test_bookmark_guids() { + let engine = new BookmarksEngine(Service); + let store = engine._store; + + let fxid = PlacesUtils.bookmarks.insertBookmark( + PlacesUtils.bookmarks.toolbarFolder, + fxuri, + PlacesUtils.bookmarks.DEFAULT_INDEX, + "Get Firefox!"); + let tbid = PlacesUtils.bookmarks.insertBookmark( + PlacesUtils.bookmarks.toolbarFolder, + tburi, + PlacesUtils.bookmarks.DEFAULT_INDEX, + "Get Thunderbird!"); + + let fxguid = store.GUIDForId(fxid); + let tbguid = store.GUIDForId(tbid); + + _("Bookmarks: Verify GUIDs are added to the guid column."); + let connection = PlacesUtils.history + .QueryInterface(Ci.nsPIPlacesDatabase) + .DBConnection; + let stmt = connection.createAsyncStatement( + "SELECT id FROM moz_bookmarks WHERE guid = :guid"); + + stmt.params.guid = fxguid; + let result = Async.querySpinningly(stmt, ["id"]); + do_check_eq(result.length, 1); + do_check_eq(result[0].id, fxid); + + stmt.params.guid = tbguid; + result = Async.querySpinningly(stmt, ["id"]); + do_check_eq(result.length, 1); + do_check_eq(result[0].id, tbid); + stmt.finalize(); + + _("Bookmarks: Verify GUIDs weren't added to annotations."); + stmt = connection.createAsyncStatement( + "SELECT a.content AS guid FROM moz_items_annos a WHERE guid = :guid"); + + stmt.params.guid = fxguid; + result = Async.querySpinningly(stmt, ["guid"]); + do_check_eq(result.length, 0); + + stmt.params.guid = tbguid; + result = Async.querySpinningly(stmt, ["guid"]); + do_check_eq(result.length, 0); + stmt.finalize(); + + run_next_test(); +}); + +function run_test() { + setPlacesDatabase("places_v10_from_v11.sqlite"); + + run_next_test(); +} diff --git a/services/sync/tests/unit/test_postqueue.js b/services/sync/tests/unit/test_postqueue.js new file mode 100644 index 0000000000..e60008a96e --- /dev/null +++ b/services/sync/tests/unit/test_postqueue.js @@ -0,0 +1,455 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +let { PostQueue } = Cu.import("resource://services-sync/record.js", {}); + +initTestLogging("Trace"); + +function makeRecord(nbytes) { + // make a string 2-bytes less - the added quotes will make it correct. + return { + toJSON: () => "x".repeat(nbytes-2), + } +} + +function makePostQueue(config, lastModTime, responseGenerator) { + let stats = { + posts: [], + } + let poster = (data, headers, batch, commit) => { + let thisPost = { nbytes: data.length, batch, commit }; + if (headers.length) { + thisPost.headers = headers; + } + stats.posts.push(thisPost); + return responseGenerator.next().value; + } + + let done = () => {} + let pq = new PostQueue(poster, lastModTime, config, getTestLogger(), done); + return { pq, stats }; +} + +add_test(function test_simple() { + let config = { + max_post_bytes: 1000, + max_post_records: 100, + max_batch_bytes: Infinity, + max_batch_records: Infinity, + } + + const time = 11111111; + + function* responseGenerator() { + yield { success: true, status: 200, headers: { 'x-weave-timestamp': time + 100, 'x-last-modified': time + 100 } }; + } + + let { pq, stats } = makePostQueue(config, time, responseGenerator()); + pq.enqueue(makeRecord(10)); + pq.flush(true); + + deepEqual(stats.posts, [{ + nbytes: 12, // expect our 10 byte record plus "[]" to wrap it. + commit: true, // we don't know if we have batch semantics, so committed. + headers: [["x-if-unmodified-since", time]], + batch: "true"}]); + + run_next_test(); +}); + +// Test we do the right thing when we need to make multiple posts when there +// are no batch semantics +add_test(function test_max_post_bytes_no_batch() { + let config = { + max_post_bytes: 50, + max_post_records: 4, + max_batch_bytes: Infinity, + max_batch_records: Infinity, + } + + const time = 11111111; + function* responseGenerator() { + yield { success: true, status: 200, headers: { 'x-weave-timestamp': time + 100, 'x-last-modified': time + 100 } }; + yield { success: true, status: 200, headers: { 'x-weave-timestamp': time + 200, 'x-last-modified': time + 200 } }; + } + + let { pq, stats } = makePostQueue(config, time, responseGenerator()); + pq.enqueue(makeRecord(20)); // total size now 22 bytes - "[" + record + "]" + pq.enqueue(makeRecord(20)); // total size now 43 bytes - "[" + record + "," + record + "]" + pq.enqueue(makeRecord(20)); // this will exceed our byte limit, so will be in the 2nd POST. + pq.flush(true); + + deepEqual(stats.posts, [ + { + nbytes: 43, // 43 for the first post + commit: false, + headers: [["x-if-unmodified-since", time]], + batch: "true", + },{ + nbytes: 22, + commit: false, // we know we aren't in a batch, so never commit. + headers: [["x-if-unmodified-since", time + 100]], + batch: null, + } + ]); + equal(pq.lastModified, time + 200); + + run_next_test(); +}); + +// Similar to the above, but we've hit max_records instead of max_bytes. +add_test(function test_max_post_records_no_batch() { + let config = { + max_post_bytes: 100, + max_post_records: 2, + max_batch_bytes: Infinity, + max_batch_records: Infinity, + } + + const time = 11111111; + + function* responseGenerator() { + yield { success: true, status: 200, headers: { 'x-weave-timestamp': time + 100, 'x-last-modified': time + 100 } }; + yield { success: true, status: 200, headers: { 'x-weave-timestamp': time + 200, 'x-last-modified': time + 200 } }; + } + + let { pq, stats } = makePostQueue(config, time, responseGenerator()); + pq.enqueue(makeRecord(20)); // total size now 22 bytes - "[" + record + "]" + pq.enqueue(makeRecord(20)); // total size now 43 bytes - "[" + record + "," + record + "]" + pq.enqueue(makeRecord(20)); // this will exceed our records limit, so will be in the 2nd POST. + pq.flush(true); + + deepEqual(stats.posts, [ + { + nbytes: 43, // 43 for the first post + commit: false, + batch: "true", + headers: [["x-if-unmodified-since", time]], + },{ + nbytes: 22, + commit: false, // we know we aren't in a batch, so never commit. + batch: null, + headers: [["x-if-unmodified-since", time + 100]], + } + ]); + equal(pq.lastModified, time + 200); + + run_next_test(); +}); + +// Batch tests. + +// Test making a single post when batch semantics are in place. +add_test(function test_single_batch() { + let config = { + max_post_bytes: 1000, + max_post_records: 100, + max_batch_bytes: 2000, + max_batch_records: 200, + } + const time = 11111111; + function* responseGenerator() { + yield { success: true, status: 202, obj: { batch: 1234 }, + headers: { 'x-last-modified': time, 'x-weave-timestamp': time + 100 }, + }; + } + + let { pq, stats } = makePostQueue(config, time, responseGenerator()); + ok(pq.enqueue(makeRecord(10)).enqueued); + pq.flush(true); + + deepEqual(stats.posts, [ + { + nbytes: 12, // expect our 10 byte record plus "[]" to wrap it. + commit: true, // we don't know if we have batch semantics, so committed. + batch: "true", + headers: [["x-if-unmodified-since", time]], + } + ]); + + run_next_test(); +}); + +// Test we do the right thing when we need to make multiple posts when there +// are batch semantics in place. +add_test(function test_max_post_bytes_batch() { + let config = { + max_post_bytes: 50, + max_post_records: 4, + max_batch_bytes: 5000, + max_batch_records: 100, + } + + const time = 11111111; + function* responseGenerator() { + yield { success: true, status: 202, obj: { batch: 1234 }, + headers: { 'x-last-modified': time, 'x-weave-timestamp': time + 100 }, + }; + yield { success: true, status: 202, obj: { batch: 1234 }, + headers: { 'x-last-modified': time + 200, 'x-weave-timestamp': time + 200 }, + }; + } + + let { pq, stats } = makePostQueue(config, time, responseGenerator()); + ok(pq.enqueue(makeRecord(20)).enqueued); // total size now 22 bytes - "[" + record + "]" + ok(pq.enqueue(makeRecord(20)).enqueued); // total size now 43 bytes - "[" + record + "," + record + "]" + ok(pq.enqueue(makeRecord(20)).enqueued); // this will exceed our byte limit, so will be in the 2nd POST. + pq.flush(true); + + deepEqual(stats.posts, [ + { + nbytes: 43, // 43 for the first post + commit: false, + batch: "true", + headers: [['x-if-unmodified-since', time]], + },{ + nbytes: 22, + commit: true, + batch: 1234, + headers: [['x-if-unmodified-since', time]], + } + ]); + + equal(pq.lastModified, time + 200); + + run_next_test(); +}); + +// Test we do the right thing when the batch bytes limit is exceeded. +add_test(function test_max_post_bytes_batch() { + let config = { + max_post_bytes: 50, + max_post_records: 20, + max_batch_bytes: 70, + max_batch_records: 100, + } + + const time0 = 11111111; + const time1 = 22222222; + function* responseGenerator() { + yield { success: true, status: 202, obj: { batch: 1234 }, + headers: { 'x-last-modified': time0, 'x-weave-timestamp': time0 + 100 }, + }; + yield { success: true, status: 202, obj: { batch: 1234 }, + headers: { 'x-last-modified': time1, 'x-weave-timestamp': time1 }, + }; + yield { success: true, status: 202, obj: { batch: 5678 }, + headers: { 'x-last-modified': time1, 'x-weave-timestamp': time1 + 100 }, + }; + yield { success: true, status: 202, obj: { batch: 5678 }, + headers: { 'x-last-modified': time1 + 200, 'x-weave-timestamp': time1 + 200 }, + }; + } + + let { pq, stats } = makePostQueue(config, time0, responseGenerator()); + ok(pq.enqueue(makeRecord(20)).enqueued); // total size now 22 bytes - "[" + record + "]" + ok(pq.enqueue(makeRecord(20)).enqueued); // total size now 43 bytes - "[" + record + "," + record + "]" + // this will exceed our POST byte limit, so will be in the 2nd POST - but still in the first batch. + ok(pq.enqueue(makeRecord(20)).enqueued); // 22 bytes for 2nd post, 55 bytes in the batch. + // this will exceed our batch byte limit, so will be in a new batch. + ok(pq.enqueue(makeRecord(20)).enqueued); // 22 bytes in 3rd post/2nd batch + ok(pq.enqueue(makeRecord(20)).enqueued); // 43 bytes in 3rd post/2nd batch + // This will exceed POST byte limit, so will be in the 4th post, part of the 2nd batch. + ok(pq.enqueue(makeRecord(20)).enqueued); // 22 bytes for 4th post/2nd batch + pq.flush(true); + + deepEqual(stats.posts, [ + { + nbytes: 43, // 43 for the first post + commit: false, + batch: "true", + headers: [['x-if-unmodified-since', time0]], + },{ + // second post of 22 bytes in the first batch, committing it. + nbytes: 22, + commit: true, + batch: 1234, + headers: [['x-if-unmodified-since', time0]], + }, { + // 3rd post of 43 bytes in a new batch, not yet committing it. + nbytes: 43, + commit: false, + batch: "true", + headers: [['x-if-unmodified-since', time1]], + },{ + // 4th post of 22 bytes in second batch, committing it. + nbytes: 22, + commit: true, + batch: 5678, + headers: [['x-if-unmodified-since', time1]], + }, + ]); + + equal(pq.lastModified, time1 + 200); + + run_next_test(); +}); + +// Test we split up the posts when we exceed the record limit when batch semantics +// are in place. +add_test(function test_max_post_bytes_batch() { + let config = { + max_post_bytes: 1000, + max_post_records: 2, + max_batch_bytes: 5000, + max_batch_records: 100, + } + + const time = 11111111; + function* responseGenerator() { + yield { success: true, status: 202, obj: { batch: 1234 }, + headers: { 'x-last-modified': time, 'x-weave-timestamp': time + 100 }, + }; + yield { success: true, status: 202, obj: { batch: 1234 }, + headers: { 'x-last-modified': time + 200, 'x-weave-timestamp': time + 200 }, + }; + } + + let { pq, stats } = makePostQueue(config, time, responseGenerator()); + ok(pq.enqueue(makeRecord(20)).enqueued); // total size now 22 bytes - "[" + record + "]" + ok(pq.enqueue(makeRecord(20)).enqueued); // total size now 43 bytes - "[" + record + "," + record + "]" + ok(pq.enqueue(makeRecord(20)).enqueued); // will exceed record limit, so will be in 2nd post. + pq.flush(true); + + deepEqual(stats.posts, [ + { + nbytes: 43, // 43 for the first post + commit: false, + batch: "true", + headers: [['x-if-unmodified-since', time]], + },{ + nbytes: 22, + commit: true, + batch: 1234, + headers: [['x-if-unmodified-since', time]], + } + ]); + + equal(pq.lastModified, time + 200); + + run_next_test(); +}); + +// Test that a single huge record fails to enqueue +add_test(function test_huge_record() { + let config = { + max_post_bytes: 50, + max_post_records: 100, + max_batch_bytes: 5000, + max_batch_records: 100, + } + + const time = 11111111; + function* responseGenerator() { + yield { success: true, status: 202, obj: { batch: 1234 }, + headers: { 'x-last-modified': time, 'x-weave-timestamp': time + 100 }, + }; + yield { success: true, status: 202, obj: { batch: 1234 }, + headers: { 'x-last-modified': time + 200, 'x-weave-timestamp': time + 200 }, + }; + } + + let { pq, stats } = makePostQueue(config, time, responseGenerator()); + ok(pq.enqueue(makeRecord(20)).enqueued); + + let { enqueued, error } = pq.enqueue(makeRecord(1000)); + ok(!enqueued); + notEqual(error, undefined); + + // make sure that we keep working, skipping the bad record entirely + // (handling the error the queue reported is left up to caller) + ok(pq.enqueue(makeRecord(20)).enqueued); + ok(pq.enqueue(makeRecord(20)).enqueued); + + pq.flush(true); + + deepEqual(stats.posts, [ + { + nbytes: 43, // 43 for the first post + commit: false, + batch: "true", + headers: [['x-if-unmodified-since', time]], + },{ + nbytes: 22, + commit: true, + batch: 1234, + headers: [['x-if-unmodified-since', time]], + } + ]); + + equal(pq.lastModified, time + 200); + + run_next_test(); +}); + +// Test we do the right thing when the batch record limit is exceeded. +add_test(function test_max_records_batch() { + let config = { + max_post_bytes: 1000, + max_post_records: 3, + max_batch_bytes: 10000, + max_batch_records: 5, + } + + const time0 = 11111111; + const time1 = 22222222; + function* responseGenerator() { + yield { success: true, status: 202, obj: { batch: 1234 }, + headers: { 'x-last-modified': time0, 'x-weave-timestamp': time0 + 100 }, + }; + yield { success: true, status: 202, obj: { batch: 1234 }, + headers: { 'x-last-modified': time1, 'x-weave-timestamp': time1 }, + }; + yield { success: true, status: 202, obj: { batch: 5678 }, + headers: { 'x-last-modified': time1, 'x-weave-timestamp': time1 + 100 }, + }; + yield { success: true, status: 202, obj: { batch: 5678 }, + headers: { 'x-last-modified': time1 + 200, 'x-weave-timestamp': time1 + 200 }, + }; + } + + let { pq, stats } = makePostQueue(config, time0, responseGenerator()); + + ok(pq.enqueue(makeRecord(20)).enqueued); + ok(pq.enqueue(makeRecord(20)).enqueued); + ok(pq.enqueue(makeRecord(20)).enqueued); + + ok(pq.enqueue(makeRecord(20)).enqueued); + ok(pq.enqueue(makeRecord(20)).enqueued); + + ok(pq.enqueue(makeRecord(20)).enqueued); + ok(pq.enqueue(makeRecord(20)).enqueued); + ok(pq.enqueue(makeRecord(20)).enqueued); + + ok(pq.enqueue(makeRecord(20)).enqueued); + + pq.flush(true); + + deepEqual(stats.posts, [ + { // 3 records + nbytes: 64, + commit: false, + batch: "true", + headers: [['x-if-unmodified-since', time0]], + },{ // 2 records -- end batch1 + nbytes: 43, + commit: true, + batch: 1234, + headers: [['x-if-unmodified-since', time0]], + }, { // 3 records + nbytes: 64, + commit: false, + batch: "true", + headers: [['x-if-unmodified-since', time1]], + },{ // 1 record -- end batch2 + nbytes: 22, + commit: true, + batch: 5678, + headers: [['x-if-unmodified-since', time1]], + }, + ]); + + equal(pq.lastModified, time1 + 200); + + run_next_test(); +});
\ No newline at end of file diff --git a/services/sync/tests/unit/test_prefs_store.js b/services/sync/tests/unit/test_prefs_store.js new file mode 100644 index 0000000000..9c321bceb3 --- /dev/null +++ b/services/sync/tests/unit/test_prefs_store.js @@ -0,0 +1,168 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +Cu.import("resource://gre/modules/LightweightThemeManager.jsm"); +Cu.import("resource://gre/modules/Preferences.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://services-common/utils.js"); +Cu.import("resource://services-sync/engines/prefs.js"); +Cu.import("resource://services-sync/service.js"); +Cu.import("resource://services-sync/util.js"); + +const PREFS_GUID = CommonUtils.encodeBase64URL(Services.appinfo.ID); + +loadAddonTestFunctions(); +startupManager(); + +function makePersona(id) { + return { + id: id || Math.random().toString(), + name: Math.random().toString(), + headerURL: "http://localhost:1234/a" + }; +} + +function run_test() { + _("Test fixtures."); + // read our custom prefs file before doing anything. + Services.prefs.readUserPrefs(do_get_file("prefs_test_prefs_store.js")); + // Now we've read from this file, any writes the pref service makes will be + // back to this prefs_test_prefs_store.js directly in the obj dir. This + // upsets things in confusing ways :) We avoid this by explicitly telling the + // pref service to use a file in our profile dir. + let prefFile = do_get_profile(); + prefFile.append("prefs.js"); + Services.prefs.savePrefFile(prefFile); + Services.prefs.readUserPrefs(prefFile); + + let store = Service.engineManager.get("prefs")._store; + let prefs = new Preferences(); + try { + + _("The GUID corresponds to XUL App ID."); + let allIDs = store.getAllIDs(); + let ids = Object.keys(allIDs); + do_check_eq(ids.length, 1); + do_check_eq(ids[0], PREFS_GUID); + do_check_true(allIDs[PREFS_GUID], true); + + do_check_true(store.itemExists(PREFS_GUID)); + do_check_false(store.itemExists("random-gibberish")); + + _("Unknown prefs record is created as deleted."); + let record = store.createRecord("random-gibberish", "prefs"); + do_check_true(record.deleted); + + _("Prefs record contains only prefs that should be synced."); + record = store.createRecord(PREFS_GUID, "prefs"); + do_check_eq(record.value["testing.int"], 123); + do_check_eq(record.value["testing.string"], "ohai"); + do_check_eq(record.value["testing.bool"], true); + // non-existing prefs get null as the value + do_check_eq(record.value["testing.nonexistent"], null); + // as do prefs that have a default value. + do_check_eq(record.value["testing.default"], null); + do_check_false("testing.turned.off" in record.value); + do_check_false("testing.not.turned.on" in record.value); + + _("Prefs record contains non-default pref sync prefs too."); + do_check_eq(record.value["services.sync.prefs.sync.testing.int"], null); + do_check_eq(record.value["services.sync.prefs.sync.testing.string"], null); + do_check_eq(record.value["services.sync.prefs.sync.testing.bool"], null); + do_check_eq(record.value["services.sync.prefs.sync.testing.dont.change"], null); + // but this one is a user_pref so *will* be synced. + do_check_eq(record.value["services.sync.prefs.sync.testing.turned.off"], false); + do_check_eq(record.value["services.sync.prefs.sync.testing.nonexistent"], null); + do_check_eq(record.value["services.sync.prefs.sync.testing.default"], null); + + _("Update some prefs, including one that's to be reset/deleted."); + Svc.Prefs.set("testing.deleteme", "I'm going to be deleted!"); + record = new PrefRec("prefs", PREFS_GUID); + record.value = { + "testing.int": 42, + "testing.string": "im in ur prefs", + "testing.bool": false, + "testing.deleteme": null, + "testing.somepref": "im a new pref from other device", + "services.sync.prefs.sync.testing.somepref": true + }; + store.update(record); + do_check_eq(prefs.get("testing.int"), 42); + do_check_eq(prefs.get("testing.string"), "im in ur prefs"); + do_check_eq(prefs.get("testing.bool"), false); + do_check_eq(prefs.get("testing.deleteme"), undefined); + do_check_eq(prefs.get("testing.dont.change"), "Please don't change me."); + do_check_eq(prefs.get("testing.somepref"), "im a new pref from other device"); + do_check_eq(Svc.Prefs.get("prefs.sync.testing.somepref"), true); + + _("Enable persona"); + // Ensure we don't go to the network to fetch personas and end up leaking + // stuff. + Services.io.offline = true; + do_check_false(!!prefs.get("lightweightThemes.selectedThemeID")); + do_check_eq(LightweightThemeManager.currentTheme, null); + + let persona1 = makePersona(); + let persona2 = makePersona(); + let usedThemes = JSON.stringify([persona1, persona2]); + record.value = { + "lightweightThemes.selectedThemeID": persona1.id, + "lightweightThemes.usedThemes": usedThemes + }; + store.update(record); + do_check_eq(prefs.get("lightweightThemes.selectedThemeID"), persona1.id); + do_check_true(Utils.deepEquals(LightweightThemeManager.currentTheme, + persona1)); + + _("Disable persona"); + record.value = { + "lightweightThemes.selectedThemeID": null, + "lightweightThemes.usedThemes": usedThemes + }; + store.update(record); + do_check_false(!!prefs.get("lightweightThemes.selectedThemeID")); + do_check_eq(LightweightThemeManager.currentTheme, null); + + _("Only the current app's preferences are applied."); + record = new PrefRec("prefs", "some-fake-app"); + record.value = { + "testing.int": 98 + }; + store.update(record); + do_check_eq(prefs.get("testing.int"), 42); + + _("The light-weight theme preference is handled correctly."); + let lastThemeID = undefined; + let orig_updateLightWeightTheme = store._updateLightWeightTheme; + store._updateLightWeightTheme = function(themeID) { + lastThemeID = themeID; + } + try { + record = new PrefRec("prefs", PREFS_GUID); + record.value = { + "testing.int": 42, + }; + store.update(record); + do_check_true(lastThemeID === undefined, + "should not have tried to change the theme with an unrelated pref."); + Services.prefs.setCharPref("lightweightThemes.selectedThemeID", "foo"); + record.value = { + "lightweightThemes.selectedThemeID": "foo", + }; + store.update(record); + do_check_true(lastThemeID === undefined, + "should not have tried to change the theme when the incoming pref matches current value."); + + record.value = { + "lightweightThemes.selectedThemeID": "bar", + }; + store.update(record); + do_check_eq(lastThemeID, "bar", + "should have tried to change the theme when the incoming pref was different."); + } finally { + store._updateLightWeightTheme = orig_updateLightWeightTheme; + } + } finally { + prefs.resetBranch(""); + } +} diff --git a/services/sync/tests/unit/test_prefs_tracker.js b/services/sync/tests/unit/test_prefs_tracker.js new file mode 100644 index 0000000000..17ccaa43e3 --- /dev/null +++ b/services/sync/tests/unit/test_prefs_tracker.js @@ -0,0 +1,88 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +Cu.import("resource://gre/modules/Preferences.jsm"); +Cu.import("resource://services-common/utils.js"); +Cu.import("resource://services-sync/constants.js"); +Cu.import("resource://services-sync/engines/prefs.js"); +Cu.import("resource://services-sync/service.js"); +Cu.import("resource://services-sync/util.js"); + +function run_test() { + let engine = Service.engineManager.get("prefs"); + let tracker = engine._tracker; + + // Don't write out by default. + tracker.persistChangedIDs = false; + + let prefs = new Preferences(); + + try { + + _("tracker.modified corresponds to preference."); + do_check_eq(Svc.Prefs.get("engine.prefs.modified"), undefined); + do_check_false(tracker.modified); + + tracker.modified = true; + do_check_eq(Svc.Prefs.get("engine.prefs.modified"), true); + do_check_true(tracker.modified); + + _("Engine's getChangedID() just returns the one GUID we have."); + let changedIDs = engine.getChangedIDs(); + let ids = Object.keys(changedIDs); + do_check_eq(ids.length, 1); + do_check_eq(ids[0], CommonUtils.encodeBase64URL(Services.appinfo.ID)); + + Svc.Prefs.set("engine.prefs.modified", false); + do_check_false(tracker.modified); + + _("No modified state, so no changed IDs."); + do_check_empty(engine.getChangedIDs()); + + _("Initial score is 0"); + do_check_eq(tracker.score, 0); + + _("Test fixtures."); + Svc.Prefs.set("prefs.sync.testing.int", true); + + _("Test fixtures haven't upped the tracker score yet because it hasn't started tracking yet."); + do_check_eq(tracker.score, 0); + + _("Tell the tracker to start tracking changes."); + Svc.Obs.notify("weave:engine:start-tracking"); + prefs.set("testing.int", 23); + do_check_eq(tracker.score, SCORE_INCREMENT_XLARGE); + do_check_eq(tracker.modified, true); + + _("Clearing changed IDs reset modified status."); + tracker.clearChangedIDs(); + do_check_eq(tracker.modified, false); + + _("Resetting a pref ups the score, too."); + prefs.reset("testing.int"); + do_check_eq(tracker.score, SCORE_INCREMENT_XLARGE * 2); + do_check_eq(tracker.modified, true); + tracker.clearChangedIDs(); + + _("So does changing a pref sync pref."); + Svc.Prefs.set("prefs.sync.testing.int", false); + do_check_eq(tracker.score, SCORE_INCREMENT_XLARGE * 3); + do_check_eq(tracker.modified, true); + tracker.clearChangedIDs(); + + _("Now that the pref sync pref has been flipped, changes to it won't be picked up."); + prefs.set("testing.int", 42); + do_check_eq(tracker.score, SCORE_INCREMENT_XLARGE * 3); + do_check_eq(tracker.modified, false); + tracker.clearChangedIDs(); + + _("Changing some other random pref won't do anything."); + prefs.set("testing.other", "blergh"); + do_check_eq(tracker.score, SCORE_INCREMENT_XLARGE * 3); + do_check_eq(tracker.modified, false); + + } finally { + Svc.Obs.notify("weave:engine:stop-tracking"); + prefs.resetBranch(""); + } +} diff --git a/services/sync/tests/unit/test_records_crypto.js b/services/sync/tests/unit/test_records_crypto.js new file mode 100644 index 0000000000..392a746efa --- /dev/null +++ b/services/sync/tests/unit/test_records_crypto.js @@ -0,0 +1,182 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +Cu.import("resource://gre/modules/Log.jsm"); +Cu.import("resource://services-sync/constants.js"); +Cu.import("resource://services-sync/keys.js"); +Cu.import("resource://services-sync/record.js"); +Cu.import("resource://services-sync/resource.js"); +Cu.import("resource://services-sync/service.js"); +Cu.import("resource://services-sync/util.js"); +Cu.import("resource://testing-common/services/sync/utils.js"); + +var cryptoWrap; + +function crypted_resource_handler(metadata, response) { + let obj = {id: "resource", + modified: cryptoWrap.modified, + payload: JSON.stringify(cryptoWrap.payload)}; + return httpd_basic_auth_handler(JSON.stringify(obj), metadata, response); +} + +function prepareCryptoWrap(collection, id) { + let w = new CryptoWrapper(); + w.cleartext.stuff = "my payload here"; + w.collection = collection; + w.id = id; + return w; +} + +function run_test() { + let server; + do_test_pending(); + + ensureLegacyIdentityManager(); + Service.identity.username = "john@example.com"; + Service.identity.syncKey = "a-abcde-abcde-abcde-abcde-abcde"; + let keyBundle = Service.identity.syncKeyBundle; + + try { + let log = Log.repository.getLogger("Test"); + Log.repository.rootLogger.addAppender(new Log.DumpAppender()); + + log.info("Setting up server and authenticator"); + + server = httpd_setup({"/steam/resource": crypted_resource_handler}); + + log.info("Creating a record"); + + let cryptoUri = "http://localhost:8080/crypto/steam"; + cryptoWrap = prepareCryptoWrap("steam", "resource"); + + log.info("cryptoWrap: " + cryptoWrap.toString()); + + log.info("Encrypting a record"); + + cryptoWrap.encrypt(keyBundle); + log.info("Ciphertext is " + cryptoWrap.ciphertext); + do_check_true(cryptoWrap.ciphertext != null); + + let firstIV = cryptoWrap.IV; + + log.info("Decrypting the record"); + + let payload = cryptoWrap.decrypt(keyBundle); + do_check_eq(payload.stuff, "my payload here"); + do_check_neq(payload, cryptoWrap.payload); // wrap.data.payload is the encrypted one + + log.info("Make sure multiple decrypts cause failures"); + let error = ""; + try { + payload = cryptoWrap.decrypt(keyBundle); + } + catch(ex) { + error = ex; + } + do_check_eq(error, "No ciphertext: nothing to decrypt?"); + + log.info("Re-encrypting the record with alternate payload"); + + cryptoWrap.cleartext.stuff = "another payload"; + cryptoWrap.encrypt(keyBundle); + let secondIV = cryptoWrap.IV; + payload = cryptoWrap.decrypt(keyBundle); + do_check_eq(payload.stuff, "another payload"); + + log.info("Make sure multiple encrypts use different IVs"); + do_check_neq(firstIV, secondIV); + + log.info("Make sure differing ids cause failures"); + cryptoWrap.encrypt(keyBundle); + cryptoWrap.data.id = "other"; + error = ""; + try { + cryptoWrap.decrypt(keyBundle); + } + catch(ex) { + error = ex; + } + do_check_eq(error, "Record id mismatch: resource != other"); + + log.info("Make sure wrong hmacs cause failures"); + cryptoWrap.encrypt(keyBundle); + cryptoWrap.hmac = "foo"; + error = ""; + try { + cryptoWrap.decrypt(keyBundle); + } + catch(ex) { + error = ex; + } + do_check_eq(error.substr(0, 42), "Record SHA256 HMAC mismatch: should be foo"); + + // Checking per-collection keys and default key handling. + + generateNewKeys(Service.collectionKeys); + let bu = "http://localhost:8080/storage/bookmarks/foo"; + let bookmarkItem = prepareCryptoWrap("bookmarks", "foo"); + bookmarkItem.encrypt(Service.collectionKeys.keyForCollection("bookmarks")); + log.info("Ciphertext is " + bookmarkItem.ciphertext); + do_check_true(bookmarkItem.ciphertext != null); + log.info("Decrypting the record explicitly with the default key."); + do_check_eq(bookmarkItem.decrypt(Service.collectionKeys._default).stuff, "my payload here"); + + // Per-collection keys. + // Generate a key for "bookmarks". + generateNewKeys(Service.collectionKeys, ["bookmarks"]); + bookmarkItem = prepareCryptoWrap("bookmarks", "foo"); + do_check_eq(bookmarkItem.collection, "bookmarks"); + + // Encrypt. This'll use the "bookmarks" encryption key, because we have a + // special key for it. The same key will need to be used for decryption. + bookmarkItem.encrypt(Service.collectionKeys.keyForCollection("bookmarks")); + do_check_true(bookmarkItem.ciphertext != null); + + // Attempt to use the default key, because this is a collision that could + // conceivably occur in the real world. Decryption will error, because + // it's not the bookmarks key. + let err; + try { + bookmarkItem.decrypt(Service.collectionKeys._default); + } catch (ex) { + err = ex; + } + do_check_eq("Record SHA256 HMAC mismatch", err.substr(0, 27)); + + // Explicitly check that it's using the bookmarks key. + // This should succeed. + do_check_eq(bookmarkItem.decrypt(Service.collectionKeys.keyForCollection("bookmarks")).stuff, + "my payload here"); + + do_check_true(Service.collectionKeys.hasKeysFor(["bookmarks"])); + + // Add a key for some new collection and verify that it isn't the + // default key. + do_check_false(Service.collectionKeys.hasKeysFor(["forms"])); + do_check_false(Service.collectionKeys.hasKeysFor(["bookmarks", "forms"])); + let oldFormsKey = Service.collectionKeys.keyForCollection("forms"); + do_check_eq(oldFormsKey, Service.collectionKeys._default); + let newKeys = Service.collectionKeys.ensureKeysFor(["forms"]); + do_check_true(newKeys.hasKeysFor(["forms"])); + do_check_true(newKeys.hasKeysFor(["bookmarks", "forms"])); + let newFormsKey = newKeys.keyForCollection("forms"); + do_check_neq(newFormsKey, oldFormsKey); + + // Verify that this doesn't overwrite keys + let regetKeys = newKeys.ensureKeysFor(["forms"]); + do_check_eq(regetKeys.keyForCollection("forms"), newFormsKey); + + const emptyKeys = new CollectionKeyManager(); + payload = { + default: Service.collectionKeys._default.keyPairB64, + collections: {} + }; + // Verify that not passing `modified` doesn't throw + emptyKeys.setContents(payload, null); + + log.info("Done!"); + } + finally { + server.stop(do_test_finished); + } +} diff --git a/services/sync/tests/unit/test_records_wbo.js b/services/sync/tests/unit/test_records_wbo.js new file mode 100644 index 0000000000..e3277b0a70 --- /dev/null +++ b/services/sync/tests/unit/test_records_wbo.js @@ -0,0 +1,86 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +Cu.import("resource://services-sync/record.js"); +Cu.import("resource://services-sync/identity.js"); +Cu.import("resource://services-sync/resource.js"); +Cu.import("resource://services-sync/service.js"); +Cu.import("resource://services-sync/util.js"); +Cu.import("resource://testing-common/services/sync/utils.js"); + + +function test_toJSON() { + _("Create a record, for now without a TTL."); + let wbo = new WBORecord("coll", "a_record"); + wbo.modified = 12345; + wbo.sortindex = 42; + wbo.payload = {}; + + _("Verify that the JSON representation contains the WBO properties, but not TTL."); + let json = JSON.parse(JSON.stringify(wbo)); + do_check_eq(json.modified, 12345); + do_check_eq(json.sortindex, 42); + do_check_eq(json.payload, "{}"); + do_check_false("ttl" in json); + + _("Set a TTL, make sure it's present in the JSON representation."); + wbo.ttl = 30*60; + json = JSON.parse(JSON.stringify(wbo)); + do_check_eq(json.ttl, 30*60); +} + + +function test_fetch() { + let record = {id: "asdf-1234-asdf-1234", + modified: 2454725.98283, + payload: JSON.stringify({cheese: "roquefort"})}; + let record2 = {id: "record2", + modified: 2454725.98284, + payload: JSON.stringify({cheese: "gruyere"})}; + let coll = [{id: "record2", + modified: 2454725.98284, + payload: JSON.stringify({cheese: "gruyere"})}]; + + _("Setting up server."); + let server = httpd_setup({ + "/record": httpd_handler(200, "OK", JSON.stringify(record)), + "/record2": httpd_handler(200, "OK", JSON.stringify(record2)), + "/coll": httpd_handler(200, "OK", JSON.stringify(coll)) + }); + do_test_pending(); + + try { + _("Fetching a WBO record"); + let rec = new WBORecord("coll", "record"); + rec.fetch(Service.resource(server.baseURI + "/record")); + do_check_eq(rec.id, "asdf-1234-asdf-1234"); // NOT "record"! + + do_check_eq(rec.modified, 2454725.98283); + do_check_eq(typeof(rec.payload), "object"); + do_check_eq(rec.payload.cheese, "roquefort"); + + _("Fetching a WBO record using the record manager"); + let rec2 = Service.recordManager.get(server.baseURI + "/record2"); + do_check_eq(rec2.id, "record2"); + do_check_eq(rec2.modified, 2454725.98284); + do_check_eq(typeof(rec2.payload), "object"); + do_check_eq(rec2.payload.cheese, "gruyere"); + do_check_eq(Service.recordManager.response.status, 200); + + // Testing collection extraction. + _("Extracting collection."); + let rec3 = new WBORecord("tabs", "foo"); // Create through constructor. + do_check_eq(rec3.collection, "tabs"); + + } finally { + server.stop(do_test_finished); + } +} + +function run_test() { + initTestLogging("Trace"); + ensureLegacyIdentityManager(); + + test_toJSON(); + test_fetch(); +} diff --git a/services/sync/tests/unit/test_resource.js b/services/sync/tests/unit/test_resource.js new file mode 100644 index 0000000000..8f5534c929 --- /dev/null +++ b/services/sync/tests/unit/test_resource.js @@ -0,0 +1,502 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +Cu.import("resource://gre/modules/Log.jsm"); +Cu.import("resource://services-common/observers.js"); +Cu.import("resource://services-sync/identity.js"); +Cu.import("resource://services-sync/resource.js"); +Cu.import("resource://services-sync/util.js"); + +var logger; + +var fetched = false; +function server_open(metadata, response) { + let body; + if (metadata.method == "GET") { + fetched = true; + body = "This path exists"; + response.setStatusLine(metadata.httpVersion, 200, "OK"); + } else { + body = "Wrong request method"; + response.setStatusLine(metadata.httpVersion, 405, "Method Not Allowed"); + } + response.bodyOutputStream.write(body, body.length); +} + +function server_protected(metadata, response) { + let body; + + if (basic_auth_matches(metadata, "guest", "guest")) { + body = "This path exists and is protected"; + response.setStatusLine(metadata.httpVersion, 200, "OK, authorized"); + response.setHeader("WWW-Authenticate", 'Basic realm="secret"', false); + } else { + body = "This path exists and is protected - failed"; + response.setStatusLine(metadata.httpVersion, 401, "Unauthorized"); + response.setHeader("WWW-Authenticate", 'Basic realm="secret"', false); + } + + response.bodyOutputStream.write(body, body.length); +} + +function server_404(metadata, response) { + let body = "File not found"; + response.setStatusLine(metadata.httpVersion, 404, "Not Found"); + response.bodyOutputStream.write(body, body.length); +} + +var pacFetched = false; +function server_pac(metadata, response) { + pacFetched = true; + let body = 'function FindProxyForURL(url, host) { return "DIRECT"; }'; + response.setStatusLine(metadata.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "application/x-ns-proxy-autoconfig", false); + response.bodyOutputStream.write(body, body.length); +} + + +var sample_data = { + some: "sample_data", + injson: "format", + number: 42 +}; + +function server_upload(metadata, response) { + let body; + + let input = readBytesFromInputStream(metadata.bodyInputStream); + if (input == JSON.stringify(sample_data)) { + body = "Valid data upload via " + metadata.method; + response.setStatusLine(metadata.httpVersion, 200, "OK"); + } else { + body = "Invalid data upload via " + metadata.method + ': ' + input; + response.setStatusLine(metadata.httpVersion, 500, "Internal Server Error"); + } + + response.bodyOutputStream.write(body, body.length); +} + +function server_delete(metadata, response) { + let body; + if (metadata.method == "DELETE") { + body = "This resource has been deleted"; + response.setStatusLine(metadata.httpVersion, 200, "OK"); + } else { + body = "Wrong request method"; + response.setStatusLine(metadata.httpVersion, 405, "Method Not Allowed"); + } + response.bodyOutputStream.write(body, body.length); +} + +function server_json(metadata, response) { + let body = JSON.stringify(sample_data); + response.setStatusLine(metadata.httpVersion, 200, "OK"); + response.bodyOutputStream.write(body, body.length); +} + +const TIMESTAMP = 1274380461; + +function server_timestamp(metadata, response) { + let body = "Thank you for your request"; + response.setHeader("X-Weave-Timestamp", ''+TIMESTAMP, false); + response.setStatusLine(metadata.httpVersion, 200, "OK"); + response.bodyOutputStream.write(body, body.length); +} + +function server_backoff(metadata, response) { + let body = "Hey, back off!"; + response.setHeader("X-Weave-Backoff", '600', false); + response.setStatusLine(metadata.httpVersion, 200, "OK"); + response.bodyOutputStream.write(body, body.length); +} + +function server_quota_notice(request, response) { + let body = "You're approaching quota."; + response.setHeader("X-Weave-Quota-Remaining", '1048576', false); + response.setStatusLine(request.httpVersion, 200, "OK"); + response.bodyOutputStream.write(body, body.length); +} + +function server_quota_error(request, response) { + let body = "14"; + response.setHeader("X-Weave-Quota-Remaining", '-1024', false); + response.setStatusLine(request.httpVersion, 400, "OK"); + response.bodyOutputStream.write(body, body.length); +} + +function server_headers(metadata, response) { + let ignore_headers = ["host", "user-agent", "accept", "accept-language", + "accept-encoding", "accept-charset", "keep-alive", + "connection", "pragma", "cache-control", + "content-length"]; + let headers = metadata.headers; + let header_names = []; + while (headers.hasMoreElements()) { + let header = headers.getNext().toString(); + if (ignore_headers.indexOf(header) == -1) { + header_names.push(header); + } + } + header_names = header_names.sort(); + + headers = {}; + for (let header of header_names) { + headers[header] = metadata.getHeader(header); + } + let body = JSON.stringify(headers); + response.setStatusLine(metadata.httpVersion, 200, "OK"); + response.bodyOutputStream.write(body, body.length); +} + +function run_test() { + initTestLogging("Trace"); + + do_test_pending(); + + let logger = Log.repository.getLogger('Test'); + Log.repository.rootLogger.addAppender(new Log.DumpAppender()); + + let server = httpd_setup({ + "/open": server_open, + "/protected": server_protected, + "/404": server_404, + "/upload": server_upload, + "/delete": server_delete, + "/json": server_json, + "/timestamp": server_timestamp, + "/headers": server_headers, + "/backoff": server_backoff, + "/pac1": server_pac, + "/quota-notice": server_quota_notice, + "/quota-error": server_quota_error + }); + + Svc.Prefs.set("network.numRetries", 1); // speed up test + + // This apparently has to come first in order for our PAC URL to be hit. + // Don't put any other HTTP requests earlier in the file! + _("Testing handling of proxy auth redirection."); + PACSystemSettings.PACURI = server.baseURI + "/pac1"; + installFakePAC(); + let proxiedRes = new Resource(server.baseURI + "/open"); + let content = proxiedRes.get(); + do_check_true(pacFetched); + do_check_true(fetched); + do_check_eq(content, "This path exists"); + pacFetched = fetched = false; + uninstallFakePAC(); + + _("Resource object members"); + let res = new Resource(server.baseURI + "/open"); + do_check_true(res.uri instanceof Ci.nsIURI); + do_check_eq(res.uri.spec, server.baseURI + "/open"); + do_check_eq(res.spec, server.baseURI + "/open"); + do_check_eq(typeof res.headers, "object"); + do_check_eq(typeof res.authenticator, "object"); + // Initially res.data is null since we haven't performed a GET or + // PUT/POST request yet. + do_check_eq(res.data, null); + + _("GET a non-password-protected resource"); + content = res.get(); + do_check_eq(content, "This path exists"); + do_check_eq(content.status, 200); + do_check_true(content.success); + // res.data has been updated with the result from the request + do_check_eq(res.data, content); + + // Observe logging messages. + logger = res._log; + let dbg = logger.debug; + let debugMessages = []; + logger.debug = function (msg) { + debugMessages.push(msg); + dbg.call(this, msg); + } + + // Since we didn't receive proper JSON data, accessing content.obj + // will result in a SyntaxError from JSON.parse. + // Furthermore, we'll have logged. + let didThrow = false; + try { + content.obj; + } catch (ex) { + didThrow = true; + } + do_check_true(didThrow); + do_check_eq(debugMessages.length, 1); + do_check_eq(debugMessages[0], + "Parse fail: Response body starts: \"\"This path exists\"\"."); + logger.debug = dbg; + + _("Test that the BasicAuthenticator doesn't screw up header case."); + let res1 = new Resource(server.baseURI + "/foo"); + res1.setHeader("Authorization", "Basic foobar"); + do_check_eq(res1.headers["authorization"], "Basic foobar"); + + _("GET a password protected resource (test that it'll fail w/o pass, no throw)"); + let res2 = new Resource(server.baseURI + "/protected"); + content = res2.get(); + do_check_eq(content, "This path exists and is protected - failed"); + do_check_eq(content.status, 401); + do_check_false(content.success); + + _("GET a password protected resource"); + let res3 = new Resource(server.baseURI + "/protected"); + let identity = new IdentityManager(); + let auth = identity.getBasicResourceAuthenticator("guest", "guest"); + res3.authenticator = auth; + do_check_eq(res3.authenticator, auth); + content = res3.get(); + do_check_eq(content, "This path exists and is protected"); + do_check_eq(content.status, 200); + do_check_true(content.success); + + _("GET a non-existent resource (test that it'll fail, but not throw)"); + let res4 = new Resource(server.baseURI + "/404"); + content = res4.get(); + do_check_eq(content, "File not found"); + do_check_eq(content.status, 404); + do_check_false(content.success); + + // Check some headers of the 404 response + do_check_eq(content.headers.connection, "close"); + do_check_eq(content.headers.server, "httpd.js"); + do_check_eq(content.headers["content-length"], 14); + + _("PUT to a resource (string)"); + let res5 = new Resource(server.baseURI + "/upload"); + content = res5.put(JSON.stringify(sample_data)); + do_check_eq(content, "Valid data upload via PUT"); + do_check_eq(content.status, 200); + do_check_eq(res5.data, content); + + _("PUT to a resource (object)"); + content = res5.put(sample_data); + do_check_eq(content, "Valid data upload via PUT"); + do_check_eq(content.status, 200); + do_check_eq(res5.data, content); + + _("PUT without data arg (uses resource.data) (string)"); + res5.data = JSON.stringify(sample_data); + content = res5.put(); + do_check_eq(content, "Valid data upload via PUT"); + do_check_eq(content.status, 200); + do_check_eq(res5.data, content); + + _("PUT without data arg (uses resource.data) (object)"); + res5.data = sample_data; + content = res5.put(); + do_check_eq(content, "Valid data upload via PUT"); + do_check_eq(content.status, 200); + do_check_eq(res5.data, content); + + _("POST to a resource (string)"); + content = res5.post(JSON.stringify(sample_data)); + do_check_eq(content, "Valid data upload via POST"); + do_check_eq(content.status, 200); + do_check_eq(res5.data, content); + + _("POST to a resource (object)"); + content = res5.post(sample_data); + do_check_eq(content, "Valid data upload via POST"); + do_check_eq(content.status, 200); + do_check_eq(res5.data, content); + + _("POST without data arg (uses resource.data) (string)"); + res5.data = JSON.stringify(sample_data); + content = res5.post(); + do_check_eq(content, "Valid data upload via POST"); + do_check_eq(content.status, 200); + do_check_eq(res5.data, content); + + _("POST without data arg (uses resource.data) (object)"); + res5.data = sample_data; + content = res5.post(); + do_check_eq(content, "Valid data upload via POST"); + do_check_eq(content.status, 200); + do_check_eq(res5.data, content); + + _("DELETE a resource"); + let res6 = new Resource(server.baseURI + "/delete"); + content = res6.delete(); + do_check_eq(content, "This resource has been deleted") + do_check_eq(content.status, 200); + + _("JSON conversion of response body"); + let res7 = new Resource(server.baseURI + "/json"); + content = res7.get(); + do_check_eq(content, JSON.stringify(sample_data)); + do_check_eq(content.status, 200); + do_check_eq(JSON.stringify(content.obj), JSON.stringify(sample_data)); + + _("X-Weave-Timestamp header updates AsyncResource.serverTime"); + // Before having received any response containing the + // X-Weave-Timestamp header, AsyncResource.serverTime is null. + do_check_eq(AsyncResource.serverTime, null); + let res8 = new Resource(server.baseURI + "/timestamp"); + content = res8.get(); + do_check_eq(AsyncResource.serverTime, TIMESTAMP); + + _("GET: no special request headers"); + let res9 = new Resource(server.baseURI + "/headers"); + content = res9.get(); + do_check_eq(content, '{}'); + + _("PUT: Content-Type defaults to text/plain"); + content = res9.put('data'); + do_check_eq(content, JSON.stringify({"content-type": "text/plain"})); + + _("POST: Content-Type defaults to text/plain"); + content = res9.post('data'); + do_check_eq(content, JSON.stringify({"content-type": "text/plain"})); + + _("setHeader(): setting simple header"); + res9.setHeader('X-What-Is-Weave', 'awesome'); + do_check_eq(res9.headers['x-what-is-weave'], 'awesome'); + content = res9.get(); + do_check_eq(content, JSON.stringify({"x-what-is-weave": "awesome"})); + + _("setHeader(): setting multiple headers, overwriting existing header"); + res9.setHeader('X-WHAT-is-Weave', 'more awesomer'); + res9.setHeader('X-Another-Header', 'hello world'); + do_check_eq(res9.headers['x-what-is-weave'], 'more awesomer'); + do_check_eq(res9.headers['x-another-header'], 'hello world'); + content = res9.get(); + do_check_eq(content, JSON.stringify({"x-another-header": "hello world", + "x-what-is-weave": "more awesomer"})); + + _("Setting headers object"); + res9.headers = {}; + content = res9.get(); + do_check_eq(content, "{}"); + + _("PUT/POST: override default Content-Type"); + res9.setHeader('Content-Type', 'application/foobar'); + do_check_eq(res9.headers['content-type'], 'application/foobar'); + content = res9.put('data'); + do_check_eq(content, JSON.stringify({"content-type": "application/foobar"})); + content = res9.post('data'); + do_check_eq(content, JSON.stringify({"content-type": "application/foobar"})); + + + _("X-Weave-Backoff header notifies observer"); + let backoffInterval; + function onBackoff(subject, data) { + backoffInterval = subject; + } + Observers.add("weave:service:backoff:interval", onBackoff); + + let res10 = new Resource(server.baseURI + "/backoff"); + content = res10.get(); + do_check_eq(backoffInterval, 600); + + + _("X-Weave-Quota-Remaining header notifies observer on successful requests."); + let quotaValue; + function onQuota(subject, data) { + quotaValue = subject; + } + Observers.add("weave:service:quota:remaining", onQuota); + + res10 = new Resource(server.baseURI + "/quota-error"); + content = res10.get(); + do_check_eq(content.status, 400); + do_check_eq(quotaValue, undefined); // HTTP 400, so no observer notification. + + res10 = new Resource(server.baseURI + "/quota-notice"); + content = res10.get(); + do_check_eq(content.status, 200); + do_check_eq(quotaValue, 1048576); + + + _("Error handling in _request() preserves exception information"); + let error; + let res11 = new Resource("http://localhost:12345/does/not/exist"); + try { + content = res11.get(); + } catch(ex) { + error = ex; + } + do_check_eq(error.result, Cr.NS_ERROR_CONNECTION_REFUSED); + do_check_eq(error.message, "NS_ERROR_CONNECTION_REFUSED"); + do_check_eq(typeof error.stack, "string"); + + _("Checking handling of errors in onProgress."); + let res18 = new Resource(server.baseURI + "/json"); + let onProgress = function(rec) { + // Provoke an XPC exception without a Javascript wrapper. + Services.io.newURI("::::::::", null, null); + }; + res18._onProgress = onProgress; + let oldWarn = res18._log.warn; + let warnings = []; + res18._log.warn = function(msg) { warnings.push(msg) }; + error = undefined; + try { + content = res18.get(); + } catch (ex) { + error = ex; + } + + // It throws and logs. + do_check_eq(error.result, Cr.NS_ERROR_MALFORMED_URI); + do_check_eq(error, "Error: NS_ERROR_MALFORMED_URI"); + // Note the strings haven't been formatted yet, but that's OK for this test. + do_check_eq(warnings.pop(), "${action} request to ${url} failed: ${ex}"); + do_check_eq(warnings.pop(), + "Got exception calling onProgress handler during fetch of " + + server.baseURI + "/json"); + + // And this is what happens if JS throws an exception. + res18 = new Resource(server.baseURI + "/json"); + onProgress = function(rec) { + throw "BOO!"; + }; + res18._onProgress = onProgress; + oldWarn = res18._log.warn; + warnings = []; + res18._log.warn = function(msg) { warnings.push(msg) }; + error = undefined; + try { + content = res18.get(); + } catch (ex) { + error = ex; + } + + // It throws and logs. + do_check_eq(error.result, Cr.NS_ERROR_XPC_JS_THREW_STRING); + do_check_eq(error, "Error: NS_ERROR_XPC_JS_THREW_STRING"); + do_check_eq(warnings.pop(), "${action} request to ${url} failed: ${ex}"); + do_check_eq(warnings.pop(), + "Got exception calling onProgress handler during fetch of " + + server.baseURI + "/json"); + + + _("Ensure channel timeouts are thrown appropriately."); + let res19 = new Resource(server.baseURI + "/json"); + res19.ABORT_TIMEOUT = 0; + error = undefined; + try { + content = res19.get(); + } catch (ex) { + error = ex; + } + do_check_eq(error.result, Cr.NS_ERROR_NET_TIMEOUT); + + _("Testing URI construction."); + let args = []; + args.push("newer=" + 1234); + args.push("limit=" + 1234); + args.push("sort=" + 1234); + + let query = "?" + args.join("&"); + + let uri1 = Utils.makeURI("http://foo/" + query) + .QueryInterface(Ci.nsIURL); + let uri2 = Utils.makeURI("http://foo/") + .QueryInterface(Ci.nsIURL); + uri2.query = query; + do_check_eq(uri1.query, uri2.query); + server.stop(do_test_finished); +} diff --git a/services/sync/tests/unit/test_resource_async.js b/services/sync/tests/unit/test_resource_async.js new file mode 100644 index 0000000000..0db91a1b59 --- /dev/null +++ b/services/sync/tests/unit/test_resource_async.js @@ -0,0 +1,730 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +Cu.import("resource://gre/modules/Log.jsm"); +Cu.import("resource://services-common/observers.js"); +Cu.import("resource://services-sync/identity.js"); +Cu.import("resource://services-sync/resource.js"); +Cu.import("resource://services-sync/util.js"); + +var logger; + +var fetched = false; +function server_open(metadata, response) { + let body; + if (metadata.method == "GET") { + fetched = true; + body = "This path exists"; + response.setStatusLine(metadata.httpVersion, 200, "OK"); + } else { + body = "Wrong request method"; + response.setStatusLine(metadata.httpVersion, 405, "Method Not Allowed"); + } + response.bodyOutputStream.write(body, body.length); +} + +function server_protected(metadata, response) { + let body; + + if (basic_auth_matches(metadata, "guest", "guest")) { + body = "This path exists and is protected"; + response.setStatusLine(metadata.httpVersion, 200, "OK, authorized"); + response.setHeader("WWW-Authenticate", 'Basic realm="secret"', false); + } else { + body = "This path exists and is protected - failed"; + response.setStatusLine(metadata.httpVersion, 401, "Unauthorized"); + response.setHeader("WWW-Authenticate", 'Basic realm="secret"', false); + } + + response.bodyOutputStream.write(body, body.length); +} + +function server_404(metadata, response) { + let body = "File not found"; + response.setStatusLine(metadata.httpVersion, 404, "Not Found"); + response.bodyOutputStream.write(body, body.length); +} + +var pacFetched = false; +function server_pac(metadata, response) { + _("Invoked PAC handler."); + pacFetched = true; + let body = 'function FindProxyForURL(url, host) { return "DIRECT"; }'; + response.setStatusLine(metadata.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "application/x-ns-proxy-autoconfig", false); + response.bodyOutputStream.write(body, body.length); +} + +var sample_data = { + some: "sample_data", + injson: "format", + number: 42 +}; + +function server_upload(metadata, response) { + let body; + + let input = readBytesFromInputStream(metadata.bodyInputStream); + if (input == JSON.stringify(sample_data)) { + body = "Valid data upload via " + metadata.method; + response.setStatusLine(metadata.httpVersion, 200, "OK"); + } else { + body = "Invalid data upload via " + metadata.method + ': ' + input; + response.setStatusLine(metadata.httpVersion, 500, "Internal Server Error"); + } + + response.bodyOutputStream.write(body, body.length); +} + +function server_delete(metadata, response) { + let body; + if (metadata.method == "DELETE") { + body = "This resource has been deleted"; + response.setStatusLine(metadata.httpVersion, 200, "OK"); + } else { + body = "Wrong request method"; + response.setStatusLine(metadata.httpVersion, 405, "Method Not Allowed"); + } + response.bodyOutputStream.write(body, body.length); +} + +function server_json(metadata, response) { + let body = JSON.stringify(sample_data); + response.setStatusLine(metadata.httpVersion, 200, "OK"); + response.bodyOutputStream.write(body, body.length); +} + +const TIMESTAMP = 1274380461; + +function server_timestamp(metadata, response) { + let body = "Thank you for your request"; + response.setHeader("X-Weave-Timestamp", ''+TIMESTAMP, false); + response.setStatusLine(metadata.httpVersion, 200, "OK"); + response.bodyOutputStream.write(body, body.length); +} + +function server_backoff(metadata, response) { + let body = "Hey, back off!"; + response.setHeader("X-Weave-Backoff", '600', false); + response.setStatusLine(metadata.httpVersion, 200, "OK"); + response.bodyOutputStream.write(body, body.length); +} + +function server_quota_notice(request, response) { + let body = "You're approaching quota."; + response.setHeader("X-Weave-Quota-Remaining", '1048576', false); + response.setStatusLine(request.httpVersion, 200, "OK"); + response.bodyOutputStream.write(body, body.length); +} + +function server_quota_error(request, response) { + let body = "14"; + response.setHeader("X-Weave-Quota-Remaining", '-1024', false); + response.setStatusLine(request.httpVersion, 400, "OK"); + response.bodyOutputStream.write(body, body.length); +} + +function server_headers(metadata, response) { + let ignore_headers = ["host", "user-agent", "accept", "accept-language", + "accept-encoding", "accept-charset", "keep-alive", + "connection", "pragma", "cache-control", + "content-length"]; + let headers = metadata.headers; + let header_names = []; + while (headers.hasMoreElements()) { + let header = headers.getNext().toString(); + if (ignore_headers.indexOf(header) == -1) { + header_names.push(header); + } + } + header_names = header_names.sort(); + + headers = {}; + for (let header of header_names) { + headers[header] = metadata.getHeader(header); + } + let body = JSON.stringify(headers); + response.setStatusLine(metadata.httpVersion, 200, "OK"); + response.bodyOutputStream.write(body, body.length); +} + +var quotaValue; +Observers.add("weave:service:quota:remaining", + function (subject) { quotaValue = subject; }); + +function run_test() { + logger = Log.repository.getLogger('Test'); + Log.repository.rootLogger.addAppender(new Log.DumpAppender()); + + Svc.Prefs.set("network.numRetries", 1); // speed up test + run_next_test(); +} + +// This apparently has to come first in order for our PAC URL to be hit. +// Don't put any other HTTP requests earlier in the file! +add_test(function test_proxy_auth_redirect() { + _("Ensure that a proxy auth redirect (which switches out our channel) " + + "doesn't break AsyncResource."); + let server = httpd_setup({ + "/open": server_open, + "/pac2": server_pac + }); + + PACSystemSettings.PACURI = server.baseURI + "/pac2"; + installFakePAC(); + let res = new AsyncResource(server.baseURI + "/open"); + res.get(function (error, result) { + do_check_true(!error); + do_check_true(pacFetched); + do_check_true(fetched); + do_check_eq("This path exists", result); + pacFetched = fetched = false; + uninstallFakePAC(); + server.stop(run_next_test); + }); +}); + +add_test(function test_new_channel() { + _("Ensure a redirect to a new channel is handled properly."); + + let resourceRequested = false; + function resourceHandler(metadata, response) { + resourceRequested = true; + + let body = "Test"; + response.setHeader("Content-Type", "text/plain"); + response.bodyOutputStream.write(body, body.length); + } + + let locationURL; + function redirectHandler(metadata, response) { + let body = "Redirecting"; + response.setStatusLine(metadata.httpVersion, 307, "TEMPORARY REDIRECT"); + response.setHeader("Location", locationURL); + response.bodyOutputStream.write(body, body.length); + } + + let server = httpd_setup({"/resource": resourceHandler, + "/redirect": redirectHandler}); + locationURL = server.baseURI + "/resource"; + + let request = new AsyncResource(server.baseURI + "/redirect"); + request.get(function onRequest(error, content) { + do_check_null(error); + do_check_true(resourceRequested); + do_check_eq(200, content.status); + do_check_true("content-type" in content.headers); + do_check_eq("text/plain", content.headers["content-type"]); + + server.stop(run_next_test); + }); +}); + + +var server; + +add_test(function setup() { + server = httpd_setup({ + "/open": server_open, + "/protected": server_protected, + "/404": server_404, + "/upload": server_upload, + "/delete": server_delete, + "/json": server_json, + "/timestamp": server_timestamp, + "/headers": server_headers, + "/backoff": server_backoff, + "/pac2": server_pac, + "/quota-notice": server_quota_notice, + "/quota-error": server_quota_error + }); + + run_next_test(); +}); + +add_test(function test_members() { + _("Resource object members"); + let uri = server.baseURI + "/open"; + let res = new AsyncResource(uri); + do_check_true(res.uri instanceof Ci.nsIURI); + do_check_eq(res.uri.spec, uri); + do_check_eq(res.spec, uri); + do_check_eq(typeof res.headers, "object"); + do_check_eq(typeof res.authenticator, "object"); + // Initially res.data is null since we haven't performed a GET or + // PUT/POST request yet. + do_check_eq(res.data, null); + + run_next_test(); +}); + +add_test(function test_get() { + _("GET a non-password-protected resource"); + let res = new AsyncResource(server.baseURI + "/open"); + res.get(function (error, content) { + do_check_eq(error, null); + do_check_eq(content, "This path exists"); + do_check_eq(content.status, 200); + do_check_true(content.success); + // res.data has been updated with the result from the request + do_check_eq(res.data, content); + + // Observe logging messages. + let logger = res._log; + let dbg = logger.debug; + let debugMessages = []; + logger.debug = function (msg) { + debugMessages.push(msg); + dbg.call(this, msg); + } + + // Since we didn't receive proper JSON data, accessing content.obj + // will result in a SyntaxError from JSON.parse + let didThrow = false; + try { + content.obj; + } catch (ex) { + didThrow = true; + } + do_check_true(didThrow); + do_check_eq(debugMessages.length, 1); + do_check_eq(debugMessages[0], + "Parse fail: Response body starts: \"\"This path exists\"\"."); + logger.debug = dbg; + + run_next_test(); + }); +}); + +add_test(function test_basicauth() { + _("Test that the BasicAuthenticator doesn't screw up header case."); + let res1 = new AsyncResource(server.baseURI + "/foo"); + res1.setHeader("Authorization", "Basic foobar"); + do_check_eq(res1._headers["authorization"], "Basic foobar"); + do_check_eq(res1.headers["authorization"], "Basic foobar"); + + run_next_test(); +}); + +add_test(function test_get_protected_fail() { + _("GET a password protected resource (test that it'll fail w/o pass, no throw)"); + let res2 = new AsyncResource(server.baseURI + "/protected"); + res2.get(function (error, content) { + do_check_eq(error, null); + do_check_eq(content, "This path exists and is protected - failed"); + do_check_eq(content.status, 401); + do_check_false(content.success); + run_next_test(); + }); +}); + +add_test(function test_get_protected_success() { + _("GET a password protected resource"); + let identity = new IdentityManager(); + let auth = identity.getBasicResourceAuthenticator("guest", "guest"); + let res3 = new AsyncResource(server.baseURI + "/protected"); + res3.authenticator = auth; + do_check_eq(res3.authenticator, auth); + res3.get(function (error, content) { + do_check_eq(error, null); + do_check_eq(content, "This path exists and is protected"); + do_check_eq(content.status, 200); + do_check_true(content.success); + run_next_test(); + }); +}); + +add_test(function test_get_404() { + _("GET a non-existent resource (test that it'll fail, but not throw)"); + let res4 = new AsyncResource(server.baseURI + "/404"); + res4.get(function (error, content) { + do_check_eq(error, null); + do_check_eq(content, "File not found"); + do_check_eq(content.status, 404); + do_check_false(content.success); + + // Check some headers of the 404 response + do_check_eq(content.headers.connection, "close"); + do_check_eq(content.headers.server, "httpd.js"); + do_check_eq(content.headers["content-length"], 14); + + run_next_test(); + }); +}); + +add_test(function test_put_string() { + _("PUT to a resource (string)"); + let res_upload = new AsyncResource(server.baseURI + "/upload"); + res_upload.put(JSON.stringify(sample_data), function(error, content) { + do_check_eq(error, null); + do_check_eq(content, "Valid data upload via PUT"); + do_check_eq(content.status, 200); + do_check_eq(res_upload.data, content); + run_next_test(); + }); +}); + +add_test(function test_put_object() { + _("PUT to a resource (object)"); + let res_upload = new AsyncResource(server.baseURI + "/upload"); + res_upload.put(sample_data, function (error, content) { + do_check_eq(error, null); + do_check_eq(content, "Valid data upload via PUT"); + do_check_eq(content.status, 200); + do_check_eq(res_upload.data, content); + run_next_test(); + }); +}); + +add_test(function test_put_data_string() { + _("PUT without data arg (uses resource.data) (string)"); + let res_upload = new AsyncResource(server.baseURI + "/upload"); + res_upload.data = JSON.stringify(sample_data); + res_upload.put(function (error, content) { + do_check_eq(error, null); + do_check_eq(content, "Valid data upload via PUT"); + do_check_eq(content.status, 200); + do_check_eq(res_upload.data, content); + run_next_test(); + }); +}); + +add_test(function test_put_data_object() { + _("PUT without data arg (uses resource.data) (object)"); + let res_upload = new AsyncResource(server.baseURI + "/upload"); + res_upload.data = sample_data; + res_upload.put(function (error, content) { + do_check_eq(error, null); + do_check_eq(content, "Valid data upload via PUT"); + do_check_eq(content.status, 200); + do_check_eq(res_upload.data, content); + run_next_test(); + }); +}); + +add_test(function test_post_string() { + _("POST to a resource (string)"); + let res_upload = new AsyncResource(server.baseURI + "/upload"); + res_upload.post(JSON.stringify(sample_data), function (error, content) { + do_check_eq(error, null); + do_check_eq(content, "Valid data upload via POST"); + do_check_eq(content.status, 200); + do_check_eq(res_upload.data, content); + run_next_test(); + }); +}); + +add_test(function test_post_object() { + _("POST to a resource (object)"); + let res_upload = new AsyncResource(server.baseURI + "/upload"); + res_upload.post(sample_data, function (error, content) { + do_check_eq(error, null); + do_check_eq(content, "Valid data upload via POST"); + do_check_eq(content.status, 200); + do_check_eq(res_upload.data, content); + run_next_test(); + }); +}); + +add_test(function test_post_data_string() { + _("POST without data arg (uses resource.data) (string)"); + let res_upload = new AsyncResource(server.baseURI + "/upload"); + res_upload.data = JSON.stringify(sample_data); + res_upload.post(function (error, content) { + do_check_eq(error, null); + do_check_eq(content, "Valid data upload via POST"); + do_check_eq(content.status, 200); + do_check_eq(res_upload.data, content); + run_next_test(); + }); +}); + +add_test(function test_post_data_object() { + _("POST without data arg (uses resource.data) (object)"); + let res_upload = new AsyncResource(server.baseURI + "/upload"); + res_upload.data = sample_data; + res_upload.post(function (error, content) { + do_check_eq(error, null); + do_check_eq(content, "Valid data upload via POST"); + do_check_eq(content.status, 200); + do_check_eq(res_upload.data, content); + run_next_test(); + }); +}); + +add_test(function test_delete() { + _("DELETE a resource"); + let res6 = new AsyncResource(server.baseURI + "/delete"); + res6.delete(function (error, content) { + do_check_eq(error, null); + do_check_eq(content, "This resource has been deleted"); + do_check_eq(content.status, 200); + run_next_test(); + }); +}); + +add_test(function test_json_body() { + _("JSON conversion of response body"); + let res7 = new AsyncResource(server.baseURI + "/json"); + res7.get(function (error, content) { + do_check_eq(error, null); + do_check_eq(content, JSON.stringify(sample_data)); + do_check_eq(content.status, 200); + do_check_eq(JSON.stringify(content.obj), JSON.stringify(sample_data)); + run_next_test(); + }); +}); + +add_test(function test_weave_timestamp() { + _("X-Weave-Timestamp header updates AsyncResource.serverTime"); + // Before having received any response containing the + // X-Weave-Timestamp header, AsyncResource.serverTime is null. + do_check_eq(AsyncResource.serverTime, null); + let res8 = new AsyncResource(server.baseURI + "/timestamp"); + res8.get(function (error, content) { + do_check_eq(error, null); + do_check_eq(AsyncResource.serverTime, TIMESTAMP); + run_next_test(); + }); +}); + +add_test(function test_get_no_headers() { + _("GET: no special request headers"); + let res_headers = new AsyncResource(server.baseURI + "/headers"); + res_headers.get(function (error, content) { + do_check_eq(error, null); + do_check_eq(content, '{}'); + run_next_test(); + }); +}); + +add_test(function test_put_default_content_type() { + _("PUT: Content-Type defaults to text/plain"); + let res_headers = new AsyncResource(server.baseURI + "/headers"); + res_headers.put('data', function (error, content) { + do_check_eq(error, null); + do_check_eq(content, JSON.stringify({"content-type": "text/plain"})); + run_next_test(); + }); +}); + +add_test(function test_post_default_content_type() { + _("POST: Content-Type defaults to text/plain"); + let res_headers = new AsyncResource(server.baseURI + "/headers"); + res_headers.post('data', function (error, content) { + do_check_eq(error, null); + do_check_eq(content, JSON.stringify({"content-type": "text/plain"})); + run_next_test(); + }); +}); + +add_test(function test_setHeader() { + _("setHeader(): setting simple header"); + let res_headers = new AsyncResource(server.baseURI + "/headers"); + res_headers.setHeader('X-What-Is-Weave', 'awesome'); + do_check_eq(res_headers.headers['x-what-is-weave'], 'awesome'); + res_headers.get(function (error, content) { + do_check_eq(error, null); + do_check_eq(content, JSON.stringify({"x-what-is-weave": "awesome"})); + run_next_test(); + }); +}); + +add_test(function test_setHeader_overwrite() { + _("setHeader(): setting multiple headers, overwriting existing header"); + let res_headers = new AsyncResource(server.baseURI + "/headers"); + res_headers.setHeader('X-WHAT-is-Weave', 'more awesomer'); + res_headers.setHeader('X-Another-Header', 'hello world'); + do_check_eq(res_headers.headers['x-what-is-weave'], 'more awesomer'); + do_check_eq(res_headers.headers['x-another-header'], 'hello world'); + res_headers.get(function (error, content) { + do_check_eq(error, null); + do_check_eq(content, JSON.stringify({"x-another-header": "hello world", + "x-what-is-weave": "more awesomer"})); + + run_next_test(); + }); +}); + +add_test(function test_headers_object() { + _("Setting headers object"); + let res_headers = new AsyncResource(server.baseURI + "/headers"); + res_headers.headers = {}; + res_headers.get(function (error, content) { + do_check_eq(error, null); + do_check_eq(content, "{}"); + run_next_test(); + }); +}); + +add_test(function test_put_override_content_type() { + _("PUT: override default Content-Type"); + let res_headers = new AsyncResource(server.baseURI + "/headers"); + res_headers.setHeader('Content-Type', 'application/foobar'); + do_check_eq(res_headers.headers['content-type'], 'application/foobar'); + res_headers.put('data', function (error, content) { + do_check_eq(error, null); + do_check_eq(content, JSON.stringify({"content-type": "application/foobar"})); + run_next_test(); + }); +}); + +add_test(function test_post_override_content_type() { + _("POST: override default Content-Type"); + let res_headers = new AsyncResource(server.baseURI + "/headers"); + res_headers.setHeader('Content-Type', 'application/foobar'); + res_headers.post('data', function (error, content) { + do_check_eq(error, null); + do_check_eq(content, JSON.stringify({"content-type": "application/foobar"})); + run_next_test(); + }); +}); + +add_test(function test_weave_backoff() { + _("X-Weave-Backoff header notifies observer"); + let backoffInterval; + function onBackoff(subject, data) { + backoffInterval = subject; + } + Observers.add("weave:service:backoff:interval", onBackoff); + + let res10 = new AsyncResource(server.baseURI + "/backoff"); + res10.get(function (error, content) { + do_check_eq(error, null); + do_check_eq(backoffInterval, 600); + run_next_test(); + }); +}); + +add_test(function test_quota_error() { + _("X-Weave-Quota-Remaining header notifies observer on successful requests."); + let res10 = new AsyncResource(server.baseURI + "/quota-error"); + res10.get(function (error, content) { + do_check_eq(error, null); + do_check_eq(content.status, 400); + do_check_eq(quotaValue, undefined); // HTTP 400, so no observer notification. + run_next_test(); + }); +}); + +add_test(function test_quota_notice() { + let res10 = new AsyncResource(server.baseURI + "/quota-notice"); + res10.get(function (error, content) { + do_check_eq(error, null); + do_check_eq(content.status, 200); + do_check_eq(quotaValue, 1048576); + run_next_test(); + }); +}); + +add_test(function test_preserve_exceptions() { + _("Error handling in ChannelListener etc. preserves exception information"); + let res11 = new AsyncResource("http://localhost:12345/does/not/exist"); + res11.get(function (error, content) { + do_check_neq(error, null); + do_check_eq(error.result, Cr.NS_ERROR_CONNECTION_REFUSED); + do_check_eq(error.message, "NS_ERROR_CONNECTION_REFUSED"); + run_next_test(); + }); +}); + +add_test(function test_xpc_exception_handling() { + _("Exception handling inside fetches."); + let res14 = new AsyncResource(server.baseURI + "/json"); + res14._onProgress = function(rec) { + // Provoke an XPC exception without a Javascript wrapper. + Services.io.newURI("::::::::", null, null); + }; + let warnings = []; + res14._log.warn = function(msg) { warnings.push(msg); }; + + res14.get(function (error, content) { + do_check_eq(error.result, Cr.NS_ERROR_MALFORMED_URI); + do_check_eq(error.message, "NS_ERROR_MALFORMED_URI"); + do_check_eq(content, null); + do_check_eq(warnings.pop(), + "Got exception calling onProgress handler during fetch of " + + server.baseURI + "/json"); + + run_next_test(); + }); +}); + +add_test(function test_js_exception_handling() { + _("JS exception handling inside fetches."); + let res15 = new AsyncResource(server.baseURI + "/json"); + res15._onProgress = function(rec) { + throw "BOO!"; + }; + let warnings = []; + res15._log.warn = function(msg) { warnings.push(msg); }; + + res15.get(function (error, content) { + do_check_eq(error.result, Cr.NS_ERROR_XPC_JS_THREW_STRING); + do_check_eq(error.message, "NS_ERROR_XPC_JS_THREW_STRING"); + do_check_eq(content, null); + do_check_eq(warnings.pop(), + "Got exception calling onProgress handler during fetch of " + + server.baseURI + "/json"); + + run_next_test(); + }); +}); + +add_test(function test_timeout() { + _("Ensure channel timeouts are thrown appropriately."); + let res19 = new AsyncResource(server.baseURI + "/json"); + res19.ABORT_TIMEOUT = 0; + res19.get(function (error, content) { + do_check_eq(error.result, Cr.NS_ERROR_NET_TIMEOUT); + run_next_test(); + }); +}); + +add_test(function test_uri_construction() { + _("Testing URI construction."); + let args = []; + args.push("newer=" + 1234); + args.push("limit=" + 1234); + args.push("sort=" + 1234); + + let query = "?" + args.join("&"); + + let uri1 = Utils.makeURI("http://foo/" + query) + .QueryInterface(Ci.nsIURL); + let uri2 = Utils.makeURI("http://foo/") + .QueryInterface(Ci.nsIURL); + uri2.query = query; + do_check_eq(uri1.query, uri2.query); + + run_next_test(); +}); + +add_test(function test_not_sending_cookie() { + function handler(metadata, response) { + let body = "COOKIE!"; + response.setStatusLine(metadata.httpVersion, 200, "OK"); + response.bodyOutputStream.write(body, body.length); + do_check_false(metadata.hasHeader("Cookie")); + } + let cookieSer = Cc["@mozilla.org/cookieService;1"] + .getService(Ci.nsICookieService); + let uri = CommonUtils.makeURI(server.baseURI); + cookieSer.setCookieString(uri, null, "test=test; path=/;", null); + + let res = new AsyncResource(server.baseURI + "/test"); + res.get(function (error) { + do_check_null(error); + do_check_true(this.response.success); + do_check_eq("COOKIE!", this.response.body); + server.stop(run_next_test); + }); +}); + +/** + * End of tests that rely on a single HTTP server. + * All tests after this point must begin and end their own. + */ +add_test(function eliminate_server() { + server.stop(run_next_test); +}); diff --git a/services/sync/tests/unit/test_resource_header.js b/services/sync/tests/unit/test_resource_header.js new file mode 100644 index 0000000000..4f28e01da2 --- /dev/null +++ b/services/sync/tests/unit/test_resource_header.js @@ -0,0 +1,65 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +Cu.import("resource://testing-common/httpd.js"); +Cu.import("resource://services-sync/resource.js"); + +function run_test() { + initTestLogging("Trace"); + run_next_test(); +} + +var httpServer = new HttpServer(); +httpServer.registerPathHandler("/content", contentHandler); +httpServer.start(-1); + +const HTTP_PORT = httpServer.identity.primaryPort; +const TEST_URL = "http://localhost:" + HTTP_PORT + "/content"; +const BODY = "response body"; + +// Keep headers for later inspection. +var auth = null; +var foo = null; +function contentHandler(metadata, response) { + _("Handling request."); + auth = metadata.getHeader("Authorization"); + foo = metadata.getHeader("X-Foo"); + + _("Extracted headers. " + auth + ", " + foo); + + response.setHeader("Content-Type", "text/plain"); + response.bodyOutputStream.write(BODY, BODY.length); +} + +// Set a proxy function to cause an internal redirect. +function triggerRedirect() { + const PROXY_FUNCTION = "function FindProxyForURL(url, host) {" + + " return 'PROXY a_non_existent_domain_x7x6c572v:80; " + + "PROXY localhost:" + HTTP_PORT + "';" + + "}"; + + let prefsService = Cc["@mozilla.org/preferences-service;1"].getService(Ci.nsIPrefService); + let prefs = prefsService.getBranch("network.proxy."); + prefs.setIntPref("type", 2); + prefs.setCharPref("autoconfig_url", "data:text/plain," + PROXY_FUNCTION); +} + +add_test(function test_headers_copied() { + triggerRedirect(); + + _("Issuing request."); + let resource = new Resource(TEST_URL); + resource.setHeader("Authorization", "Basic foobar"); + resource.setHeader("X-Foo", "foofoo"); + + let result = resource.get(TEST_URL); + _("Result: " + result); + + do_check_eq(result, BODY); + do_check_eq(auth, "Basic foobar"); + do_check_eq(foo, "foofoo"); + + httpServer.stop(run_next_test); +}); diff --git a/services/sync/tests/unit/test_resource_ua.js b/services/sync/tests/unit/test_resource_ua.js new file mode 100644 index 0000000000..31c2cd3791 --- /dev/null +++ b/services/sync/tests/unit/test_resource_ua.js @@ -0,0 +1,100 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +Cu.import("resource://services-sync/constants.js"); +Cu.import("resource://services-sync/resource.js"); +Cu.import("resource://services-sync/service.js"); +Cu.import("resource://services-sync/util.js"); +Cu.import("resource://testing-common/services/sync/utils.js"); + +var httpProtocolHandler = Cc["@mozilla.org/network/protocol;1?name=http"] + .getService(Ci.nsIHttpProtocolHandler); + +// Tracking info/collections. +var collectionsHelper = track_collections_helper(); +var collections = collectionsHelper.collections; + +var meta_global; +var server; + +var expectedUA; +var ua; +function uaHandler(f) { + return function(request, response) { + ua = request.getHeader("User-Agent"); + return f(request, response); + }; +} + +function run_test() { + Log.repository.rootLogger.addAppender(new Log.DumpAppender()); + meta_global = new ServerWBO('global'); + server = httpd_setup({ + "/1.1/johndoe/info/collections": uaHandler(collectionsHelper.handler), + "/1.1/johndoe/storage/meta/global": uaHandler(meta_global.handler()), + }); + + ensureLegacyIdentityManager(); + setBasicCredentials("johndoe", "ilovejane"); + Service.serverURL = server.baseURI + "/"; + Service.clusterURL = server.baseURI + "/"; + _("Server URL: " + server.baseURI); + + // Note this string is missing the trailing ".destkop" as the test + // adjusts the "client.type" pref where that portion comes from. + expectedUA = Services.appinfo.name + "/" + Services.appinfo.version + + " (" + httpProtocolHandler.oscpu + ")" + + " FxSync/" + WEAVE_VERSION + "." + + Services.appinfo.appBuildID; + + run_next_test(); +} + +add_test(function test_fetchInfo() { + _("Testing _fetchInfo."); + Service._fetchInfo(); + _("User-Agent: " + ua); + do_check_eq(ua, expectedUA + ".desktop"); + ua = ""; + run_next_test(); +}); + +add_test(function test_desktop_post() { + _("Testing direct Resource POST."); + let r = new AsyncResource(server.baseURI + "/1.1/johndoe/storage/meta/global"); + r.post("foo=bar", function (error, content) { + _("User-Agent: " + ua); + do_check_eq(ua, expectedUA + ".desktop"); + ua = ""; + run_next_test(); + }); +}); + +add_test(function test_desktop_get() { + _("Testing async."); + Svc.Prefs.set("client.type", "desktop"); + let r = new AsyncResource(server.baseURI + "/1.1/johndoe/storage/meta/global"); + r.get(function(error, content) { + _("User-Agent: " + ua); + do_check_eq(ua, expectedUA + ".desktop"); + ua = ""; + run_next_test(); + }); +}); + +add_test(function test_mobile_get() { + _("Testing mobile."); + Svc.Prefs.set("client.type", "mobile"); + let r = new AsyncResource(server.baseURI + "/1.1/johndoe/storage/meta/global"); + r.get(function (error, content) { + _("User-Agent: " + ua); + do_check_eq(ua, expectedUA + ".mobile"); + ua = ""; + run_next_test(); + }); +}); + +add_test(function tear_down() { + server.stop(run_next_test); +}); + diff --git a/services/sync/tests/unit/test_score_triggers.js b/services/sync/tests/unit/test_score_triggers.js new file mode 100644 index 0000000000..513be685a8 --- /dev/null +++ b/services/sync/tests/unit/test_score_triggers.js @@ -0,0 +1,149 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +Cu.import("resource://services-sync/engines.js"); +Cu.import("resource://services-sync/engines/clients.js"); +Cu.import("resource://services-sync/constants.js"); +Cu.import("resource://services-sync/service.js"); +Cu.import("resource://services-sync/status.js"); +Cu.import("resource://services-sync/util.js"); +Cu.import("resource://testing-common/services/sync/rotaryengine.js"); +Cu.import("resource://testing-common/services/sync/utils.js"); + +Service.engineManager.clear(); +Service.engineManager.register(RotaryEngine); +var engine = Service.engineManager.get("rotary"); +var tracker = engine._tracker; +engine.enabled = true; + +// Tracking info/collections. +var collectionsHelper = track_collections_helper(); +var upd = collectionsHelper.with_updated_collection; + +function sync_httpd_setup() { + let handlers = {}; + + handlers["/1.1/johndoe/storage/meta/global"] = + new ServerWBO("global", {}).handler(); + handlers["/1.1/johndoe/storage/steam"] = + new ServerWBO("steam", {}).handler(); + + handlers["/1.1/johndoe/info/collections"] = collectionsHelper.handler; + delete collectionsHelper.collections.crypto; + delete collectionsHelper.collections.meta; + + let cr = new ServerWBO("keys"); + handlers["/1.1/johndoe/storage/crypto/keys"] = + upd("crypto", cr.handler()); + + let cl = new ServerCollection(); + handlers["/1.1/johndoe/storage/clients"] = + upd("clients", cl.handler()); + + return httpd_setup(handlers); +} + +function setUp(server) { + new SyncTestingInfrastructure(server, "johndoe", "ilovejane", "sekrit"); +} + +function run_test() { + initTestLogging("Trace"); + + Log.repository.getLogger("Sync.Service").level = Log.Level.Trace; + + run_next_test(); +} + +add_test(function test_tracker_score_updated() { + let scoreUpdated = 0; + + function onScoreUpdated() { + scoreUpdated++; + } + + Svc.Obs.add("weave:engine:score:updated", onScoreUpdated()); + + try { + do_check_eq(engine.score, 0); + + tracker.score += SCORE_INCREMENT_SMALL; + do_check_eq(engine.score, SCORE_INCREMENT_SMALL); + + do_check_eq(scoreUpdated, 1); + } finally { + Svc.Obs.remove("weave:engine:score:updated", onScoreUpdated); + tracker.resetScore(); + run_next_test(); + } +}); + +add_test(function test_sync_triggered() { + let server = sync_httpd_setup(); + setUp(server); + + Service.login(); + + Service.scheduler.syncThreshold = MULTI_DEVICE_THRESHOLD; + Svc.Obs.add("weave:service:sync:finish", function onSyncFinish() { + Svc.Obs.remove("weave:service:sync:finish", onSyncFinish); + _("Sync completed!"); + server.stop(run_next_test); + }); + + do_check_eq(Status.login, LOGIN_SUCCEEDED); + tracker.score += SCORE_INCREMENT_XLARGE; +}); + +add_test(function test_clients_engine_sync_triggered() { + _("Ensure that client engine score changes trigger a sync."); + + // The clients engine is not registered like other engines. Therefore, + // it needs special treatment throughout the code. Here, we verify the + // global score tracker gives it that treatment. See bug 676042 for more. + + let server = sync_httpd_setup(); + setUp(server); + Service.login(); + + const TOPIC = "weave:service:sync:finish"; + Svc.Obs.add(TOPIC, function onSyncFinish() { + Svc.Obs.remove(TOPIC, onSyncFinish); + _("Sync due to clients engine change completed."); + server.stop(run_next_test); + }); + + Service.scheduler.syncThreshold = MULTI_DEVICE_THRESHOLD; + do_check_eq(Status.login, LOGIN_SUCCEEDED); + Service.clientsEngine._tracker.score += SCORE_INCREMENT_XLARGE; +}); + +add_test(function test_incorrect_credentials_sync_not_triggered() { + _("Ensure that score changes don't trigger a sync if Status.login != LOGIN_SUCCEEDED."); + let server = sync_httpd_setup(); + setUp(server); + + // Ensure we don't actually try to sync. + function onSyncStart() { + do_throw("Should not get here!"); + } + Svc.Obs.add("weave:service:sync:start", onSyncStart); + + // First wait >100ms (nsITimers can take up to that much time to fire, so + // we can account for the timer in delayedAutoconnect) and then one event + // loop tick (to account for a possible call to weave:service:sync:start). + Utils.namedTimer(function() { + Utils.nextTick(function() { + Svc.Obs.remove("weave:service:sync:start", onSyncStart); + + do_check_eq(Status.login, LOGIN_FAILED_LOGIN_REJECTED); + + Service.startOver(); + server.stop(run_next_test); + }); + }, 150, {}, "timer"); + + // Faking incorrect credentials to prevent score update. + Status.login = LOGIN_FAILED_LOGIN_REJECTED; + tracker.score += SCORE_INCREMENT_XLARGE; +}); diff --git a/services/sync/tests/unit/test_sendcredentials_controller.js b/services/sync/tests/unit/test_sendcredentials_controller.js new file mode 100644 index 0000000000..42e5ec8e80 --- /dev/null +++ b/services/sync/tests/unit/test_sendcredentials_controller.js @@ -0,0 +1,102 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +Cu.import("resource://services-sync/constants.js"); +Cu.import("resource://services-sync/jpakeclient.js"); +Cu.import("resource://services-sync/service.js"); +Cu.import("resource://services-sync/util.js"); +Cu.import("resource://testing-common/services/sync/utils.js"); + +function run_test() { + ensureLegacyIdentityManager(); + setBasicCredentials("johndoe", "ilovejane", Utils.generatePassphrase()); + Service.serverURL = "http://weave.server/"; + + initTestLogging("Trace"); + Log.repository.getLogger("Sync.SendCredentialsController").level = Log.Level.Trace; + Log.repository.getLogger("Sync.SyncScheduler").level = Log.Level.Trace; + run_next_test(); +} + +function make_sendCredentials_test(topic) { + return function test_sendCredentials() { + _("Test sending credentials on " + topic + " observer notification."); + + let sendAndCompleteCalled = false; + let jpakeclient = { + sendAndComplete: function sendAndComplete(data) { + // Verify that the controller unregisters itself as an observer + // when the exchange is complete by faking another notification. + do_check_false(sendAndCompleteCalled); + sendAndCompleteCalled = true; + + // Verify it sends the correct data. + do_check_eq(data.account, Service.identity.account); + do_check_eq(data.password, Service.identity.basicPassword); + do_check_eq(data.synckey, Service.identity.syncKey); + do_check_eq(data.serverURL, Service.serverURL); + + this.controller.onComplete(); + // Verify it schedules a sync for the expected interval. + let expectedInterval = Service.scheduler.activeInterval; + do_check_true(Service.scheduler.nextSync - Date.now() <= expectedInterval); + + // Signal the end of another sync. We shouldn't be registered anymore, + // so we shouldn't re-enter this method (cf sendAndCompleteCalled above) + Svc.Obs.notify(topic); + + Service.scheduler.setDefaults(); + Utils.nextTick(run_next_test); + } + }; + jpakeclient.controller = new SendCredentialsController(jpakeclient, Service); + Svc.Obs.notify(topic); + }; +} + +add_test(make_sendCredentials_test("weave:service:sync:finish")); +add_test(make_sendCredentials_test("weave:service:sync:error")); + + +add_test(function test_abort() { + _("Test aborting the J-PAKE exchange."); + + let jpakeclient = { + sendAndComplete: function sendAndComplete() { + do_throw("Shouldn't get here!"); + } + }; + jpakeclient.controller = new SendCredentialsController(jpakeclient, Service); + + // Verify that the controller unregisters itself when the exchange + // was aborted. + jpakeclient.controller.onAbort(JPAKE_ERROR_USERABORT); + Svc.Obs.notify("weave:service:sync:finish"); + Utils.nextTick(run_next_test); +}); + + +add_test(function test_startOver() { + _("Test wiping local Sync config aborts transaction."); + + let abortCalled = false; + let jpakeclient = { + abort: function abort() { + abortCalled = true; + this.controller.onAbort(JPAKE_ERROR_USERABORT); + }, + sendAndComplete: function sendAndComplete() { + do_throw("Shouldn't get here!"); + } + }; + jpakeclient.controller = new SendCredentialsController(jpakeclient, Service); + + Svc.Obs.notify("weave:service:start-over"); + do_check_true(abortCalled); + + // Ensure that the controller no longer does anything if a sync + // finishes now or -- more likely -- errors out. + Svc.Obs.notify("weave:service:sync:error"); + + Utils.nextTick(run_next_test); +}); diff --git a/services/sync/tests/unit/test_service_attributes.js b/services/sync/tests/unit/test_service_attributes.js new file mode 100644 index 0000000000..931c7741a3 --- /dev/null +++ b/services/sync/tests/unit/test_service_attributes.js @@ -0,0 +1,118 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +Cu.import("resource://services-sync/constants.js"); +Cu.import("resource://services-sync/service.js"); +Cu.import("resource://services-sync/util.js"); +Cu.import("resource://testing-common/services/sync/fakeservices.js"); +Cu.import("resource://testing-common/services/sync/utils.js"); + +function test_urls() { + _("URL related Service properties correspond to preference settings."); + try { + ensureLegacyIdentityManager(); + do_check_true(!!Service.serverURL); // actual value may change + do_check_eq(Service.clusterURL, ""); + do_check_eq(Service.userBaseURL, undefined); + do_check_eq(Service.infoURL, undefined); + do_check_eq(Service.storageURL, undefined); + do_check_eq(Service.metaURL, undefined); + + _("The 'clusterURL' attribute updates preferences and cached URLs."); + Service.identity.username = "johndoe"; + + // Since we don't have a cluster URL yet, these will still not be defined. + do_check_eq(Service.infoURL, undefined); + do_check_eq(Service.userBaseURL, undefined); + do_check_eq(Service.storageURL, undefined); + do_check_eq(Service.metaURL, undefined); + + Service.serverURL = "http://weave.server/"; + Service.clusterURL = "http://weave.cluster/"; + + do_check_eq(Service.userBaseURL, "http://weave.cluster/1.1/johndoe/"); + do_check_eq(Service.infoURL, + "http://weave.cluster/1.1/johndoe/info/collections"); + do_check_eq(Service.storageURL, + "http://weave.cluster/1.1/johndoe/storage/"); + do_check_eq(Service.metaURL, + "http://weave.cluster/1.1/johndoe/storage/meta/global"); + + _("The 'miscURL' and 'userURL' attributes can be relative to 'serverURL' or absolute."); + Svc.Prefs.set("miscURL", "relative/misc/"); + Svc.Prefs.set("userURL", "relative/user/"); + do_check_eq(Service.miscAPI, + "http://weave.server/relative/misc/1.0/"); + do_check_eq(Service.userAPIURI, + "http://weave.server/relative/user/1.0/"); + + Svc.Prefs.set("miscURL", "http://weave.misc.services/"); + Svc.Prefs.set("userURL", "http://weave.user.services/"); + do_check_eq(Service.miscAPI, "http://weave.misc.services/1.0/"); + do_check_eq(Service.userAPIURI, "http://weave.user.services/1.0/"); + + do_check_eq(Service.pwResetURL, + "http://weave.server/weave-password-reset"); + + _("Empty/false value for 'username' resets preference."); + Service.identity.username = ""; + do_check_eq(Svc.Prefs.get("username"), undefined); + do_check_eq(Service.identity.username, null); + + _("The 'serverURL' attributes updates/resets preferences."); + // Identical value doesn't do anything + Service.serverURL = Service.serverURL; + do_check_eq(Service.clusterURL, "http://weave.cluster/"); + + Service.serverURL = "http://different.auth.node/"; + do_check_eq(Svc.Prefs.get("serverURL"), "http://different.auth.node/"); + do_check_eq(Service.clusterURL, ""); + + } finally { + Svc.Prefs.resetBranch(""); + } +} + + +function test_syncID() { + _("Service.syncID is auto-generated, corresponds to preference."); + new FakeGUIDService(); + + try { + // Ensure pristine environment + do_check_eq(Svc.Prefs.get("client.syncID"), undefined); + + // Performing the first get on the attribute will generate a new GUID. + do_check_eq(Service.syncID, "fake-guid-00"); + do_check_eq(Svc.Prefs.get("client.syncID"), "fake-guid-00"); + + Svc.Prefs.set("client.syncID", Utils.makeGUID()); + do_check_eq(Svc.Prefs.get("client.syncID"), "fake-guid-01"); + do_check_eq(Service.syncID, "fake-guid-01"); + } finally { + Svc.Prefs.resetBranch(""); + new FakeGUIDService(); + } +} + +function test_locked() { + _("The 'locked' attribute can be toggled with lock() and unlock()"); + + // Defaults to false + do_check_eq(Service.locked, false); + + do_check_eq(Service.lock(), true); + do_check_eq(Service.locked, true); + + // Locking again will return false + do_check_eq(Service.lock(), false); + + Service.unlock(); + do_check_eq(Service.locked, false); +} + +function run_test() { + test_urls(); + test_syncID(); + test_locked(); +} diff --git a/services/sync/tests/unit/test_service_changePassword.js b/services/sync/tests/unit/test_service_changePassword.js new file mode 100644 index 0000000000..12b0ad00e4 --- /dev/null +++ b/services/sync/tests/unit/test_service_changePassword.js @@ -0,0 +1,80 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +Cu.import("resource://gre/modules/Log.jsm"); +Cu.import("resource://services-sync/constants.js"); +Cu.import("resource://services-sync/service.js"); +Cu.import("resource://services-sync/util.js"); +Cu.import("resource://testing-common/services/sync/utils.js"); + +function run_test() { + initTestLogging("Trace"); + Log.repository.getLogger("Sync.AsyncResource").level = Log.Level.Trace; + Log.repository.getLogger("Sync.Resource").level = Log.Level.Trace; + Log.repository.getLogger("Sync.Service").level = Log.Level.Trace; + + ensureLegacyIdentityManager(); + + run_next_test(); +} + +add_test(function test_change_password() { + let requestBody; + let server; + + function send(statusCode, status, body) { + return function(request, response) { + requestBody = readBytesFromInputStream(request.bodyInputStream); + response.setStatusLine(request.httpVersion, statusCode, status); + response.bodyOutputStream.write(body, body.length); + }; + } + + try { + Service.baseURI = "http://localhost:9999/"; + Service.serverURL = "http://localhost:9999/"; + setBasicCredentials("johndoe", "ilovejane"); + + _("changePassword() returns false for a network error, the password won't change."); + let res = Service.changePassword("ILoveJane83"); + do_check_false(res); + do_check_eq(Service.identity.basicPassword, "ilovejane"); + + _("Let's fire up the server and actually change the password."); + server = httpd_setup({ + "/user/1.0/johndoe/password": send(200, "OK", ""), + "/user/1.0/janedoe/password": send(401, "Unauthorized", "Forbidden!") + }); + + Service.serverURL = server.baseURI; + res = Service.changePassword("ILoveJane83"); + do_check_true(res); + do_check_eq(Service.identity.basicPassword, "ILoveJane83"); + do_check_eq(requestBody, "ILoveJane83"); + + _("Make sure the password has been persisted in the login manager."); + let logins = Services.logins.findLogins({}, PWDMGR_HOST, null, + PWDMGR_PASSWORD_REALM); + do_check_eq(logins.length, 1); + do_check_eq(logins[0].password, "ILoveJane83"); + + _("A non-ASCII password is UTF-8 encoded."); + const moneyPassword = "moneyislike$£¥"; + res = Service.changePassword(moneyPassword); + do_check_true(res); + do_check_eq(Service.identity.basicPassword, Utils.encodeUTF8(moneyPassword)); + do_check_eq(requestBody, Utils.encodeUTF8(moneyPassword)); + + _("changePassword() returns false for a server error, the password won't change."); + Services.logins.removeAllLogins(); + setBasicCredentials("janedoe", "ilovejohn"); + res = Service.changePassword("ILoveJohn86"); + do_check_false(res); + do_check_eq(Service.identity.basicPassword, "ilovejohn"); + + } finally { + Svc.Prefs.resetBranch(""); + Services.logins.removeAllLogins(); + server.stop(run_next_test); + } +}); diff --git a/services/sync/tests/unit/test_service_checkAccount.js b/services/sync/tests/unit/test_service_checkAccount.js new file mode 100644 index 0000000000..618348d1af --- /dev/null +++ b/services/sync/tests/unit/test_service_checkAccount.js @@ -0,0 +1,41 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +Cu.import("resource://services-sync/service.js"); +Cu.import("resource://services-sync/util.js"); +Cu.import("resource://testing-common/services/sync/utils.js"); + +function run_test() { + do_test_pending(); + ensureLegacyIdentityManager(); + let server = httpd_setup({ + "/user/1.0/johndoe": httpd_handler(200, "OK", "1"), + "/user/1.0/janedoe": httpd_handler(200, "OK", "0"), + // john@doe.com + "/user/1.0/7wohs32cngzuqt466q3ge7indszva4of": httpd_handler(200, "OK", "0"), + // jane@doe.com + "/user/1.0/vuuf3eqgloxpxmzph27f5a6ve7gzlrms": httpd_handler(200, "OK", "1") + }); + try { + Service.serverURL = server.baseURI; + + _("A 404 will be recorded as 'generic-server-error'"); + do_check_eq(Service.checkAccount("jimdoe"), "generic-server-error"); + + _("Account that's available."); + do_check_eq(Service.checkAccount("john@doe.com"), "available"); + + _("Account that's not available."); + do_check_eq(Service.checkAccount("jane@doe.com"), "notAvailable"); + + _("Username fallback: Account that's not available."); + do_check_eq(Service.checkAccount("johndoe"), "notAvailable"); + + _("Username fallback: Account that's available."); + do_check_eq(Service.checkAccount("janedoe"), "available"); + + } finally { + Svc.Prefs.resetBranch(""); + server.stop(do_test_finished); + } +} diff --git a/services/sync/tests/unit/test_service_cluster.js b/services/sync/tests/unit/test_service_cluster.js new file mode 100644 index 0000000000..65f0c3a956 --- /dev/null +++ b/services/sync/tests/unit/test_service_cluster.js @@ -0,0 +1,110 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +Cu.import("resource://services-sync/service.js"); +Cu.import("resource://services-sync/util.js"); +Cu.import("resource://testing-common/services/sync/utils.js"); + +function do_check_throws(func) { + var raised = false; + try { + func(); + } catch (ex) { + raised = true; + } + do_check_true(raised); +} + +add_test(function test_findCluster() { + _("Test Service._findCluster()"); + let server; + ensureLegacyIdentityManager(); + try { + _("_findCluster() throws on network errors (e.g. connection refused)."); + do_check_throws(function() { + Service.serverURL = "http://dummy:9000/"; + Service.identity.account = "johndoe"; + Service._clusterManager._findCluster(); + }); + + server = httpd_setup({ + "/user/1.0/johndoe/node/weave": httpd_handler(200, "OK", "http://weave.user.node/"), + "/user/1.0/jimdoe/node/weave": httpd_handler(200, "OK", "null"), + "/user/1.0/janedoe/node/weave": httpd_handler(404, "Not Found", "Not Found"), + "/user/1.0/juliadoe/node/weave": httpd_handler(400, "Bad Request", "Bad Request"), + "/user/1.0/joedoe/node/weave": httpd_handler(500, "Server Error", "Server Error") + }); + + Service.serverURL = server.baseURI; + Service.identity.account = "johndoe"; + + _("_findCluster() returns the user's cluster node"); + let cluster = Service._clusterManager._findCluster(); + do_check_eq(cluster, "http://weave.user.node/"); + + _("A 'null' response is converted to null."); + Service.identity.account = "jimdoe"; + cluster = Service._clusterManager._findCluster(); + do_check_eq(cluster, null); + + _("If a 404 is encountered, the server URL is taken as the cluster URL"); + Service.identity.account = "janedoe"; + cluster = Service._clusterManager._findCluster(); + do_check_eq(cluster, Service.serverURL); + + _("A 400 response will throw an error."); + Service.identity.account = "juliadoe"; + do_check_throws(function() { + Service._clusterManager._findCluster(); + }); + + _("Any other server response (e.g. 500) will throw an error."); + Service.identity.account = "joedoe"; + do_check_throws(function() { + Service._clusterManager._findCluster(); + }); + + } finally { + Svc.Prefs.resetBranch(""); + if (server) { + server.stop(run_next_test); + } + } +}); + +add_test(function test_setCluster() { + _("Test Service._setCluster()"); + let server = httpd_setup({ + "/user/1.0/johndoe/node/weave": httpd_handler(200, "OK", "http://weave.user.node/"), + "/user/1.0/jimdoe/node/weave": httpd_handler(200, "OK", "null") + }); + try { + Service.serverURL = server.baseURI; + Service.identity.account = "johndoe"; + + _("Check initial state."); + do_check_eq(Service.clusterURL, ""); + + _("Set the cluster URL."); + do_check_true(Service._clusterManager.setCluster()); + do_check_eq(Service.clusterURL, "http://weave.user.node/"); + + _("Setting it again won't make a difference if it's the same one."); + do_check_false(Service._clusterManager.setCluster()); + do_check_eq(Service.clusterURL, "http://weave.user.node/"); + + _("A 'null' response won't make a difference either."); + Service.identity.account = "jimdoe"; + do_check_false(Service._clusterManager.setCluster()); + do_check_eq(Service.clusterURL, "http://weave.user.node/"); + + } finally { + Svc.Prefs.resetBranch(""); + server.stop(run_next_test); + } +}); + +function run_test() { + initTestLogging(); + run_next_test(); +} diff --git a/services/sync/tests/unit/test_service_createAccount.js b/services/sync/tests/unit/test_service_createAccount.js new file mode 100644 index 0000000000..93c6f78e3b --- /dev/null +++ b/services/sync/tests/unit/test_service_createAccount.js @@ -0,0 +1,75 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +Cu.import("resource://services-sync/util.js"); +Cu.import("resource://services-sync/service.js"); +Cu.import("resource://testing-common/services/sync/utils.js"); + +function run_test() { + initTestLogging("Trace"); + + let requestBody; + let secretHeader; + function send(statusCode, status, body) { + return function(request, response) { + requestBody = readBytesFromInputStream(request.bodyInputStream); + if (request.hasHeader("X-Weave-Secret")) { + secretHeader = request.getHeader("X-Weave-Secret"); + } + + response.setStatusLine(request.httpVersion, statusCode, status); + response.bodyOutputStream.write(body, body.length); + }; + } + + do_test_pending(); + let server = httpd_setup({ + // john@doe.com + "/user/1.0/7wohs32cngzuqt466q3ge7indszva4of": send(200, "OK", "0"), + // jane@doe.com + "/user/1.0/vuuf3eqgloxpxmzph27f5a6ve7gzlrms": send(400, "Bad Request", "2"), + // jim@doe.com + "/user/1.0/vz6fhecgw5t3sgx3a4cektoiokyczkqd": send(500, "Server Error", "Server Error") + }); + try { + Service.serverURL = server.baseURI; + + _("Create an account."); + let res = Service.createAccount("john@doe.com", "mysecretpw", + "challenge", "response"); + do_check_eq(res, null); + let payload = JSON.parse(requestBody); + do_check_eq(payload.password, "mysecretpw"); + do_check_eq(payload.email, "john@doe.com"); + do_check_eq(payload["captcha-challenge"], "challenge"); + do_check_eq(payload["captcha-response"], "response"); + + _("A non-ASCII password is UTF-8 encoded."); + const moneyPassword = "moneyislike$£¥"; + res = Service.createAccount("john@doe.com", moneyPassword, + "challenge", "response"); + do_check_eq(res, null); + payload = JSON.parse(requestBody); + do_check_eq(payload.password, Utils.encodeUTF8(moneyPassword)); + + _("Invalid captcha or other user-friendly error."); + res = Service.createAccount("jane@doe.com", "anothersecretpw", + "challenge", "response"); + do_check_eq(res, "invalid-captcha"); + + _("Generic server error."); + res = Service.createAccount("jim@doe.com", "preciousss", + "challenge", "response"); + do_check_eq(res, "generic-server-error"); + + _("Admin secret preference is passed as HTTP header token."); + Svc.Prefs.set("admin-secret", "my-server-secret"); + res = Service.createAccount("john@doe.com", "mysecretpw", + "challenge", "response"); + do_check_eq(secretHeader, "my-server-secret"); + + } finally { + Svc.Prefs.resetBranch(""); + server.stop(do_test_finished); + } +} diff --git a/services/sync/tests/unit/test_service_detect_upgrade.js b/services/sync/tests/unit/test_service_detect_upgrade.js new file mode 100644 index 0000000000..0f46832d98 --- /dev/null +++ b/services/sync/tests/unit/test_service_detect_upgrade.js @@ -0,0 +1,297 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +Cu.import("resource://gre/modules/Log.jsm"); +Cu.import("resource://services-sync/constants.js"); +Cu.import("resource://services-sync/keys.js"); +Cu.import("resource://services-sync/engines/tabs.js"); +Cu.import("resource://services-sync/engines.js"); +Cu.import("resource://services-sync/record.js"); +Cu.import("resource://services-sync/service.js"); +Cu.import("resource://services-sync/util.js"); +Cu.import("resource://testing-common/services/sync/utils.js"); + +Service.engineManager.register(TabEngine); + +add_test(function v4_upgrade() { + let passphrase = "abcdeabcdeabcdeabcdeabcdea"; + + let clients = new ServerCollection(); + let meta_global = new ServerWBO('global'); + + // Tracking info/collections. + let collectionsHelper = track_collections_helper(); + let upd = collectionsHelper.with_updated_collection; + let collections = collectionsHelper.collections; + + let keysWBO = new ServerWBO("keys"); + let server = httpd_setup({ + // Special. + "/1.1/johndoe/info/collections": collectionsHelper.handler, + "/1.1/johndoe/storage/crypto/keys": upd("crypto", keysWBO.handler()), + "/1.1/johndoe/storage/meta/global": upd("meta", meta_global.handler()), + + // Track modified times. + "/1.1/johndoe/storage/clients": upd("clients", clients.handler()), + "/1.1/johndoe/storage/tabs": upd("tabs", new ServerCollection().handler()), + + // Just so we don't get 404s in the logs. + "/1.1/johndoe/storage/bookmarks": new ServerCollection().handler(), + "/1.1/johndoe/storage/forms": new ServerCollection().handler(), + "/1.1/johndoe/storage/history": new ServerCollection().handler(), + "/1.1/johndoe/storage/passwords": new ServerCollection().handler(), + "/1.1/johndoe/storage/prefs": new ServerCollection().handler() + }); + + ensureLegacyIdentityManager(); + + try { + + _("Set up some tabs."); + let myTabs = + {windows: [{tabs: [{index: 1, + entries: [{ + url: "http://foo.com/", + title: "Title" + }], + attributes: { + image: "image" + } + }]}]}; + delete Svc.Session; + Svc.Session = { + getBrowserState: () => JSON.stringify(myTabs) + }; + + Service.status.resetSync(); + + _("Logging in."); + Service.serverURL = server.baseURI; + + Service.login("johndoe", "ilovejane", passphrase); + do_check_true(Service.isLoggedIn); + Service.verifyAndFetchSymmetricKeys(); + do_check_true(Service._remoteSetup()); + + function test_out_of_date() { + _("Old meta/global: " + JSON.stringify(meta_global)); + meta_global.payload = JSON.stringify({"syncID": "foooooooooooooooooooooooooo", + "storageVersion": STORAGE_VERSION + 1}); + collections.meta = Date.now() / 1000; + _("New meta/global: " + JSON.stringify(meta_global)); + Service.recordManager.set(Service.metaURL, meta_global); + try { + Service.sync(); + } + catch (ex) { + } + do_check_eq(Service.status.sync, VERSION_OUT_OF_DATE); + } + + // See what happens when we bump the storage version. + _("Syncing after server has been upgraded."); + test_out_of_date(); + + // Same should happen after a wipe. + _("Syncing after server has been upgraded and wiped."); + Service.wipeServer(); + test_out_of_date(); + + // Now's a great time to test what happens when keys get replaced. + _("Syncing afresh..."); + Service.logout(); + Service.collectionKeys.clear(); + Service.serverURL = server.baseURI; + meta_global.payload = JSON.stringify({"syncID": "foooooooooooooobbbbbbbbbbbb", + "storageVersion": STORAGE_VERSION}); + collections.meta = Date.now() / 1000; + Service.recordManager.set(Service.metaURL, meta_global); + Service.login("johndoe", "ilovejane", passphrase); + do_check_true(Service.isLoggedIn); + Service.sync(); + do_check_true(Service.isLoggedIn); + + let serverDecrypted; + let serverKeys; + let serverResp; + + + function retrieve_server_default() { + serverKeys = serverResp = serverDecrypted = null; + + serverKeys = new CryptoWrapper("crypto", "keys"); + serverResp = serverKeys.fetch(Service.resource(Service.cryptoKeysURL)).response; + do_check_true(serverResp.success); + + serverDecrypted = serverKeys.decrypt(Service.identity.syncKeyBundle); + _("Retrieved WBO: " + JSON.stringify(serverDecrypted)); + _("serverKeys: " + JSON.stringify(serverKeys)); + + return serverDecrypted.default; + } + + function retrieve_and_compare_default(should_succeed) { + let serverDefault = retrieve_server_default(); + let localDefault = Service.collectionKeys.keyForCollection().keyPairB64; + + _("Retrieved keyBundle: " + JSON.stringify(serverDefault)); + _("Local keyBundle: " + JSON.stringify(localDefault)); + + if (should_succeed) + do_check_eq(JSON.stringify(serverDefault), JSON.stringify(localDefault)); + else + do_check_neq(JSON.stringify(serverDefault), JSON.stringify(localDefault)); + } + + // Uses the objects set above. + function set_server_keys(pair) { + serverDecrypted.default = pair; + serverKeys.cleartext = serverDecrypted; + serverKeys.encrypt(Service.identity.syncKeyBundle); + serverKeys.upload(Service.resource(Service.cryptoKeysURL)); + } + + _("Checking we have the latest keys."); + retrieve_and_compare_default(true); + + _("Update keys on server."); + set_server_keys(["KaaaaaaaaaaaHAtfmuRY0XEJ7LXfFuqvF7opFdBD/MY=", + "aaaaaaaaaaaapxMO6TEWtLIOv9dj6kBAJdzhWDkkkis="]); + + _("Checking that we no longer have the latest keys."); + retrieve_and_compare_default(false); + + _("Indeed, they're what we set them to..."); + do_check_eq("KaaaaaaaaaaaHAtfmuRY0XEJ7LXfFuqvF7opFdBD/MY=", + retrieve_server_default()[0]); + + _("Sync. Should download changed keys automatically."); + let oldClientsModified = collections.clients; + let oldTabsModified = collections.tabs; + + Service.login("johndoe", "ilovejane", passphrase); + Service.sync(); + _("New key should have forced upload of data."); + _("Tabs: " + oldTabsModified + " < " + collections.tabs); + _("Clients: " + oldClientsModified + " < " + collections.clients); + do_check_true(collections.clients > oldClientsModified); + do_check_true(collections.tabs > oldTabsModified); + + _("... and keys will now match."); + retrieve_and_compare_default(true); + + // Clean up. + Service.startOver(); + + } finally { + Svc.Prefs.resetBranch(""); + server.stop(run_next_test); + } +}); + +add_test(function v5_upgrade() { + let passphrase = "abcdeabcdeabcdeabcdeabcdea"; + + // Tracking info/collections. + let collectionsHelper = track_collections_helper(); + let upd = collectionsHelper.with_updated_collection; + let collections = collectionsHelper.collections; + + let keysWBO = new ServerWBO("keys"); + let bulkWBO = new ServerWBO("bulk"); + let clients = new ServerCollection(); + let meta_global = new ServerWBO('global'); + + let server = httpd_setup({ + // Special. + "/1.1/johndoe/storage/meta/global": upd("meta", meta_global.handler()), + "/1.1/johndoe/info/collections": collectionsHelper.handler, + "/1.1/johndoe/storage/crypto/keys": upd("crypto", keysWBO.handler()), + "/1.1/johndoe/storage/crypto/bulk": upd("crypto", bulkWBO.handler()), + + // Track modified times. + "/1.1/johndoe/storage/clients": upd("clients", clients.handler()), + "/1.1/johndoe/storage/tabs": upd("tabs", new ServerCollection().handler()), + }); + + try { + + _("Set up some tabs."); + let myTabs = + {windows: [{tabs: [{index: 1, + entries: [{ + url: "http://foo.com/", + title: "Title" + }], + attributes: { + image: "image" + } + }]}]}; + delete Svc.Session; + Svc.Session = { + getBrowserState: () => JSON.stringify(myTabs) + }; + + Service.status.resetSync(); + + setBasicCredentials("johndoe", "ilovejane", passphrase); + Service.serverURL = server.baseURI + "/"; + Service.clusterURL = server.baseURI + "/"; + + // Test an upgrade where the contents of the server would cause us to error + // -- keys decrypted with a different sync key, for example. + _("Testing v4 -> v5 (or similar) upgrade."); + function update_server_keys(syncKeyBundle, wboName, collWBO) { + generateNewKeys(Service.collectionKeys); + serverKeys = Service.collectionKeys.asWBO("crypto", wboName); + serverKeys.encrypt(syncKeyBundle); + let res = Service.resource(Service.storageURL + collWBO); + do_check_true(serverKeys.upload(res).success); + } + + _("Bumping version."); + // Bump version on the server. + let m = new WBORecord("meta", "global"); + m.payload = {"syncID": "foooooooooooooooooooooooooo", + "storageVersion": STORAGE_VERSION + 1}; + m.upload(Service.resource(Service.metaURL)); + + _("New meta/global: " + JSON.stringify(meta_global)); + + // Fill the keys with bad data. + let badKeys = new SyncKeyBundle("foobar", "aaaaaaaaaaaaaaaaaaaaaaaaaa"); + update_server_keys(badKeys, "keys", "crypto/keys"); // v4 + update_server_keys(badKeys, "bulk", "crypto/bulk"); // v5 + + _("Generating new keys."); + generateNewKeys(Service.collectionKeys); + + // Now sync and see what happens. It should be a version fail, not a crypto + // fail. + + _("Logging in."); + try { + Service.login("johndoe", "ilovejane", passphrase); + } + catch (e) { + _("Exception: " + e); + } + _("Status: " + Service.status); + do_check_false(Service.isLoggedIn); + do_check_eq(VERSION_OUT_OF_DATE, Service.status.sync); + + // Clean up. + Service.startOver(); + + } finally { + Svc.Prefs.resetBranch(""); + server.stop(run_next_test); + } +}); + +function run_test() { + let logger = Log.repository.rootLogger; + Log.repository.rootLogger.addAppender(new Log.DumpAppender()); + + run_next_test(); +} diff --git a/services/sync/tests/unit/test_service_getStorageInfo.js b/services/sync/tests/unit/test_service_getStorageInfo.js new file mode 100644 index 0000000000..841dceb78b --- /dev/null +++ b/services/sync/tests/unit/test_service_getStorageInfo.js @@ -0,0 +1,94 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +Cu.import("resource://services-common/rest.js"); +Cu.import("resource://services-sync/constants.js"); +Cu.import("resource://services-sync/service.js"); +Cu.import("resource://services-sync/util.js"); +Cu.import("resource://testing-common/services/sync/utils.js"); + +var httpProtocolHandler = Cc["@mozilla.org/network/protocol;1?name=http"] + .getService(Ci.nsIHttpProtocolHandler); + +var collections = {steam: 65.11328, + petrol: 82.488281, + diesel: 2.25488281}; + +function run_test() { + Log.repository.getLogger("Sync.Service").level = Log.Level.Trace; + Log.repository.getLogger("Sync.StorageRequest").level = Log.Level.Trace; + initTestLogging(); + + ensureLegacyIdentityManager(); + setBasicCredentials("johndoe", "ilovejane"); + + run_next_test(); +} + +add_test(function test_success() { + let handler = httpd_handler(200, "OK", JSON.stringify(collections)); + let server = httpd_setup({"/1.1/johndoe/info/collections": handler}); + Service.serverURL = server.baseURI + "/"; + Service.clusterURL = server.baseURI + "/"; + + let request = Service.getStorageInfo("collections", function (error, info) { + do_check_eq(error, null); + do_check_true(Utils.deepEquals(info, collections)); + + // Ensure that the request is sent off with the right bits. + do_check_true(basic_auth_matches(handler.request, + Service.identity.username, + Service.identity.basicPassword)); + let expectedUA = Services.appinfo.name + "/" + Services.appinfo.version + + " (" + httpProtocolHandler.oscpu + ")" + + " FxSync/" + WEAVE_VERSION + "." + + Services.appinfo.appBuildID + ".desktop"; + do_check_eq(handler.request.getHeader("User-Agent"), expectedUA); + + server.stop(run_next_test); + }); + do_check_true(request instanceof RESTRequest); +}); + +add_test(function test_invalid_type() { + do_check_throws(function () { + Service.getStorageInfo("invalid", function (error, info) { + do_throw("Shouldn't get here!"); + }); + }); + run_next_test(); +}); + +add_test(function test_network_error() { + Service.getStorageInfo(INFO_COLLECTIONS, function (error, info) { + do_check_eq(error.result, Cr.NS_ERROR_CONNECTION_REFUSED); + do_check_eq(info, null); + run_next_test(); + }); +}); + +add_test(function test_http_error() { + let handler = httpd_handler(500, "Oh noez", "Something went wrong!"); + let server = httpd_setup({"/1.1/johndoe/info/collections": handler}); + Service.serverURL = server.baseURI + "/"; + Service.clusterURL = server.baseURI + "/"; + + let request = Service.getStorageInfo(INFO_COLLECTIONS, function (error, info) { + do_check_eq(error.status, 500); + do_check_eq(info, null); + server.stop(run_next_test); + }); +}); + +add_test(function test_invalid_json() { + let handler = httpd_handler(200, "OK", "Invalid JSON"); + let server = httpd_setup({"/1.1/johndoe/info/collections": handler}); + Service.serverURL = server.baseURI + "/"; + Service.clusterURL = server.baseURI + "/"; + + let request = Service.getStorageInfo(INFO_COLLECTIONS, function (error, info) { + do_check_eq(error.name, "SyntaxError"); + do_check_eq(info, null); + server.stop(run_next_test); + }); +}); diff --git a/services/sync/tests/unit/test_service_login.js b/services/sync/tests/unit/test_service_login.js new file mode 100644 index 0000000000..42c163915f --- /dev/null +++ b/services/sync/tests/unit/test_service_login.js @@ -0,0 +1,245 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +Cu.import("resource://gre/modules/Log.jsm"); +Cu.import("resource://services-sync/constants.js"); +Cu.import("resource://services-sync/service.js"); +Cu.import("resource://services-sync/policies.js"); +Cu.import("resource://services-sync/util.js"); +Cu.import("resource://testing-common/services/sync/utils.js"); + +function login_handling(handler) { + return function (request, response) { + if (basic_auth_matches(request, "johndoe", "ilovejane") || + basic_auth_matches(request, "janedoe", "ilovejohn")) { + handler(request, response); + } else { + let body = "Unauthorized"; + response.setStatusLine(request.httpVersion, 401, "Unauthorized"); + response.setHeader("Content-Type", "text/plain"); + response.bodyOutputStream.write(body, body.length); + } + }; +} + +function run_test() { + let logger = Log.repository.rootLogger; + Log.repository.rootLogger.addAppender(new Log.DumpAppender()); + + run_next_test(); +} + +add_test(function test_offline() { + try { + _("The right bits are set when we're offline."); + Services.io.offline = true; + do_check_false(!!Service.login()); + do_check_eq(Service.status.login, LOGIN_FAILED_NETWORK_ERROR); + Services.io.offline = false; + } finally { + Svc.Prefs.resetBranch(""); + run_next_test(); + } +}); + +function setup() { + let janeHelper = track_collections_helper(); + let janeU = janeHelper.with_updated_collection; + let janeColls = janeHelper.collections; + let johnHelper = track_collections_helper(); + let johnU = johnHelper.with_updated_collection; + let johnColls = johnHelper.collections; + + let server = httpd_setup({ + "/1.1/johndoe/info/collections": login_handling(johnHelper.handler), + "/1.1/janedoe/info/collections": login_handling(janeHelper.handler), + + // We need these handlers because we test login, and login + // is where keys are generated or fetched. + // TODO: have Jane fetch her keys, not generate them... + "/1.1/johndoe/storage/crypto/keys": johnU("crypto", new ServerWBO("keys").handler()), + "/1.1/johndoe/storage/meta/global": johnU("meta", new ServerWBO("global").handler()), + "/1.1/janedoe/storage/crypto/keys": janeU("crypto", new ServerWBO("keys").handler()), + "/1.1/janedoe/storage/meta/global": janeU("meta", new ServerWBO("global").handler()) + }); + + Service.serverURL = server.baseURI; + return server; +} + +add_test(function test_login_logout() { + let server = setup(); + + try { + _("Force the initial state."); + ensureLegacyIdentityManager(); + Service.status.service = STATUS_OK; + do_check_eq(Service.status.service, STATUS_OK); + + _("Try logging in. It won't work because we're not configured yet."); + Service.login(); + do_check_eq(Service.status.service, CLIENT_NOT_CONFIGURED); + do_check_eq(Service.status.login, LOGIN_FAILED_NO_USERNAME); + do_check_false(Service.isLoggedIn); + + _("Try again with username and password set."); + Service.identity.account = "johndoe"; + Service.identity.basicPassword = "ilovejane"; + Service.login(); + do_check_eq(Service.status.service, CLIENT_NOT_CONFIGURED); + do_check_eq(Service.status.login, LOGIN_FAILED_NO_PASSPHRASE); + do_check_false(Service.isLoggedIn); + + _("Success if passphrase is set."); + Service.identity.syncKey = "foo"; + Service.login(); + do_check_eq(Service.status.service, STATUS_OK); + do_check_eq(Service.status.login, LOGIN_SUCCEEDED); + do_check_true(Service.isLoggedIn); + + _("We can also pass username, password and passphrase to login()."); + Service.login("janedoe", "incorrectpassword", "bar"); + setBasicCredentials("janedoe", "incorrectpassword", "bar"); + do_check_eq(Service.status.service, LOGIN_FAILED); + do_check_eq(Service.status.login, LOGIN_FAILED_LOGIN_REJECTED); + do_check_false(Service.isLoggedIn); + + _("Try again with correct password."); + Service.login("janedoe", "ilovejohn"); + do_check_eq(Service.status.service, STATUS_OK); + do_check_eq(Service.status.login, LOGIN_SUCCEEDED); + do_check_true(Service.isLoggedIn); + + _("Calling login() with parameters when the client is unconfigured sends notification."); + let notified = false; + Svc.Obs.add("weave:service:setup-complete", function() { + notified = true; + }); + setBasicCredentials(null, null, null); + Service.login("janedoe", "ilovejohn", "bar"); + do_check_true(notified); + do_check_eq(Service.status.service, STATUS_OK); + do_check_eq(Service.status.login, LOGIN_SUCCEEDED); + do_check_true(Service.isLoggedIn); + + _("Logout."); + Service.logout(); + do_check_false(Service.isLoggedIn); + + _("Logging out again won't do any harm."); + Service.logout(); + do_check_false(Service.isLoggedIn); + + } finally { + Svc.Prefs.resetBranch(""); + server.stop(run_next_test); + } +}); + +add_test(function test_login_on_sync() { + let server = setup(); + setBasicCredentials("johndoe", "ilovejane", "bar"); + + try { + _("Sync calls login."); + let oldLogin = Service.login; + let loginCalled = false; + Service.login = function() { + loginCalled = true; + Service.status.login = LOGIN_SUCCEEDED; + this._loggedIn = false; // So that sync aborts. + return true; + }; + + Service.sync(); + + do_check_true(loginCalled); + Service.login = oldLogin; + + // Stub mpLocked. + let mpLockedF = Utils.mpLocked; + let mpLocked = true; + Utils.mpLocked = () => mpLocked; + + // Stub scheduleNextSync. This gets called within checkSyncStatus if we're + // ready to sync, so use it as an indicator. + let scheduleNextSyncF = Service.scheduler.scheduleNextSync; + let scheduleCalled = false; + Service.scheduler.scheduleNextSync = function(wait) { + scheduleCalled = true; + scheduleNextSyncF.call(this, wait); + }; + + // Autoconnect still tries to connect in the background (useful behavior: + // for non-MP users and unlocked MPs, this will detect version expiry + // earlier). + // + // Consequently, non-MP users will be logged in as in the pre-Bug 543784 world, + // and checkSyncStatus reflects that by waiting for login. + // + // This process doesn't apply if your MP is still locked, so we make + // checkSyncStatus accept a locked MP in place of being logged in. + // + // This test exercises these two branches. + + _("We're ready to sync if locked."); + Service.enabled = true; + Services.io.offline = false; + Service.scheduler.checkSyncStatus(); + do_check_true(scheduleCalled); + + _("... and also if we're not locked."); + scheduleCalled = false; + mpLocked = false; + Service.scheduler.checkSyncStatus(); + do_check_true(scheduleCalled); + Service.scheduler.scheduleNextSync = scheduleNextSyncF; + + // TODO: need better tests around master password prompting. See Bug 620583. + + mpLocked = true; + + // Testing exception handling if master password dialog is canceled. + // Do this by monkeypatching. + let oldGetter = Service.identity.__lookupGetter__("syncKey"); + let oldSetter = Service.identity.__lookupSetter__("syncKey"); + _("Old passphrase function is " + oldGetter); + Service.identity.__defineGetter__("syncKey", + function() { + throw "User canceled Master Password entry"; + }); + + let oldClearSyncTriggers = Service.scheduler.clearSyncTriggers; + let oldLockedSync = Service._lockedSync; + + let cSTCalled = false; + let lockedSyncCalled = false; + + Service.scheduler.clearSyncTriggers = function() { cSTCalled = true; }; + Service._lockedSync = function() { lockedSyncCalled = true; }; + + _("If master password is canceled, login fails and we report lockage."); + do_check_false(!!Service.login()); + do_check_eq(Service.status.login, MASTER_PASSWORD_LOCKED); + do_check_eq(Service.status.service, LOGIN_FAILED); + _("Locked? " + Utils.mpLocked()); + _("checkSync reports the correct term."); + do_check_eq(Service._checkSync(), kSyncMasterPasswordLocked); + + _("Sync doesn't proceed and clears triggers if MP is still locked."); + Service.sync(); + + do_check_true(cSTCalled); + do_check_false(lockedSyncCalled); + + Service.identity.__defineGetter__("syncKey", oldGetter); + Service.identity.__defineSetter__("syncKey", oldSetter); + + // N.B., a bunch of methods are stubbed at this point. Be careful putting + // new tests after this point! + + } finally { + Svc.Prefs.resetBranch(""); + server.stop(run_next_test); + } +}); diff --git a/services/sync/tests/unit/test_service_migratePrefs.js b/services/sync/tests/unit/test_service_migratePrefs.js new file mode 100644 index 0000000000..89a147c06a --- /dev/null +++ b/services/sync/tests/unit/test_service_migratePrefs.js @@ -0,0 +1,70 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +Cu.import("resource://gre/modules/Preferences.jsm"); +Cu.import("resource://services-sync/util.js"); + +function test_migrate_logging() { + _("Testing log pref migration."); + Svc.Prefs.set("log.appender.debugLog", "Warn"); + Svc.Prefs.set("log.appender.debugLog.enabled", true); + do_check_true(Svc.Prefs.get("log.appender.debugLog.enabled")); + do_check_eq(Svc.Prefs.get("log.appender.file.level"), "Trace"); + do_check_eq(Svc.Prefs.get("log.appender.file.logOnSuccess"), false); + + Service._migratePrefs(); + + do_check_eq("Warn", Svc.Prefs.get("log.appender.file.level")); + do_check_true(Svc.Prefs.get("log.appender.file.logOnSuccess")); + do_check_eq(Svc.Prefs.get("log.appender.debugLog"), undefined); + do_check_eq(Svc.Prefs.get("log.appender.debugLog.enabled"), undefined); +}; + +function run_test() { + _("Set some prefs on the old branch"); + let globalPref = new Preferences(""); + globalPref.set("extensions.weave.hello", "world"); + globalPref.set("extensions.weave.number", 42); + globalPref.set("extensions.weave.yes", true); + globalPref.set("extensions.weave.no", false); + + _("Make sure the old prefs are there"); + do_check_eq(globalPref.get("extensions.weave.hello"), "world"); + do_check_eq(globalPref.get("extensions.weave.number"), 42); + do_check_eq(globalPref.get("extensions.weave.yes"), true); + do_check_eq(globalPref.get("extensions.weave.no"), false); + + _("New prefs shouldn't exist yet"); + do_check_eq(globalPref.get("services.sync.hello"), null); + do_check_eq(globalPref.get("services.sync.number"), null); + do_check_eq(globalPref.get("services.sync.yes"), null); + do_check_eq(globalPref.get("services.sync.no"), null); + + _("Loading service should migrate"); + Cu.import("resource://services-sync/service.js"); + do_check_eq(globalPref.get("services.sync.hello"), "world"); + do_check_eq(globalPref.get("services.sync.number"), 42); + do_check_eq(globalPref.get("services.sync.yes"), true); + do_check_eq(globalPref.get("services.sync.no"), false); + do_check_eq(globalPref.get("extensions.weave.hello"), null); + do_check_eq(globalPref.get("extensions.weave.number"), null); + do_check_eq(globalPref.get("extensions.weave.yes"), null); + do_check_eq(globalPref.get("extensions.weave.no"), null); + + _("Migrating should set a pref to make sure to not re-migrate"); + do_check_true(globalPref.get("services.sync.migrated")); + + _("Make sure re-migrate doesn't happen"); + globalPref.set("extensions.weave.tooLate", "already migrated!"); + do_check_eq(globalPref.get("extensions.weave.tooLate"), "already migrated!"); + do_check_eq(globalPref.get("services.sync.tooLate"), null); + Service._migratePrefs(); + do_check_eq(globalPref.get("extensions.weave.tooLate"), "already migrated!"); + do_check_eq(globalPref.get("services.sync.tooLate"), null); + + _("Clearing out pref changes for other tests"); + globalPref.resetBranch("extensions.weave."); + globalPref.resetBranch("services.sync."); + + test_migrate_logging(); +} diff --git a/services/sync/tests/unit/test_service_passwordUTF8.js b/services/sync/tests/unit/test_service_passwordUTF8.js new file mode 100644 index 0000000000..e781050b36 --- /dev/null +++ b/services/sync/tests/unit/test_service_passwordUTF8.js @@ -0,0 +1,95 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +Cu.import("resource://services-sync/resource.js"); +Cu.import("resource://services-sync/util.js"); +Cu.import("resource://services-sync/service.js"); +Cu.import("resource://testing-common/services/sync/utils.js"); + +const JAPANESE = "\u34ff\u35ff\u36ff\u37ff"; +const APPLES = "\uf8ff\uf8ff\uf8ff\uf8ff"; +const LOWBYTES = "\xff\xff\xff\xff"; + +// Poor man's /etc/passwd. Static since there's no btoa()/atob() in xpcshell. +var basicauth = {}; +basicauth[LOWBYTES] = "Basic am9obmRvZTr/////"; +basicauth[Utils.encodeUTF8(JAPANESE)] = "Basic am9obmRvZTrjk7/jl7/jm7/jn78="; + +// Global var for the server password, read by info_collections(), +// modified by change_password(). +var server_password; + +function login_handling(handler) { + return function (request, response) { + let basic = basicauth[server_password]; + + if (basic && (request.getHeader("Authorization") == basic)) { + handler(request, response); + } else { + let body = "Unauthorized"; + response.setStatusLine(request.httpVersion, 401, "Unauthorized"); + response.setHeader("WWW-Authenticate", 'Basic realm="secret"', false); + response.bodyOutputStream.write(body, body.length); + } + }; +} + +function change_password(request, response) { + let body, statusCode, status; + let basic = basicauth[server_password]; + + if (basic && (request.getHeader("Authorization") == basic)) { + server_password = readBytesFromInputStream(request.bodyInputStream); + body = ""; + statusCode = 200; + status = "OK"; + } else { + statusCode = 401; + body = status = "Unauthorized"; + } + response.setStatusLine(request.httpVersion, statusCode, status); + response.setHeader("WWW-Authenticate", 'Basic realm="secret"', false); + response.bodyOutputStream.write(body, body.length); +} + +function run_test() { + initTestLogging("Trace"); + let collectionsHelper = track_collections_helper(); + let upd = collectionsHelper.with_updated_collection; + let collections = collectionsHelper.collections; + + ensureLegacyIdentityManager(); + + do_test_pending(); + let server = httpd_setup({ + "/1.1/johndoe/info/collections": login_handling(collectionsHelper.handler), + "/1.1/johndoe/storage/meta/global": upd("meta", new ServerWBO("global").handler()), + "/1.1/johndoe/storage/crypto/keys": upd("crypto", new ServerWBO("keys").handler()), + "/user/1.0/johndoe/password": change_password + }); + + setBasicCredentials("johndoe", JAPANESE, "irrelevant"); + Service.serverURL = server.baseURI; + + try { + _("Try to log in with the password."); + server_password = "foobar"; + do_check_false(Service.verifyLogin()); + do_check_eq(server_password, "foobar"); + + _("Make the server password the low byte version of our password."); + server_password = LOWBYTES; + do_check_false(Service.verifyLogin()); + do_check_eq(server_password, LOWBYTES); + + _("Can't use a password that has the same low bytes as ours."); + server_password = Utils.encodeUTF8(JAPANESE); + Service.identity.basicPassword = APPLES; + do_check_false(Service.verifyLogin()); + do_check_eq(server_password, Utils.encodeUTF8(JAPANESE)); + + } finally { + server.stop(do_test_finished); + Svc.Prefs.resetBranch(""); + } +} diff --git a/services/sync/tests/unit/test_service_persistLogin.js b/services/sync/tests/unit/test_service_persistLogin.js new file mode 100644 index 0000000000..9d4a1e51a7 --- /dev/null +++ b/services/sync/tests/unit/test_service_persistLogin.js @@ -0,0 +1,46 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +Cu.import("resource://services-sync/constants.js"); +Cu.import("resource://services-sync/service.js"); +Cu.import("resource://services-sync/util.js"); +Cu.import("resource://testing-common/services/sync/utils.js"); + +function run_test() { + try { + // Ensure we have a blank slate to start. + ensureLegacyIdentityManager(); + Services.logins.removeAllLogins(); + + setBasicCredentials("johndoe", "ilovejane", "abbbbbcccccdddddeeeeefffff"); + + _("Confirm initial environment is empty."); + let logins = Services.logins.findLogins({}, PWDMGR_HOST, null, + PWDMGR_PASSWORD_REALM); + do_check_eq(logins.length, 0); + logins = Services.logins.findLogins({}, PWDMGR_HOST, null, + PWDMGR_PASSPHRASE_REALM); + do_check_eq(logins.length, 0); + + _("Persist logins to the login service"); + Service.persistLogin(); + + _("The password has been persisted in the login service."); + logins = Services.logins.findLogins({}, PWDMGR_HOST, null, + PWDMGR_PASSWORD_REALM); + do_check_eq(logins.length, 1); + do_check_eq(logins[0].username, "johndoe"); + do_check_eq(logins[0].password, "ilovejane"); + + _("The passphrase has been persisted in the login service."); + logins = Services.logins.findLogins({}, PWDMGR_HOST, null, + PWDMGR_PASSPHRASE_REALM); + do_check_eq(logins.length, 1); + do_check_eq(logins[0].username, "johndoe"); + do_check_eq(logins[0].password, "abbbbbcccccdddddeeeeefffff"); + + } finally { + Svc.Prefs.resetBranch(""); + Services.logins.removeAllLogins(); + } +} diff --git a/services/sync/tests/unit/test_service_set_serverURL.js b/services/sync/tests/unit/test_service_set_serverURL.js new file mode 100644 index 0000000000..6fef2bfaa0 --- /dev/null +++ b/services/sync/tests/unit/test_service_set_serverURL.js @@ -0,0 +1,13 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +Cu.import("resource://services-sync/service.js"); + +function run_test() { + Service.serverURL = "http://example.com/sync"; + do_check_eq(Service.serverURL, "http://example.com/sync/"); + + Service.serverURL = "http://example.com/sync/"; + do_check_eq(Service.serverURL, "http://example.com/sync/"); +} + diff --git a/services/sync/tests/unit/test_service_startOver.js b/services/sync/tests/unit/test_service_startOver.js new file mode 100644 index 0000000000..8994205480 --- /dev/null +++ b/services/sync/tests/unit/test_service_startOver.js @@ -0,0 +1,101 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +Cu.import("resource://services-sync/constants.js"); +Cu.import("resource://services-sync/engines.js"); +Cu.import("resource://services-sync/service.js"); +Cu.import("resource://services-sync/util.js"); +Cu.import("resource://testing-common/services/sync/utils.js"); + +function BlaEngine() { + SyncEngine.call(this, "Bla", Service); +} +BlaEngine.prototype = { + __proto__: SyncEngine.prototype, + + removed: false, + removeClientData: function() { + this.removed = true; + } + +}; + +Service.engineManager.register(BlaEngine); + + +function run_test() { + initTestLogging("Trace"); + run_next_test(); +} + +add_identity_test(this, function* test_resetLocalData() { + yield configureIdentity(); + Service.status.enforceBackoff = true; + Service.status.backoffInterval = 42; + Service.status.minimumNextSync = 23; + Service.persistLogin(); + + // Verify set up. + do_check_eq(Service.status.checkSetup(), STATUS_OK); + + // Verify state that the observer sees. + let observerCalled = false; + Svc.Obs.add("weave:service:start-over", function onStartOver() { + Svc.Obs.remove("weave:service:start-over", onStartOver); + observerCalled = true; + + do_check_eq(Service.status.service, CLIENT_NOT_CONFIGURED); + }); + + Service.startOver(); + do_check_true(observerCalled); + + // Verify the site was nuked from orbit. + do_check_eq(Svc.Prefs.get("username"), undefined); + do_check_eq(Service.identity.basicPassword, null); + do_check_eq(Service.identity.syncKey, null); + + do_check_eq(Service.status.service, CLIENT_NOT_CONFIGURED); + do_check_false(Service.status.enforceBackoff); + do_check_eq(Service.status.backoffInterval, 0); + do_check_eq(Service.status.minimumNextSync, 0); +}); + +add_test(function test_removeClientData() { + let engine = Service.engineManager.get("bla"); + + // No cluster URL = no removal. + do_check_false(engine.removed); + Service.startOver(); + do_check_false(engine.removed); + + Service.serverURL = "https://localhost/"; + Service.clusterURL = Service.serverURL; + + do_check_false(engine.removed); + Service.startOver(); + do_check_true(engine.removed); + + run_next_test(); +}); + +add_test(function test_reset_SyncScheduler() { + // Some non-default values for SyncScheduler's attributes. + Service.scheduler.idle = true; + Service.scheduler.hasIncomingItems = true; + Service.scheduler.numClients = 42; + Service.scheduler.nextSync = Date.now(); + Service.scheduler.syncThreshold = MULTI_DEVICE_THRESHOLD; + Service.scheduler.syncInterval = Service.scheduler.activeInterval; + + Service.startOver(); + + do_check_false(Service.scheduler.idle); + do_check_false(Service.scheduler.hasIncomingItems); + do_check_eq(Service.scheduler.numClients, 0); + do_check_eq(Service.scheduler.nextSync, 0); + do_check_eq(Service.scheduler.syncThreshold, SINGLE_USER_THRESHOLD); + do_check_eq(Service.scheduler.syncInterval, Service.scheduler.singleDeviceInterval); + + run_next_test(); +}); diff --git a/services/sync/tests/unit/test_service_startup.js b/services/sync/tests/unit/test_service_startup.js new file mode 100644 index 0000000000..5148f6d133 --- /dev/null +++ b/services/sync/tests/unit/test_service_startup.js @@ -0,0 +1,49 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +Cu.import("resource://services-common/observers.js"); +Cu.import("resource://services-sync/engines.js"); +Cu.import("resource://services-sync/util.js"); +Cu.import("resource://testing-common/services/sync/utils.js"); + +Svc.Prefs.set("registerEngines", "Tab,Bookmarks,Form,History"); +Cu.import("resource://services-sync/service.js"); + +function run_test() { + validate_all_future_pings(); + _("When imported, Service.onStartup is called"); + initTestLogging("Trace"); + + let xps = Cc["@mozilla.org/weave/service;1"] + .getService(Ci.nsISupports) + .wrappedJSObject; + do_check_false(xps.enabled); + + // Test fixtures + Service.identity.username = "johndoe"; + do_check_true(xps.enabled); + + Cu.import("resource://services-sync/service.js"); + + _("Service is enabled."); + do_check_eq(Service.enabled, true); + + _("Engines are registered."); + let engines = Service.engineManager.getAll(); + do_check_true(Utils.deepEquals(engines.map(engine => engine.name), + ['tabs', 'bookmarks', 'forms', 'history'])); + + _("Observers are notified of startup"); + do_test_pending(); + + do_check_false(Service.status.ready); + do_check_false(xps.ready); + Observers.add("weave:service:ready", function (subject, data) { + do_check_true(Service.status.ready); + do_check_true(xps.ready); + + // Clean up. + Svc.Prefs.resetBranch(""); + do_test_finished(); + }); +} diff --git a/services/sync/tests/unit/test_service_sync_401.js b/services/sync/tests/unit/test_service_sync_401.js new file mode 100644 index 0000000000..9e9db81374 --- /dev/null +++ b/services/sync/tests/unit/test_service_sync_401.js @@ -0,0 +1,84 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +Cu.import("resource://services-sync/constants.js"); +Cu.import("resource://services-sync/policies.js"); +Cu.import("resource://services-sync/service.js"); +Cu.import("resource://services-sync/util.js"); +Cu.import("resource://testing-common/services/sync/utils.js"); + +function login_handling(handler) { + return function (request, response) { + if (basic_auth_matches(request, "johndoe", "ilovejane")) { + handler(request, response); + } else { + let body = "Unauthorized"; + response.setStatusLine(request.httpVersion, 401, "Unauthorized"); + response.bodyOutputStream.write(body, body.length); + } + }; +} + +function run_test() { + let logger = Log.repository.rootLogger; + Log.repository.rootLogger.addAppender(new Log.DumpAppender()); + + let collectionsHelper = track_collections_helper(); + let upd = collectionsHelper.with_updated_collection; + let collections = collectionsHelper.collections; + + do_test_pending(); + let server = httpd_setup({ + "/1.1/johndoe/storage/crypto/keys": upd("crypto", new ServerWBO("keys").handler()), + "/1.1/johndoe/storage/meta/global": upd("meta", new ServerWBO("global").handler()), + "/1.1/johndoe/info/collections": login_handling(collectionsHelper.handler) + }); + + const GLOBAL_SCORE = 42; + + try { + _("Set up test fixtures."); + new SyncTestingInfrastructure(server, "johndoe", "ilovejane", "foo"); + Service.scheduler.globalScore = GLOBAL_SCORE; + // Avoid daily ping + Svc.Prefs.set("lastPing", Math.floor(Date.now() / 1000)); + + let threw = false; + Svc.Obs.add("weave:service:sync:error", function (subject, data) { + threw = true; + }); + + _("Initial state: We're successfully logged in."); + Service.login(); + do_check_true(Service.isLoggedIn); + do_check_eq(Service.status.login, LOGIN_SUCCEEDED); + + _("Simulate having changed the password somewhere else."); + Service.identity.basicPassword = "ilovejosephine"; + + _("Let's try to sync."); + Service.sync(); + + _("Verify that sync() threw an exception."); + do_check_true(threw); + + _("We're no longer logged in."); + do_check_false(Service.isLoggedIn); + + _("Sync status won't have changed yet, because we haven't tried again."); + + _("globalScore is reset upon starting a sync."); + do_check_eq(Service.scheduler.globalScore, 0); + + _("Our next sync will fail appropriately."); + try { + Service.sync(); + } catch (ex) { + } + do_check_eq(Service.status.login, LOGIN_FAILED_LOGIN_REJECTED); + + } finally { + Svc.Prefs.resetBranch(""); + server.stop(do_test_finished); + } +} diff --git a/services/sync/tests/unit/test_service_sync_locked.js b/services/sync/tests/unit/test_service_sync_locked.js new file mode 100644 index 0000000000..ee952c7eef --- /dev/null +++ b/services/sync/tests/unit/test_service_sync_locked.js @@ -0,0 +1,37 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +Cu.import("resource://services-sync/service.js"); +Cu.import("resource://services-sync/util.js"); + +function run_test() { + validate_all_future_pings(); + let debug = []; + let info = []; + + function augmentLogger(old) { + let d = old.debug; + let i = old.info; + // For the purposes of this test we don't need to do full formatting + // of the 2nd param, as the ones we care about are always strings. + old.debug = function(m, p) { debug.push(p ? m + ": " + p : m); d.call(old, m, p); } + old.info = function(m, p) { info.push(p ? m + ": " + p : m); i.call(old, m, p); } + return old; + } + + Log.repository.rootLogger.addAppender(new Log.DumpAppender()); + + augmentLogger(Service._log); + + // Avoid daily ping + Svc.Prefs.set("lastPing", Math.floor(Date.now() / 1000)); + + _("Check that sync will log appropriately if already in 'progress'."); + Service._locked = true; + Service.sync(); + Service._locked = false; + + do_check_true(debug[debug.length - 2].startsWith("Exception calling WrappedLock: Could not acquire lock. Label: \"service.js: login\".")); + do_check_eq(info[info.length - 1], "Cannot start sync: already syncing?"); +} + diff --git a/services/sync/tests/unit/test_service_sync_remoteSetup.js b/services/sync/tests/unit/test_service_sync_remoteSetup.js new file mode 100644 index 0000000000..83dbf3cd7b --- /dev/null +++ b/services/sync/tests/unit/test_service_sync_remoteSetup.js @@ -0,0 +1,237 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +Cu.import("resource://gre/modules/Log.jsm"); +Cu.import("resource://services-sync/constants.js"); +Cu.import("resource://services-sync/keys.js"); +Cu.import("resource://services-sync/service.js"); +Cu.import("resource://services-sync/util.js"); +Cu.import("resource://testing-common/services/sync/fakeservices.js"); +Cu.import("resource://testing-common/services/sync/utils.js"); + +function run_test() { + validate_all_future_pings(); + let logger = Log.repository.rootLogger; + Log.repository.rootLogger.addAppender(new Log.DumpAppender()); + + let guidSvc = new FakeGUIDService(); + let clients = new ServerCollection(); + let meta_global = new ServerWBO('global'); + + let collectionsHelper = track_collections_helper(); + let upd = collectionsHelper.with_updated_collection; + let collections = collectionsHelper.collections; + + function wasCalledHandler(wbo) { + let handler = wbo.handler(); + return function() { + wbo.wasCalled = true; + handler.apply(this, arguments); + }; + } + + let keysWBO = new ServerWBO("keys"); + let cryptoColl = new ServerCollection({keys: keysWBO}); + let metaColl = new ServerCollection({global: meta_global}); + do_test_pending(); + + /** + * Handle the bulk DELETE request sent by wipeServer. + */ + function storageHandler(request, response) { + do_check_eq("DELETE", request.method); + do_check_true(request.hasHeader("X-Confirm-Delete")); + + _("Wiping out all collections."); + cryptoColl.delete({}); + clients.delete({}); + metaColl.delete({}); + + let ts = new_timestamp(); + collectionsHelper.update_collection("crypto", ts); + collectionsHelper.update_collection("clients", ts); + collectionsHelper.update_collection("meta", ts); + return_timestamp(request, response, ts); + } + + const GLOBAL_PATH = "/1.1/johndoe/storage/meta/global"; + const INFO_PATH = "/1.1/johndoe/info/collections"; + + let handlers = { + "/1.1/johndoe/storage": storageHandler, + "/1.1/johndoe/storage/crypto/keys": upd("crypto", keysWBO.handler()), + "/1.1/johndoe/storage/crypto": upd("crypto", cryptoColl.handler()), + "/1.1/johndoe/storage/clients": upd("clients", clients.handler()), + "/1.1/johndoe/storage/meta": upd("meta", wasCalledHandler(metaColl)), + "/1.1/johndoe/storage/meta/global": upd("meta", wasCalledHandler(meta_global)), + "/1.1/johndoe/info/collections": collectionsHelper.handler + }; + + function mockHandler(path, mock) { + server.registerPathHandler(path, mock(handlers[path])); + return { + restore() { server.registerPathHandler(path, handlers[path]); } + } + } + + let server = httpd_setup(handlers); + + try { + _("Log in."); + ensureLegacyIdentityManager(); + Service.serverURL = server.baseURI; + + _("Checking Status.sync with no credentials."); + Service.verifyAndFetchSymmetricKeys(); + do_check_eq(Service.status.sync, CREDENTIALS_CHANGED); + do_check_eq(Service.status.login, LOGIN_FAILED_NO_PASSPHRASE); + + _("Log in with an old secret phrase, is upgraded to Sync Key."); + Service.login("johndoe", "ilovejane", "my old secret phrase!!1!"); + _("End of login"); + do_check_true(Service.isLoggedIn); + do_check_true(Utils.isPassphrase(Service.identity.syncKey)); + let syncKey = Service.identity.syncKey; + Service.startOver(); + + Service.serverURL = server.baseURI; + Service.login("johndoe", "ilovejane", syncKey); + do_check_true(Service.isLoggedIn); + + _("Checking that remoteSetup returns true when credentials have changed."); + Service.recordManager.get(Service.metaURL).payload.syncID = "foobar"; + do_check_true(Service._remoteSetup()); + + let returnStatusCode = (method, code) => (oldMethod) => (req, res) => { + if (req.method === method) { + res.setStatusLine(req.httpVersion, code, ""); + } else { + oldMethod(req, res); + } + }; + + let mock = mockHandler(GLOBAL_PATH, returnStatusCode("GET", 401)); + Service.recordManager.del(Service.metaURL); + _("Checking that remoteSetup returns false on 401 on first get /meta/global."); + do_check_false(Service._remoteSetup()); + mock.restore(); + + Service.login("johndoe", "ilovejane", syncKey); + mock = mockHandler(GLOBAL_PATH, returnStatusCode("GET", 503)); + Service.recordManager.del(Service.metaURL); + _("Checking that remoteSetup returns false on 503 on first get /meta/global."); + do_check_false(Service._remoteSetup()); + do_check_eq(Service.status.sync, METARECORD_DOWNLOAD_FAIL); + mock.restore(); + + mock = mockHandler(GLOBAL_PATH, returnStatusCode("GET", 404)); + Service.recordManager.del(Service.metaURL); + _("Checking that remoteSetup recovers on 404 on first get /meta/global."); + do_check_true(Service._remoteSetup()); + mock.restore(); + + let makeOutdatedMeta = () => { + Service.metaModified = 0; + let infoResponse = Service._fetchInfo(); + return { + status: infoResponse.status, + obj: { + crypto: infoResponse.obj.crypto, + clients: infoResponse.obj.clients, + meta: 1 + } + }; + } + + _("Checking that remoteSetup recovers on 404 on get /meta/global after clear cached one."); + mock = mockHandler(GLOBAL_PATH, returnStatusCode("GET", 404)); + Service.recordManager.set(Service.metaURL, { isNew: false }); + do_check_true(Service._remoteSetup(makeOutdatedMeta())); + mock.restore(); + + _("Checking that remoteSetup returns false on 503 on get /meta/global after clear cached one."); + mock = mockHandler(GLOBAL_PATH, returnStatusCode("GET", 503)); + Service.status.sync = ""; + Service.recordManager.set(Service.metaURL, { isNew: false }); + do_check_false(Service._remoteSetup(makeOutdatedMeta())); + do_check_eq(Service.status.sync, ""); + mock.restore(); + + metaColl.delete({}); + + _("Do an initial sync."); + let beforeSync = Date.now()/1000; + Service.sync(); + + _("Checking that remoteSetup returns true."); + do_check_true(Service._remoteSetup()); + + _("Verify that the meta record was uploaded."); + do_check_eq(meta_global.data.syncID, Service.syncID); + do_check_eq(meta_global.data.storageVersion, STORAGE_VERSION); + do_check_eq(meta_global.data.engines.clients.version, Service.clientsEngine.version); + do_check_eq(meta_global.data.engines.clients.syncID, Service.clientsEngine.syncID); + + _("Set the collection info hash so that sync() will remember the modified times for future runs."); + collections.meta = Service.clientsEngine.lastSync; + collections.clients = Service.clientsEngine.lastSync; + Service.sync(); + + _("Sync again and verify that meta/global wasn't downloaded again"); + meta_global.wasCalled = false; + Service.sync(); + do_check_false(meta_global.wasCalled); + + _("Fake modified records. This will cause a redownload, but not reupload since it hasn't changed."); + collections.meta += 42; + meta_global.wasCalled = false; + + let metaModified = meta_global.modified; + + Service.sync(); + do_check_true(meta_global.wasCalled); + do_check_eq(metaModified, meta_global.modified); + + _("Checking bad passphrases."); + let pp = Service.identity.syncKey; + Service.identity.syncKey = "notvalid"; + do_check_false(Service.verifyAndFetchSymmetricKeys()); + do_check_eq(Service.status.sync, CREDENTIALS_CHANGED); + do_check_eq(Service.status.login, LOGIN_FAILED_INVALID_PASSPHRASE); + Service.identity.syncKey = pp; + do_check_true(Service.verifyAndFetchSymmetricKeys()); + + // changePassphrase wipes our keys, and they're regenerated on next sync. + _("Checking changed passphrase."); + let existingDefault = Service.collectionKeys.keyForCollection(); + let existingKeysPayload = keysWBO.payload; + let newPassphrase = "bbbbbabcdeabcdeabcdeabcdea"; + Service.changePassphrase(newPassphrase); + + _("Local key cache is full, but different."); + do_check_true(!!Service.collectionKeys._default); + do_check_false(Service.collectionKeys._default.equals(existingDefault)); + + _("Server has new keys."); + do_check_true(!!keysWBO.payload); + do_check_true(!!keysWBO.modified); + do_check_neq(keysWBO.payload, existingKeysPayload); + + // Try to screw up HMAC calculation. + // Re-encrypt keys with a new random keybundle, and upload them to the + // server, just as might happen with a second client. + _("Attempting to screw up HMAC by re-encrypting keys."); + let keys = Service.collectionKeys.asWBO(); + let b = new BulkKeyBundle("hmacerror"); + b.generateRandom(); + collections.crypto = keys.modified = 100 + (Date.now()/1000); // Future modification time. + keys.encrypt(b); + keys.upload(Service.resource(Service.cryptoKeysURL)); + + do_check_false(Service.verifyAndFetchSymmetricKeys()); + do_check_eq(Service.status.login, LOGIN_FAILED_INVALID_PASSPHRASE); + } finally { + Svc.Prefs.resetBranch(""); + server.stop(do_test_finished); + } +} diff --git a/services/sync/tests/unit/test_service_sync_specified.js b/services/sync/tests/unit/test_service_sync_specified.js new file mode 100644 index 0000000000..7cb0f9d9c6 --- /dev/null +++ b/services/sync/tests/unit/test_service_sync_specified.js @@ -0,0 +1,160 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +Cu.import("resource://services-sync/constants.js"); +Cu.import("resource://services-sync/engines.js"); +Cu.import("resource://services-sync/engines/clients.js"); +Cu.import("resource://services-sync/record.js"); +Cu.import("resource://services-sync/service.js"); +Cu.import("resource://services-sync/util.js"); +Cu.import("resource://testing-common/services/sync/utils.js"); + +initTestLogging(); +Service.engineManager.clear(); + +let syncedEngines = [] + +function SteamEngine() { + SyncEngine.call(this, "Steam", Service); +} +SteamEngine.prototype = { + __proto__: SyncEngine.prototype, + _sync: function _sync() { + syncedEngines.push(this.name); + } +}; +Service.engineManager.register(SteamEngine); + +function StirlingEngine() { + SyncEngine.call(this, "Stirling", Service); +} +StirlingEngine.prototype = { + __proto__: SteamEngine.prototype, + _sync: function _sync() { + syncedEngines.push(this.name); + } +}; +Service.engineManager.register(StirlingEngine); + +// Tracking info/collections. +var collectionsHelper = track_collections_helper(); +var upd = collectionsHelper.with_updated_collection; + +function sync_httpd_setup(handlers) { + + handlers["/1.1/johndoe/info/collections"] = collectionsHelper.handler; + delete collectionsHelper.collections.crypto; + delete collectionsHelper.collections.meta; + + let cr = new ServerWBO("keys"); + handlers["/1.1/johndoe/storage/crypto/keys"] = + upd("crypto", cr.handler()); + + let cl = new ServerCollection(); + handlers["/1.1/johndoe/storage/clients"] = + upd("clients", cl.handler()); + + return httpd_setup(handlers); +} + +function setUp() { + syncedEngines = []; + let engine = Service.engineManager.get("steam"); + engine.enabled = true; + engine.syncPriority = 1; + + engine = Service.engineManager.get("stirling"); + engine.enabled = true; + engine.syncPriority = 2; + + let server = sync_httpd_setup({ + "/1.1/johndoe/storage/meta/global": new ServerWBO("global", {}).handler(), + }); + new SyncTestingInfrastructure(server, "johndoe", "ilovejane", + "abcdeabcdeabcdeabcdeabcdea"); + return server; +} + +function run_test() { + initTestLogging("Trace"); + validate_all_future_pings(); + Log.repository.getLogger("Sync.Service").level = Log.Level.Trace; + Log.repository.getLogger("Sync.ErrorHandler").level = Log.Level.Trace; + + run_next_test(); +} + +add_test(function test_noEngines() { + _("Test: An empty array of engines to sync does nothing."); + let server = setUp(); + + try { + _("Sync with no engines specified."); + Service.sync([]); + deepEqual(syncedEngines, [], "no engines were synced"); + + } finally { + Service.startOver(); + server.stop(run_next_test); + } +}); + +add_test(function test_oneEngine() { + _("Test: Only one engine is synced."); + let server = setUp(); + + try { + + _("Sync with 1 engine specified."); + Service.sync(["steam"]); + deepEqual(syncedEngines, ["steam"]) + + } finally { + Service.startOver(); + server.stop(run_next_test); + } +}); + +add_test(function test_bothEnginesSpecified() { + _("Test: All engines are synced when specified in the correct order (1)."); + let server = setUp(); + + try { + _("Sync with both engines specified."); + Service.sync(["steam", "stirling"]); + deepEqual(syncedEngines, ["steam", "stirling"]) + + } finally { + Service.startOver(); + server.stop(run_next_test); + } +}); + +add_test(function test_bothEnginesSpecified() { + _("Test: All engines are synced when specified in the correct order (2)."); + let server = setUp(); + + try { + _("Sync with both engines specified."); + Service.sync(["stirling", "steam"]); + deepEqual(syncedEngines, ["stirling", "steam"]) + + } finally { + Service.startOver(); + server.stop(run_next_test); + } +}); + +add_test(function test_bothEnginesDefault() { + _("Test: All engines are synced when nothing is specified."); + let server = setUp(); + + try { + Service.sync(); + deepEqual(syncedEngines, ["steam", "stirling"]) + + } finally { + Service.startOver(); + server.stop(run_next_test); + } +}); diff --git a/services/sync/tests/unit/test_service_sync_updateEnabledEngines.js b/services/sync/tests/unit/test_service_sync_updateEnabledEngines.js new file mode 100644 index 0000000000..ee1800fd3f --- /dev/null +++ b/services/sync/tests/unit/test_service_sync_updateEnabledEngines.js @@ -0,0 +1,442 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +Cu.import("resource://services-sync/constants.js"); +Cu.import("resource://services-sync/engines.js"); +Cu.import("resource://services-sync/engines/clients.js"); +Cu.import("resource://services-sync/record.js"); +Cu.import("resource://services-sync/service.js"); +Cu.import("resource://services-sync/util.js"); +Cu.import("resource://testing-common/services/sync/utils.js"); + +initTestLogging(); +Service.engineManager.clear(); + +function QuietStore() { + Store.call("Quiet"); +} +QuietStore.prototype = { + getAllIDs: function getAllIDs() { + return []; + } +} + +function SteamEngine() { + SyncEngine.call(this, "Steam", Service); +} +SteamEngine.prototype = { + __proto__: SyncEngine.prototype, + // We're not interested in engine sync but what the service does. + _storeObj: QuietStore, + + _sync: function _sync() { + this._syncStartup(); + } +}; +Service.engineManager.register(SteamEngine); + +function StirlingEngine() { + SyncEngine.call(this, "Stirling", Service); +} +StirlingEngine.prototype = { + __proto__: SteamEngine.prototype, + // This engine's enabled state is the same as the SteamEngine's. + get prefName() { + return "steam"; + } +}; +Service.engineManager.register(StirlingEngine); + +// Tracking info/collections. +var collectionsHelper = track_collections_helper(); +var upd = collectionsHelper.with_updated_collection; + +function sync_httpd_setup(handlers) { + + handlers["/1.1/johndoe/info/collections"] = collectionsHelper.handler; + delete collectionsHelper.collections.crypto; + delete collectionsHelper.collections.meta; + + let cr = new ServerWBO("keys"); + handlers["/1.1/johndoe/storage/crypto/keys"] = + upd("crypto", cr.handler()); + + let cl = new ServerCollection(); + handlers["/1.1/johndoe/storage/clients"] = + upd("clients", cl.handler()); + + return httpd_setup(handlers); +} + +function setUp(server) { + new SyncTestingInfrastructure(server, "johndoe", "ilovejane", + "abcdeabcdeabcdeabcdeabcdea"); + // Ensure that the server has valid keys so that logging in will work and not + // result in a server wipe, rendering many of these tests useless. + generateNewKeys(Service.collectionKeys); + let serverKeys = Service.collectionKeys.asWBO("crypto", "keys"); + serverKeys.encrypt(Service.identity.syncKeyBundle); + return serverKeys.upload(Service.resource(Service.cryptoKeysURL)).success; +} + +const PAYLOAD = 42; + + +function run_test() { + initTestLogging("Trace"); + Log.repository.getLogger("Sync.Service").level = Log.Level.Trace; + Log.repository.getLogger("Sync.ErrorHandler").level = Log.Level.Trace; + validate_all_future_pings(); + + run_next_test(); +} + +add_test(function test_newAccount() { + _("Test: New account does not disable locally enabled engines."); + let engine = Service.engineManager.get("steam"); + let server = sync_httpd_setup({ + "/1.1/johndoe/storage/meta/global": new ServerWBO("global", {}).handler(), + "/1.1/johndoe/storage/steam": new ServerWBO("steam", {}).handler() + }); + setUp(server); + + try { + _("Engine is enabled from the beginning."); + Service._ignorePrefObserver = true; + engine.enabled = true; + Service._ignorePrefObserver = false; + + _("Sync."); + Service.sync(); + + _("Engine continues to be enabled."); + do_check_true(engine.enabled); + } finally { + Service.startOver(); + server.stop(run_next_test); + } +}); + +add_test(function test_enabledLocally() { + _("Test: Engine is disabled on remote clients and enabled locally"); + Service.syncID = "abcdefghij"; + let engine = Service.engineManager.get("steam"); + let metaWBO = new ServerWBO("global", {syncID: Service.syncID, + storageVersion: STORAGE_VERSION, + engines: {}}); + let server = sync_httpd_setup({ + "/1.1/johndoe/storage/meta/global": metaWBO.handler(), + "/1.1/johndoe/storage/steam": new ServerWBO("steam", {}).handler() + }); + setUp(server); + + try { + _("Enable engine locally."); + engine.enabled = true; + + _("Sync."); + Service.sync(); + + _("Meta record now contains the new engine."); + do_check_true(!!metaWBO.data.engines.steam); + + _("Engine continues to be enabled."); + do_check_true(engine.enabled); + } finally { + Service.startOver(); + server.stop(run_next_test); + } +}); + +add_test(function test_disabledLocally() { + _("Test: Engine is enabled on remote clients and disabled locally"); + Service.syncID = "abcdefghij"; + let engine = Service.engineManager.get("steam"); + let metaWBO = new ServerWBO("global", { + syncID: Service.syncID, + storageVersion: STORAGE_VERSION, + engines: {steam: {syncID: engine.syncID, + version: engine.version}} + }); + let steamCollection = new ServerWBO("steam", PAYLOAD); + + let server = sync_httpd_setup({ + "/1.1/johndoe/storage/meta/global": metaWBO.handler(), + "/1.1/johndoe/storage/steam": steamCollection.handler() + }); + setUp(server); + + try { + _("Disable engine locally."); + Service._ignorePrefObserver = true; + engine.enabled = true; + Service._ignorePrefObserver = false; + engine.enabled = false; + + _("Sync."); + Service.sync(); + + _("Meta record no longer contains engine."); + do_check_false(!!metaWBO.data.engines.steam); + + _("Server records are wiped."); + do_check_eq(steamCollection.payload, undefined); + + _("Engine continues to be disabled."); + do_check_false(engine.enabled); + } finally { + Service.startOver(); + server.stop(run_next_test); + } +}); + +add_test(function test_disabledLocally_wipe503() { + _("Test: Engine is enabled on remote clients and disabled locally"); + Service.syncID = "abcdefghij"; + let engine = Service.engineManager.get("steam"); + let metaWBO = new ServerWBO("global", { + syncID: Service.syncID, + storageVersion: STORAGE_VERSION, + engines: {steam: {syncID: engine.syncID, + version: engine.version}} + }); + let steamCollection = new ServerWBO("steam", PAYLOAD); + + function service_unavailable(request, response) { + let body = "Service Unavailable"; + response.setStatusLine(request.httpVersion, 503, "Service Unavailable"); + response.setHeader("Retry-After", "23"); + response.bodyOutputStream.write(body, body.length); + } + + let server = sync_httpd_setup({ + "/1.1/johndoe/storage/meta/global": metaWBO.handler(), + "/1.1/johndoe/storage/steam": service_unavailable + }); + setUp(server); + + _("Disable engine locally."); + Service._ignorePrefObserver = true; + engine.enabled = true; + Service._ignorePrefObserver = false; + engine.enabled = false; + + Svc.Obs.add("weave:ui:sync:error", function onSyncError() { + Svc.Obs.remove("weave:ui:sync:error", onSyncError); + + do_check_eq(Service.status.sync, SERVER_MAINTENANCE); + + Service.startOver(); + server.stop(run_next_test); + }); + + _("Sync."); + Service.errorHandler.syncAndReportErrors(); +}); + +add_test(function test_enabledRemotely() { + _("Test: Engine is disabled locally and enabled on a remote client"); + Service.syncID = "abcdefghij"; + let engine = Service.engineManager.get("steam"); + let metaWBO = new ServerWBO("global", { + syncID: Service.syncID, + storageVersion: STORAGE_VERSION, + engines: {steam: {syncID: engine.syncID, + version: engine.version}} + }); + let server = sync_httpd_setup({ + "/1.1/johndoe/storage/meta/global": + upd("meta", metaWBO.handler()), + + "/1.1/johndoe/storage/steam": + upd("steam", new ServerWBO("steam", {}).handler()) + }); + setUp(server); + + // We need to be very careful how we do this, so that we don't trigger a + // fresh start! + try { + _("Upload some keys to avoid a fresh start."); + let wbo = Service.collectionKeys.generateNewKeysWBO(); + wbo.encrypt(Service.identity.syncKeyBundle); + do_check_eq(200, wbo.upload(Service.resource(Service.cryptoKeysURL)).status); + + _("Engine is disabled."); + do_check_false(engine.enabled); + + _("Sync."); + Service.sync(); + + _("Engine is enabled."); + do_check_true(engine.enabled); + + _("Meta record still present."); + do_check_eq(metaWBO.data.engines.steam.syncID, engine.syncID); + } finally { + Service.startOver(); + server.stop(run_next_test); + } +}); + +add_test(function test_disabledRemotelyTwoClients() { + _("Test: Engine is enabled locally and disabled on a remote client... with two clients."); + Service.syncID = "abcdefghij"; + let engine = Service.engineManager.get("steam"); + let metaWBO = new ServerWBO("global", {syncID: Service.syncID, + storageVersion: STORAGE_VERSION, + engines: {}}); + let server = sync_httpd_setup({ + "/1.1/johndoe/storage/meta/global": + upd("meta", metaWBO.handler()), + + "/1.1/johndoe/storage/steam": + upd("steam", new ServerWBO("steam", {}).handler()) + }); + setUp(server); + + try { + _("Enable engine locally."); + Service._ignorePrefObserver = true; + engine.enabled = true; + Service._ignorePrefObserver = false; + + _("Sync."); + Service.sync(); + + _("Disable engine by deleting from meta/global."); + let d = metaWBO.data; + delete d.engines["steam"]; + metaWBO.payload = JSON.stringify(d); + metaWBO.modified = Date.now() / 1000; + + _("Add a second client and verify that the local pref is changed."); + Service.clientsEngine._store._remoteClients["foobar"] = {name: "foobar", type: "desktop"}; + Service.sync(); + + _("Engine is disabled."); + do_check_false(engine.enabled); + + } finally { + Service.startOver(); + server.stop(run_next_test); + } +}); + +add_test(function test_disabledRemotely() { + _("Test: Engine is enabled locally and disabled on a remote client"); + Service.syncID = "abcdefghij"; + let engine = Service.engineManager.get("steam"); + let metaWBO = new ServerWBO("global", {syncID: Service.syncID, + storageVersion: STORAGE_VERSION, + engines: {}}); + let server = sync_httpd_setup({ + "/1.1/johndoe/storage/meta/global": metaWBO.handler(), + "/1.1/johndoe/storage/steam": new ServerWBO("steam", {}).handler() + }); + setUp(server); + + try { + _("Enable engine locally."); + Service._ignorePrefObserver = true; + engine.enabled = true; + Service._ignorePrefObserver = false; + + _("Sync."); + Service.sync(); + + _("Engine is not disabled: only one client."); + do_check_true(engine.enabled); + + } finally { + Service.startOver(); + server.stop(run_next_test); + } +}); + +add_test(function test_dependentEnginesEnabledLocally() { + _("Test: Engine is disabled on remote clients and enabled locally"); + Service.syncID = "abcdefghij"; + let steamEngine = Service.engineManager.get("steam"); + let stirlingEngine = Service.engineManager.get("stirling"); + let metaWBO = new ServerWBO("global", {syncID: Service.syncID, + storageVersion: STORAGE_VERSION, + engines: {}}); + let server = sync_httpd_setup({ + "/1.1/johndoe/storage/meta/global": metaWBO.handler(), + "/1.1/johndoe/storage/steam": new ServerWBO("steam", {}).handler(), + "/1.1/johndoe/storage/stirling": new ServerWBO("stirling", {}).handler() + }); + setUp(server); + + try { + _("Enable engine locally. Doing it on one is enough."); + steamEngine.enabled = true; + + _("Sync."); + Service.sync(); + + _("Meta record now contains the new engines."); + do_check_true(!!metaWBO.data.engines.steam); + do_check_true(!!metaWBO.data.engines.stirling); + + _("Engines continue to be enabled."); + do_check_true(steamEngine.enabled); + do_check_true(stirlingEngine.enabled); + } finally { + Service.startOver(); + server.stop(run_next_test); + } +}); + +add_test(function test_dependentEnginesDisabledLocally() { + _("Test: Two dependent engines are enabled on remote clients and disabled locally"); + Service.syncID = "abcdefghij"; + let steamEngine = Service.engineManager.get("steam"); + let stirlingEngine = Service.engineManager.get("stirling"); + let metaWBO = new ServerWBO("global", { + syncID: Service.syncID, + storageVersion: STORAGE_VERSION, + engines: {steam: {syncID: steamEngine.syncID, + version: steamEngine.version}, + stirling: {syncID: stirlingEngine.syncID, + version: stirlingEngine.version}} + }); + + let steamCollection = new ServerWBO("steam", PAYLOAD); + let stirlingCollection = new ServerWBO("stirling", PAYLOAD); + + let server = sync_httpd_setup({ + "/1.1/johndoe/storage/meta/global": metaWBO.handler(), + "/1.1/johndoe/storage/steam": steamCollection.handler(), + "/1.1/johndoe/storage/stirling": stirlingCollection.handler() + }); + setUp(server); + + try { + _("Disable engines locally. Doing it on one is enough."); + Service._ignorePrefObserver = true; + steamEngine.enabled = true; + do_check_true(stirlingEngine.enabled); + Service._ignorePrefObserver = false; + steamEngine.enabled = false; + do_check_false(stirlingEngine.enabled); + + _("Sync."); + Service.sync(); + + _("Meta record no longer contains engines."); + do_check_false(!!metaWBO.data.engines.steam); + do_check_false(!!metaWBO.data.engines.stirling); + + _("Server records are wiped."); + do_check_eq(steamCollection.payload, undefined); + do_check_eq(stirlingCollection.payload, undefined); + + _("Engines continue to be disabled."); + do_check_false(steamEngine.enabled); + do_check_false(stirlingEngine.enabled); + } finally { + Service.startOver(); + server.stop(run_next_test); + } +}); diff --git a/services/sync/tests/unit/test_service_verifyLogin.js b/services/sync/tests/unit/test_service_verifyLogin.js new file mode 100644 index 0000000000..2a27fd1b0e --- /dev/null +++ b/services/sync/tests/unit/test_service_verifyLogin.js @@ -0,0 +1,122 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +Cu.import("resource://gre/modules/Log.jsm"); +Cu.import("resource://services-sync/constants.js"); +Cu.import("resource://services-sync/service.js"); +Cu.import("resource://services-sync/util.js"); +Cu.import("resource://testing-common/services/sync/utils.js"); + +function login_handling(handler) { + return function (request, response) { + if (basic_auth_matches(request, "johndoe", "ilovejane")) { + handler(request, response); + } else { + let body = "Unauthorized"; + response.setStatusLine(request.httpVersion, 401, "Unauthorized"); + response.bodyOutputStream.write(body, body.length); + } + }; +} + +function service_unavailable(request, response) { + let body = "Service Unavailable"; + response.setStatusLine(request.httpVersion, 503, "Service Unavailable"); + response.setHeader("Retry-After", "42"); + response.bodyOutputStream.write(body, body.length); +} + +function run_test() { + let logger = Log.repository.rootLogger; + Log.repository.rootLogger.addAppender(new Log.DumpAppender()); + + ensureLegacyIdentityManager(); + // This test expects a clean slate -- no saved passphrase. + Services.logins.removeAllLogins(); + let johnHelper = track_collections_helper(); + let johnU = johnHelper.with_updated_collection; + let johnColls = johnHelper.collections; + + do_test_pending(); + + let server; + function weaveHandler (request, response) { + response.setStatusLine(request.httpVersion, 200, "OK"); + let body = server.baseURI + "/api/"; + response.bodyOutputStream.write(body, body.length); + } + + server = httpd_setup({ + "/api/1.1/johndoe/info/collections": login_handling(johnHelper.handler), + "/api/1.1/janedoe/info/collections": service_unavailable, + + "/api/1.1/johndoe/storage/crypto/keys": johnU("crypto", new ServerWBO("keys").handler()), + "/api/1.1/johndoe/storage/meta/global": johnU("meta", new ServerWBO("global").handler()), + "/user/1.0/johndoe/node/weave": weaveHandler, + }); + + try { + Service.serverURL = server.baseURI; + + _("Force the initial state."); + Service.status.service = STATUS_OK; + do_check_eq(Service.status.service, STATUS_OK); + + _("Credentials won't check out because we're not configured yet."); + Service.status.resetSync(); + do_check_false(Service.verifyLogin()); + do_check_eq(Service.status.service, CLIENT_NOT_CONFIGURED); + do_check_eq(Service.status.login, LOGIN_FAILED_NO_USERNAME); + + _("Try again with username and password set."); + Service.status.resetSync(); + setBasicCredentials("johndoe", "ilovejane", null); + do_check_false(Service.verifyLogin()); + do_check_eq(Service.status.service, CLIENT_NOT_CONFIGURED); + do_check_eq(Service.status.login, LOGIN_FAILED_NO_PASSPHRASE); + + _("verifyLogin() has found out the user's cluster URL, though."); + do_check_eq(Service.clusterURL, server.baseURI + "/api/"); + + _("Success if passphrase is set."); + Service.status.resetSync(); + Service.identity.syncKey = "foo"; + do_check_true(Service.verifyLogin()); + do_check_eq(Service.status.service, STATUS_OK); + do_check_eq(Service.status.login, LOGIN_SUCCEEDED); + + _("If verifyLogin() encounters a server error, it flips on the backoff flag and notifies observers on a 503 with Retry-After."); + Service.status.resetSync(); + Service.identity.account = "janedoe"; + Service._updateCachedURLs(); + do_check_false(Service.status.enforceBackoff); + let backoffInterval; + Svc.Obs.add("weave:service:backoff:interval", function observe(subject, data) { + Svc.Obs.remove("weave:service:backoff:interval", observe); + backoffInterval = subject; + }); + do_check_false(Service.verifyLogin()); + do_check_true(Service.status.enforceBackoff); + do_check_eq(backoffInterval, 42); + do_check_eq(Service.status.service, LOGIN_FAILED); + do_check_eq(Service.status.login, SERVER_MAINTENANCE); + + _("Ensure a network error when finding the cluster sets the right Status bits."); + Service.status.resetSync(); + Service.serverURL = "http://localhost:12345/"; + do_check_false(Service.verifyLogin()); + do_check_eq(Service.status.service, LOGIN_FAILED); + do_check_eq(Service.status.login, LOGIN_FAILED_NETWORK_ERROR); + + _("Ensure a network error when getting the collection info sets the right Status bits."); + Service.status.resetSync(); + Service.clusterURL = "http://localhost:12345/"; + do_check_false(Service.verifyLogin()); + do_check_eq(Service.status.service, LOGIN_FAILED); + do_check_eq(Service.status.login, LOGIN_FAILED_NETWORK_ERROR); + + } finally { + Svc.Prefs.resetBranch(""); + server.stop(do_test_finished); + } +} diff --git a/services/sync/tests/unit/test_service_wipeClient.js b/services/sync/tests/unit/test_service_wipeClient.js new file mode 100644 index 0000000000..aab7692298 --- /dev/null +++ b/services/sync/tests/unit/test_service_wipeClient.js @@ -0,0 +1,112 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +Cu.import("resource://services-sync/identity.js"); +Cu.import("resource://services-sync/engines.js"); +Cu.import("resource://services-sync/record.js"); +Cu.import("resource://services-sync/service.js"); +Cu.import("resource://services-sync/util.js"); +Cu.import("resource://testing-common/services/sync/utils.js"); + +Service.engineManager.clear(); + +function CanDecryptEngine() { + SyncEngine.call(this, "CanDecrypt", Service); +} +CanDecryptEngine.prototype = { + __proto__: SyncEngine.prototype, + + // Override these methods with mocks for the test + canDecrypt: function canDecrypt() { + return true; + }, + + wasWiped: false, + wipeClient: function wipeClient() { + this.wasWiped = true; + } +}; +Service.engineManager.register(CanDecryptEngine); + + +function CannotDecryptEngine() { + SyncEngine.call(this, "CannotDecrypt", Service); +} +CannotDecryptEngine.prototype = { + __proto__: SyncEngine.prototype, + + // Override these methods with mocks for the test + canDecrypt: function canDecrypt() { + return false; + }, + + wasWiped: false, + wipeClient: function wipeClient() { + this.wasWiped = true; + } +}; +Service.engineManager.register(CannotDecryptEngine); + + +add_test(function test_withEngineList() { + try { + _("Ensure initial scenario."); + do_check_false(Service.engineManager.get("candecrypt").wasWiped); + do_check_false(Service.engineManager.get("cannotdecrypt").wasWiped); + + _("Wipe local engine data."); + Service.wipeClient(["candecrypt", "cannotdecrypt"]); + + _("Ensure only the engine that can decrypt was wiped."); + do_check_true(Service.engineManager.get("candecrypt").wasWiped); + do_check_false(Service.engineManager.get("cannotdecrypt").wasWiped); + } finally { + Service.engineManager.get("candecrypt").wasWiped = false; + Service.engineManager.get("cannotdecrypt").wasWiped = false; + Service.startOver(); + } + + run_next_test(); +}); + +add_test(function test_startOver_clears_keys() { + generateNewKeys(Service.collectionKeys); + do_check_true(!!Service.collectionKeys.keyForCollection()); + Service.startOver(); + do_check_false(!!Service.collectionKeys.keyForCollection()); + + run_next_test(); +}); + +add_test(function test_credentials_preserved() { + _("Ensure that credentials are preserved if client is wiped."); + + // Required for wipeClient(). + ensureLegacyIdentityManager(); + Service.identity.account = "testaccount"; + Service.identity.basicPassword = "testpassword"; + Service.clusterURL = "http://dummy:9000/"; + let key = Utils.generatePassphrase(); + Service.identity.syncKey = key; + Service.identity.persistCredentials(); + + // Simulate passwords engine wipe without all the overhead. To do this + // properly would require extra test infrastructure. + Services.logins.removeAllLogins(); + Service.wipeClient(); + + let id = new IdentityManager(); + do_check_eq(id.account, "testaccount"); + do_check_eq(id.basicPassword, "testpassword"); + do_check_eq(id.syncKey, key); + + Service.startOver(); + + run_next_test(); +}); + +function run_test() { + initTestLogging(); + + run_next_test(); +} diff --git a/services/sync/tests/unit/test_service_wipeServer.js b/services/sync/tests/unit/test_service_wipeServer.js new file mode 100644 index 0000000000..9320f4b88a --- /dev/null +++ b/services/sync/tests/unit/test_service_wipeServer.js @@ -0,0 +1,242 @@ +Cu.import("resource://services-sync/util.js"); +Cu.import("resource://services-sync/record.js"); +Cu.import("resource://services-sync/resource.js"); +Cu.import("resource://testing-common/services/sync/fakeservices.js"); +Cu.import("resource://testing-common/services/sync/utils.js"); + +Svc.DefaultPrefs.set("registerEngines", ""); +Cu.import("resource://services-sync/service.js"); + +// configure the identity we use for this test. +identityConfig = makeIdentityConfig({username: "johndoe"}); + +function FakeCollection() { + this.deleted = false; +} +FakeCollection.prototype = { + handler: function() { + let self = this; + return function(request, response) { + let body = ""; + self.timestamp = new_timestamp(); + let timestamp = "" + self.timestamp; + if (request.method == "DELETE") { + body = timestamp; + self.deleted = true; + } + response.setHeader("X-Weave-Timestamp", timestamp); + response.setStatusLine(request.httpVersion, 200, "OK"); + response.bodyOutputStream.write(body, body.length); + }; + } +}; + +function* setUpTestFixtures(server) { + let cryptoService = new FakeCryptoService(); + + Service.serverURL = server.baseURI + "/"; + Service.clusterURL = server.baseURI + "/"; + + yield configureIdentity(identityConfig); +} + + +function run_test() { + initTestLogging("Trace"); + run_next_test(); +} + +function promiseStopServer(server) { + let deferred = Promise.defer(); + server.stop(deferred.resolve); + return deferred.promise; +} + +add_identity_test(this, function* test_wipeServer_list_success() { + _("Service.wipeServer() deletes collections given as argument."); + + let steam_coll = new FakeCollection(); + let diesel_coll = new FakeCollection(); + + let server = httpd_setup({ + "/1.1/johndoe/storage/steam": steam_coll.handler(), + "/1.1/johndoe/storage/diesel": diesel_coll.handler(), + "/1.1/johndoe/storage/petrol": httpd_handler(404, "Not Found") + }); + + try { + yield setUpTestFixtures(server); + new SyncTestingInfrastructure(server, "johndoe", "irrelevant", "irrelevant"); + + _("Confirm initial environment."); + do_check_false(steam_coll.deleted); + do_check_false(diesel_coll.deleted); + + _("wipeServer() will happily ignore the non-existent collection and use the timestamp of the last DELETE that was successful."); + let timestamp = Service.wipeServer(["steam", "diesel", "petrol"]); + do_check_eq(timestamp, diesel_coll.timestamp); + + _("wipeServer stopped deleting after encountering an error with the 'petrol' collection, thus only 'steam' has been deleted."); + do_check_true(steam_coll.deleted); + do_check_true(diesel_coll.deleted); + + } finally { + yield promiseStopServer(server); + Svc.Prefs.resetBranch(""); + } +}); + +add_identity_test(this, function* test_wipeServer_list_503() { + _("Service.wipeServer() deletes collections given as argument."); + + let steam_coll = new FakeCollection(); + let diesel_coll = new FakeCollection(); + + let server = httpd_setup({ + "/1.1/johndoe/storage/steam": steam_coll.handler(), + "/1.1/johndoe/storage/petrol": httpd_handler(503, "Service Unavailable"), + "/1.1/johndoe/storage/diesel": diesel_coll.handler() + }); + + try { + yield setUpTestFixtures(server); + new SyncTestingInfrastructure(server, "johndoe", "irrelevant", "irrelevant"); + + _("Confirm initial environment."); + do_check_false(steam_coll.deleted); + do_check_false(diesel_coll.deleted); + + _("wipeServer() will happily ignore the non-existent collection, delete the 'steam' collection and abort after an receiving an error on the 'petrol' collection."); + let error; + try { + Service.wipeServer(["non-existent", "steam", "petrol", "diesel"]); + do_throw("Should have thrown!"); + } catch(ex) { + error = ex; + } + _("wipeServer() threw this exception: " + error); + do_check_eq(error.status, 503); + + _("wipeServer stopped deleting after encountering an error with the 'petrol' collection, thus only 'steam' has been deleted."); + do_check_true(steam_coll.deleted); + do_check_false(diesel_coll.deleted); + + } finally { + yield promiseStopServer(server); + Svc.Prefs.resetBranch(""); + } +}); + +add_identity_test(this, function* test_wipeServer_all_success() { + _("Service.wipeServer() deletes all the things."); + + /** + * Handle the bulk DELETE request sent by wipeServer. + */ + let deleted = false; + let serverTimestamp; + function storageHandler(request, response) { + do_check_eq("DELETE", request.method); + do_check_true(request.hasHeader("X-Confirm-Delete")); + deleted = true; + serverTimestamp = return_timestamp(request, response); + } + + let server = httpd_setup({ + "/1.1/johndoe/storage": storageHandler + }); + yield setUpTestFixtures(server); + + _("Try deletion."); + new SyncTestingInfrastructure(server, "johndoe", "irrelevant", "irrelevant"); + let returnedTimestamp = Service.wipeServer(); + do_check_true(deleted); + do_check_eq(returnedTimestamp, serverTimestamp); + + yield promiseStopServer(server); + Svc.Prefs.resetBranch(""); +}); + +add_identity_test(this, function* test_wipeServer_all_404() { + _("Service.wipeServer() accepts a 404."); + + /** + * Handle the bulk DELETE request sent by wipeServer. Returns a 404. + */ + let deleted = false; + let serverTimestamp; + function storageHandler(request, response) { + do_check_eq("DELETE", request.method); + do_check_true(request.hasHeader("X-Confirm-Delete")); + deleted = true; + serverTimestamp = new_timestamp(); + response.setHeader("X-Weave-Timestamp", "" + serverTimestamp); + response.setStatusLine(request.httpVersion, 404, "Not Found"); + } + + let server = httpd_setup({ + "/1.1/johndoe/storage": storageHandler + }); + yield setUpTestFixtures(server); + + _("Try deletion."); + new SyncTestingInfrastructure(server, "johndoe", "irrelevant", "irrelevant"); + let returnedTimestamp = Service.wipeServer(); + do_check_true(deleted); + do_check_eq(returnedTimestamp, serverTimestamp); + + yield promiseStopServer(server); + Svc.Prefs.resetBranch(""); +}); + +add_identity_test(this, function* test_wipeServer_all_503() { + _("Service.wipeServer() throws if it encounters a non-200/404 response."); + + /** + * Handle the bulk DELETE request sent by wipeServer. Returns a 503. + */ + function storageHandler(request, response) { + do_check_eq("DELETE", request.method); + do_check_true(request.hasHeader("X-Confirm-Delete")); + response.setStatusLine(request.httpVersion, 503, "Service Unavailable"); + } + + let server = httpd_setup({ + "/1.1/johndoe/storage": storageHandler + }); + yield setUpTestFixtures(server); + + _("Try deletion."); + let error; + try { + new SyncTestingInfrastructure(server, "johndoe", "irrelevant", "irrelevant"); + Service.wipeServer(); + do_throw("Should have thrown!"); + } catch (ex) { + error = ex; + } + do_check_eq(error.status, 503); + + yield promiseStopServer(server); + Svc.Prefs.resetBranch(""); +}); + +add_identity_test(this, function* test_wipeServer_all_connectionRefused() { + _("Service.wipeServer() throws if it encounters a network problem."); + let server = httpd_setup({}); + yield setUpTestFixtures(server); + + Service.serverURL = "http://localhost:4352/"; + Service.clusterURL = "http://localhost:4352/"; + + _("Try deletion."); + try { + Service.wipeServer(); + do_throw("Should have thrown!"); + } catch (ex) { + do_check_eq(ex.result, Cr.NS_ERROR_CONNECTION_REFUSED); + } + + Svc.Prefs.resetBranch(""); + yield promiseStopServer(server); +}); diff --git a/services/sync/tests/unit/test_status.js b/services/sync/tests/unit/test_status.js new file mode 100644 index 0000000000..378aafe908 --- /dev/null +++ b/services/sync/tests/unit/test_status.js @@ -0,0 +1,91 @@ +Cu.import("resource://services-sync/constants.js"); +Cu.import("resource://services-sync/status.js"); + +function run_test() { + + // Check initial states + do_check_false(Status.enforceBackoff); + do_check_eq(Status.backoffInterval, 0); + do_check_eq(Status.minimumNextSync, 0); + + do_check_eq(Status.service, STATUS_OK); + do_check_eq(Status.sync, SYNC_SUCCEEDED); + do_check_eq(Status.login, LOGIN_SUCCEEDED); + for (let name in Status.engines) { + do_throw('Status.engines should be empty.'); + } + do_check_eq(Status.partial, false); + + + // Check login status + for (let code of [LOGIN_FAILED_NO_USERNAME, + LOGIN_FAILED_NO_PASSWORD, + LOGIN_FAILED_NO_PASSPHRASE]) { + Status.login = code; + do_check_eq(Status.login, code); + do_check_eq(Status.service, CLIENT_NOT_CONFIGURED); + Status.resetSync(); + } + + Status.login = LOGIN_FAILED; + do_check_eq(Status.login, LOGIN_FAILED); + do_check_eq(Status.service, LOGIN_FAILED); + Status.resetSync(); + + Status.login = LOGIN_SUCCEEDED; + do_check_eq(Status.login, LOGIN_SUCCEEDED); + do_check_eq(Status.service, STATUS_OK); + Status.resetSync(); + + + // Check sync status + Status.sync = SYNC_FAILED; + do_check_eq(Status.sync, SYNC_FAILED); + do_check_eq(Status.service, SYNC_FAILED); + + Status.sync = SYNC_SUCCEEDED; + do_check_eq(Status.sync, SYNC_SUCCEEDED); + do_check_eq(Status.service, STATUS_OK); + + Status.resetSync(); + + + // Check engine status + Status.engines = ["testEng1", ENGINE_SUCCEEDED]; + do_check_eq(Status.engines["testEng1"], ENGINE_SUCCEEDED); + do_check_eq(Status.service, STATUS_OK); + + Status.engines = ["testEng2", ENGINE_DOWNLOAD_FAIL]; + do_check_eq(Status.engines["testEng1"], ENGINE_SUCCEEDED); + do_check_eq(Status.engines["testEng2"], ENGINE_DOWNLOAD_FAIL); + do_check_eq(Status.service, SYNC_FAILED_PARTIAL); + + Status.engines = ["testEng3", ENGINE_SUCCEEDED]; + do_check_eq(Status.engines["testEng1"], ENGINE_SUCCEEDED); + do_check_eq(Status.engines["testEng2"], ENGINE_DOWNLOAD_FAIL); + do_check_eq(Status.engines["testEng3"], ENGINE_SUCCEEDED); + do_check_eq(Status.service, SYNC_FAILED_PARTIAL); + + + // Check resetSync + Status.sync = SYNC_FAILED; + Status.resetSync(); + + do_check_eq(Status.service, STATUS_OK); + do_check_eq(Status.sync, SYNC_SUCCEEDED); + for (name in Status.engines) { + do_throw('Status.engines should be empty.'); + } + + + // Check resetBackoff + Status.enforceBackoff = true; + Status.backOffInterval = 4815162342; + Status.backOffInterval = 42; + Status.resetBackoff(); + + do_check_false(Status.enforceBackoff); + do_check_eq(Status.backoffInterval, 0); + do_check_eq(Status.minimumNextSync, 0); + +} diff --git a/services/sync/tests/unit/test_status_checkSetup.js b/services/sync/tests/unit/test_status_checkSetup.js new file mode 100644 index 0000000000..64a6aac932 --- /dev/null +++ b/services/sync/tests/unit/test_status_checkSetup.js @@ -0,0 +1,45 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +Cu.import("resource://services-sync/constants.js"); +Cu.import("resource://services-sync/status.js"); +Cu.import("resource://services-sync/util.js"); +Cu.import("resource://testing-common/services/sync/utils.js"); + +function run_test() { + initTestLogging("Trace"); + ensureLegacyIdentityManager(); + + try { + _("Ensure fresh config."); + Status._authManager.deleteSyncCredentials(); + + _("Fresh setup, we're not configured."); + do_check_eq(Status.checkSetup(), CLIENT_NOT_CONFIGURED); + do_check_eq(Status.login, LOGIN_FAILED_NO_USERNAME); + Status.resetSync(); + + _("Let's provide a username."); + Status._authManager.username = "johndoe"; + do_check_eq(Status.checkSetup(), CLIENT_NOT_CONFIGURED); + do_check_eq(Status.login, LOGIN_FAILED_NO_PASSWORD); + Status.resetSync(); + + do_check_neq(Status._authManager.username, null); + + _("Let's provide a password."); + Status._authManager.basicPassword = "carotsalad"; + do_check_eq(Status.checkSetup(), CLIENT_NOT_CONFIGURED); + do_check_eq(Status.login, LOGIN_FAILED_NO_PASSPHRASE); + Status.resetSync(); + + _("Let's provide a passphrase"); + Status._authManager.syncKey = "a-bcdef-abcde-acbde-acbde-acbde"; + _("checkSetup()"); + do_check_eq(Status.checkSetup(), STATUS_OK); + Status.resetSync(); + + } finally { + Svc.Prefs.resetBranch(""); + } +} diff --git a/services/sync/tests/unit/test_syncedtabs.js b/services/sync/tests/unit/test_syncedtabs.js new file mode 100644 index 0000000000..fe2cb6d1b2 --- /dev/null +++ b/services/sync/tests/unit/test_syncedtabs.js @@ -0,0 +1,221 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- + * vim:set ts=2 sw=2 sts=2 et: +*/ +"use strict"; + +Cu.import("resource://services-sync/main.js"); +Cu.import("resource://services-sync/SyncedTabs.jsm"); +Cu.import("resource://gre/modules/Log.jsm"); + +const faviconService = Cc["@mozilla.org/browser/favicon-service;1"] + .getService(Ci.nsIFaviconService); + +Log.repository.getLogger("Sync.RemoteTabs").addAppender(new Log.DumpAppender()); + +// A mock "Tabs" engine which the SyncedTabs module will use instead of the real +// engine. We pass a constructor that Sync creates. +function MockTabsEngine() { + this.clients = {}; // We'll set this dynamically +} + +MockTabsEngine.prototype = { + name: "tabs", + enabled: true, + + getAllClients() { + return this.clients; + }, + + getOpenURLs() { + return new Set(); + }, +} + +// A clients engine that doesn't need to be a constructor. +let MockClientsEngine = { + clientSettings: null, // Set in `configureClients`. + + isMobile(guid) { + if (!guid.endsWith("desktop") && !guid.endsWith("mobile")) { + throw new Error("this module expected guids to end with 'desktop' or 'mobile'"); + } + return guid.endsWith("mobile"); + }, + remoteClientExists(id) { + return this.clientSettings[id] !== false; + }, + getClientName(id) { + if (this.clientSettings[id]) { + return this.clientSettings[id]; + } + let engine = Weave.Service.engineManager.get("tabs"); + return engine.clients[id].clientName; + }, +} + +// Configure Sync with our mock tabs engine and force it to become initialized. +Services.prefs.setCharPref("services.sync.username", "someone@somewhere.com"); + +Weave.Service.engineManager.unregister("tabs"); +Weave.Service.engineManager.register(MockTabsEngine); +Weave.Service.clientsEngine = MockClientsEngine; + +// Tell the Sync XPCOM service it is initialized. +let weaveXPCService = Cc["@mozilla.org/weave/service;1"] + .getService(Ci.nsISupports) + .wrappedJSObject; +weaveXPCService.ready = true; + +function configureClients(clients, clientSettings = {}) { + // Configure the instance Sync created. + let engine = Weave.Service.engineManager.get("tabs"); + // each client record is expected to have an id. + for (let [guid, client] of Object.entries(clients)) { + client.id = guid; + } + engine.clients = clients; + // Apply clients collection overrides. + MockClientsEngine.clientSettings = clientSettings; + // Send an observer that pretends the engine just finished a sync. + Services.obs.notifyObservers(null, "weave:engine:sync:finish", "tabs"); +} + +// The tests. +add_task(function* test_noClients() { + // no clients, can't be tabs. + yield configureClients({}); + + let tabs = yield SyncedTabs.getTabClients(); + equal(Object.keys(tabs).length, 0); +}); + +add_task(function* test_clientWithTabs() { + yield configureClients({ + guid_desktop: { + clientName: "My Desktop", + tabs: [ + { + urlHistory: ["http://foo.com/"], + icon: "http://foo.com/favicon", + }], + }, + guid_mobile: { + clientName: "My Phone", + tabs: [], + } + }); + + let clients = yield SyncedTabs.getTabClients(); + equal(clients.length, 2); + clients.sort((a, b) => { return a.name.localeCompare(b.name);}); + equal(clients[0].tabs.length, 1); + equal(clients[0].tabs[0].url, "http://foo.com/"); + equal(clients[0].tabs[0].icon, "http://foo.com/favicon"); + // second client has no tabs. + equal(clients[1].tabs.length, 0); +}); + +add_task(function* test_staleClientWithTabs() { + yield configureClients({ + guid_desktop: { + clientName: "My Desktop", + tabs: [ + { + urlHistory: ["http://foo.com/"], + icon: "http://foo.com/favicon", + }], + }, + guid_mobile: { + clientName: "My Phone", + tabs: [], + }, + guid_stale_mobile: { + clientName: "My Deleted Phone", + tabs: [], + }, + guid_stale_desktop: { + clientName: "My Deleted Laptop", + tabs: [ + { + urlHistory: ["https://bar.com/"], + icon: "https://bar.com/favicon", + }], + }, + guid_stale_name_desktop: { + clientName: "My Generic Device", + tabs: [ + { + urlHistory: ["https://example.edu/"], + icon: "https://example.edu/favicon", + }], + }, + }, { + guid_stale_mobile: false, + guid_stale_desktop: false, + // We should always use the device name from the clients collection, instead + // of the possibly stale tabs collection. + guid_stale_name_desktop: "My Laptop", + }); + let clients = yield SyncedTabs.getTabClients(); + clients.sort((a, b) => { return a.name.localeCompare(b.name);}); + equal(clients.length, 3); + equal(clients[0].name, "My Desktop"); + equal(clients[0].tabs.length, 1); + equal(clients[0].tabs[0].url, "http://foo.com/"); + equal(clients[1].name, "My Laptop"); + equal(clients[1].tabs.length, 1); + equal(clients[1].tabs[0].url, "https://example.edu/"); + equal(clients[2].name, "My Phone"); + equal(clients[2].tabs.length, 0); +}); + +add_task(function* test_clientWithTabsIconsDisabled() { + Services.prefs.setBoolPref("services.sync.syncedTabs.showRemoteIcons", false); + yield configureClients({ + guid_desktop: { + clientName: "My Desktop", + tabs: [ + { + urlHistory: ["http://foo.com/"], + icon: "http://foo.com/favicon", + }], + }, + }); + + let clients = yield SyncedTabs.getTabClients(); + equal(clients.length, 1); + clients.sort((a, b) => { return a.name.localeCompare(b.name);}); + equal(clients[0].tabs.length, 1); + equal(clients[0].tabs[0].url, "http://foo.com/"); + // expect the default favicon (empty string) due to the pref being false. + equal(clients[0].tabs[0].icon, ""); + Services.prefs.clearUserPref("services.sync.syncedTabs.showRemoteIcons"); +}); + +add_task(function* test_filter() { + // Nothing matches. + yield configureClients({ + guid_desktop: { + clientName: "My Desktop", + tabs: [ + { + urlHistory: ["http://foo.com/"], + title: "A test page.", + }, + { + urlHistory: ["http://bar.com/"], + title: "Another page.", + }], + }, + }); + + let clients = yield SyncedTabs.getTabClients("foo"); + equal(clients.length, 1); + equal(clients[0].tabs.length, 1); + equal(clients[0].tabs[0].url, "http://foo.com/"); + // check it matches the title. + clients = yield SyncedTabs.getTabClients("test"); + equal(clients.length, 1); + equal(clients[0].tabs.length, 1); + equal(clients[0].tabs[0].url, "http://foo.com/"); +}); diff --git a/services/sync/tests/unit/test_syncengine.js b/services/sync/tests/unit/test_syncengine.js new file mode 100644 index 0000000000..8c01ca0489 --- /dev/null +++ b/services/sync/tests/unit/test_syncengine.js @@ -0,0 +1,204 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +Cu.import("resource://services-sync/engines.js"); +Cu.import("resource://services-sync/service.js"); +Cu.import("resource://services-sync/util.js"); +Cu.import("resource://testing-common/services/sync/utils.js"); + +function makeSteamEngine() { + return new SyncEngine('Steam', Service); +} + +var server; + +function test_url_attributes() { + _("SyncEngine url attributes"); + let syncTesting = new SyncTestingInfrastructure(server); + Service.clusterURL = "https://cluster/"; + let engine = makeSteamEngine(); + try { + do_check_eq(engine.storageURL, "https://cluster/1.1/foo/storage/"); + do_check_eq(engine.engineURL, "https://cluster/1.1/foo/storage/steam"); + do_check_eq(engine.metaURL, "https://cluster/1.1/foo/storage/meta/global"); + } finally { + Svc.Prefs.resetBranch(""); + } +} + +function test_syncID() { + _("SyncEngine.syncID corresponds to preference"); + let syncTesting = new SyncTestingInfrastructure(server); + let engine = makeSteamEngine(); + try { + // Ensure pristine environment + do_check_eq(Svc.Prefs.get("steam.syncID"), undefined); + + // Performing the first get on the attribute will generate a new GUID. + do_check_eq(engine.syncID, "fake-guid-00"); + do_check_eq(Svc.Prefs.get("steam.syncID"), "fake-guid-00"); + + Svc.Prefs.set("steam.syncID", Utils.makeGUID()); + do_check_eq(Svc.Prefs.get("steam.syncID"), "fake-guid-01"); + do_check_eq(engine.syncID, "fake-guid-01"); + } finally { + Svc.Prefs.resetBranch(""); + } +} + +function test_lastSync() { + _("SyncEngine.lastSync and SyncEngine.lastSyncLocal correspond to preferences"); + let syncTesting = new SyncTestingInfrastructure(server); + let engine = makeSteamEngine(); + try { + // Ensure pristine environment + do_check_eq(Svc.Prefs.get("steam.lastSync"), undefined); + do_check_eq(engine.lastSync, 0); + do_check_eq(Svc.Prefs.get("steam.lastSyncLocal"), undefined); + do_check_eq(engine.lastSyncLocal, 0); + + // Floats are properly stored as floats and synced with the preference + engine.lastSync = 123.45; + do_check_eq(engine.lastSync, 123.45); + do_check_eq(Svc.Prefs.get("steam.lastSync"), "123.45"); + + // Integer is properly stored + engine.lastSyncLocal = 67890; + do_check_eq(engine.lastSyncLocal, 67890); + do_check_eq(Svc.Prefs.get("steam.lastSyncLocal"), "67890"); + + // resetLastSync() resets the value (and preference) to 0 + engine.resetLastSync(); + do_check_eq(engine.lastSync, 0); + do_check_eq(Svc.Prefs.get("steam.lastSync"), "0"); + } finally { + Svc.Prefs.resetBranch(""); + } +} + +function test_toFetch() { + _("SyncEngine.toFetch corresponds to file on disk"); + let syncTesting = new SyncTestingInfrastructure(server); + const filename = "weave/toFetch/steam.json"; + let engine = makeSteamEngine(); + try { + // Ensure pristine environment + do_check_eq(engine.toFetch.length, 0); + + // Write file to disk + let toFetch = [Utils.makeGUID(), Utils.makeGUID(), Utils.makeGUID()]; + engine.toFetch = toFetch; + do_check_eq(engine.toFetch, toFetch); + // toFetch is written asynchronously + engine._store._sleep(0); + let fakefile = syncTesting.fakeFilesystem.fakeContents[filename]; + do_check_eq(fakefile, JSON.stringify(toFetch)); + + // Read file from disk + toFetch = [Utils.makeGUID(), Utils.makeGUID()]; + syncTesting.fakeFilesystem.fakeContents[filename] = JSON.stringify(toFetch); + engine.loadToFetch(); + do_check_eq(engine.toFetch.length, 2); + do_check_eq(engine.toFetch[0], toFetch[0]); + do_check_eq(engine.toFetch[1], toFetch[1]); + } finally { + Svc.Prefs.resetBranch(""); + } +} + +function test_previousFailed() { + _("SyncEngine.previousFailed corresponds to file on disk"); + let syncTesting = new SyncTestingInfrastructure(server); + const filename = "weave/failed/steam.json"; + let engine = makeSteamEngine(); + try { + // Ensure pristine environment + do_check_eq(engine.previousFailed.length, 0); + + // Write file to disk + let previousFailed = [Utils.makeGUID(), Utils.makeGUID(), Utils.makeGUID()]; + engine.previousFailed = previousFailed; + do_check_eq(engine.previousFailed, previousFailed); + // previousFailed is written asynchronously + engine._store._sleep(0); + let fakefile = syncTesting.fakeFilesystem.fakeContents[filename]; + do_check_eq(fakefile, JSON.stringify(previousFailed)); + + // Read file from disk + previousFailed = [Utils.makeGUID(), Utils.makeGUID()]; + syncTesting.fakeFilesystem.fakeContents[filename] = JSON.stringify(previousFailed); + engine.loadPreviousFailed(); + do_check_eq(engine.previousFailed.length, 2); + do_check_eq(engine.previousFailed[0], previousFailed[0]); + do_check_eq(engine.previousFailed[1], previousFailed[1]); + } finally { + Svc.Prefs.resetBranch(""); + } +} + +function test_resetClient() { + _("SyncEngine.resetClient resets lastSync and toFetch"); + let syncTesting = new SyncTestingInfrastructure(server); + let engine = makeSteamEngine(); + try { + // Ensure pristine environment + do_check_eq(Svc.Prefs.get("steam.lastSync"), undefined); + do_check_eq(Svc.Prefs.get("steam.lastSyncLocal"), undefined); + do_check_eq(engine.toFetch.length, 0); + + engine.lastSync = 123.45; + engine.lastSyncLocal = 67890; + engine.toFetch = [Utils.makeGUID(), Utils.makeGUID(), Utils.makeGUID()]; + engine.previousFailed = [Utils.makeGUID(), Utils.makeGUID(), Utils.makeGUID()]; + + engine.resetClient(); + do_check_eq(engine.lastSync, 0); + do_check_eq(engine.lastSyncLocal, 0); + do_check_eq(engine.toFetch.length, 0); + do_check_eq(engine.previousFailed.length, 0); + } finally { + Svc.Prefs.resetBranch(""); + } +} + +function test_wipeServer() { + _("SyncEngine.wipeServer deletes server data and resets the client."); + let engine = makeSteamEngine(); + + const PAYLOAD = 42; + let steamCollection = new ServerWBO("steam", PAYLOAD); + let server = httpd_setup({ + "/1.1/foo/storage/steam": steamCollection.handler() + }); + let syncTesting = new SyncTestingInfrastructure(server); + do_test_pending(); + + try { + // Some data to reset. + engine.lastSync = 123.45; + engine.toFetch = [Utils.makeGUID(), Utils.makeGUID(), Utils.makeGUID()]; + + _("Wipe server data and reset client."); + engine.wipeServer(); + do_check_eq(steamCollection.payload, undefined); + do_check_eq(engine.lastSync, 0); + do_check_eq(engine.toFetch.length, 0); + + } finally { + server.stop(do_test_finished); + Svc.Prefs.resetBranch(""); + } +} + +function run_test() { + server = httpd_setup({}); + test_url_attributes(); + test_syncID(); + test_lastSync(); + test_toFetch(); + test_previousFailed(); + test_resetClient(); + test_wipeServer(); + + server.stop(run_next_test); +} diff --git a/services/sync/tests/unit/test_syncengine_sync.js b/services/sync/tests/unit/test_syncengine_sync.js new file mode 100644 index 0000000000..97289962f1 --- /dev/null +++ b/services/sync/tests/unit/test_syncengine_sync.js @@ -0,0 +1,1855 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +Cu.import("resource://services-sync/constants.js"); +Cu.import("resource://services-sync/engines.js"); +Cu.import("resource://services-sync/policies.js"); +Cu.import("resource://services-sync/record.js"); +Cu.import("resource://services-sync/resource.js"); +Cu.import("resource://services-sync/service.js"); +Cu.import("resource://services-sync/util.js"); +Cu.import("resource://testing-common/services/sync/rotaryengine.js"); +Cu.import("resource://testing-common/services/sync/utils.js"); + +function makeRotaryEngine() { + return new RotaryEngine(Service); +} + +function clean() { + Svc.Prefs.resetBranch(""); + Svc.Prefs.set("log.logger.engine.rotary", "Trace"); + Service.recordManager.clearCache(); +} + +function cleanAndGo(server) { + clean(); + server.stop(run_next_test); +} + +function promiseClean(server) { + clean(); + return new Promise(resolve => server.stop(resolve)); +} + +function configureService(server, username, password) { + Service.clusterURL = server.baseURI; + + Service.identity.account = username || "foo"; + Service.identity.basicPassword = password || "password"; +} + +function createServerAndConfigureClient() { + let engine = new RotaryEngine(Service); + + let contents = { + meta: {global: {engines: {rotary: {version: engine.version, + syncID: engine.syncID}}}}, + crypto: {}, + rotary: {} + }; + + const USER = "foo"; + let server = new SyncServer(); + server.registerUser(USER, "password"); + server.createContents(USER, contents); + server.start(); + + Service.serverURL = server.baseURI; + Service.clusterURL = server.baseURI; + Service.identity.username = USER; + Service._updateCachedURLs(); + + return [engine, server, USER]; +} + +function run_test() { + generateNewKeys(Service.collectionKeys); + Svc.Prefs.set("log.logger.engine.rotary", "Trace"); + run_next_test(); +} + +/* + * Tests + * + * SyncEngine._sync() is divided into four rather independent steps: + * + * - _syncStartup() + * - _processIncoming() + * - _uploadOutgoing() + * - _syncFinish() + * + * In the spirit of unit testing, these are tested individually for + * different scenarios below. + */ + +add_test(function test_syncStartup_emptyOrOutdatedGlobalsResetsSync() { + _("SyncEngine._syncStartup resets sync and wipes server data if there's no or an outdated global record"); + + // Some server side data that's going to be wiped + let collection = new ServerCollection(); + collection.insert('flying', + encryptPayload({id: 'flying', + denomination: "LNER Class A3 4472"})); + collection.insert('scotsman', + encryptPayload({id: 'scotsman', + denomination: "Flying Scotsman"})); + + let server = sync_httpd_setup({ + "/1.1/foo/storage/rotary": collection.handler() + }); + + let syncTesting = new SyncTestingInfrastructure(server); + Service.identity.username = "foo"; + + let engine = makeRotaryEngine(); + engine._store.items = {rekolok: "Rekonstruktionslokomotive"}; + try { + + // Confirm initial environment + do_check_eq(engine._tracker.changedIDs["rekolok"], undefined); + let metaGlobal = Service.recordManager.get(engine.metaURL); + do_check_eq(metaGlobal.payload.engines, undefined); + do_check_true(!!collection.payload("flying")); + do_check_true(!!collection.payload("scotsman")); + + engine.lastSync = Date.now() / 1000; + engine.lastSyncLocal = Date.now(); + + // Trying to prompt a wipe -- we no longer track CryptoMeta per engine, + // so it has nothing to check. + engine._syncStartup(); + + // The meta/global WBO has been filled with data about the engine + let engineData = metaGlobal.payload.engines["rotary"]; + do_check_eq(engineData.version, engine.version); + do_check_eq(engineData.syncID, engine.syncID); + + // Sync was reset and server data was wiped + do_check_eq(engine.lastSync, 0); + do_check_eq(collection.payload("flying"), undefined); + do_check_eq(collection.payload("scotsman"), undefined); + + } finally { + cleanAndGo(server); + } +}); + +add_test(function test_syncStartup_serverHasNewerVersion() { + _("SyncEngine._syncStartup "); + + let global = new ServerWBO('global', {engines: {rotary: {version: 23456}}}); + let server = httpd_setup({ + "/1.1/foo/storage/meta/global": global.handler() + }); + + let syncTesting = new SyncTestingInfrastructure(server); + Service.identity.username = "foo"; + + let engine = makeRotaryEngine(); + try { + + // The server has a newer version of the data and our engine can + // handle. That should give us an exception. + let error; + try { + engine._syncStartup(); + } catch (ex) { + error = ex; + } + do_check_eq(error.failureCode, VERSION_OUT_OF_DATE); + + } finally { + cleanAndGo(server); + } +}); + + +add_test(function test_syncStartup_syncIDMismatchResetsClient() { + _("SyncEngine._syncStartup resets sync if syncIDs don't match"); + + let server = sync_httpd_setup({}); + let syncTesting = new SyncTestingInfrastructure(server); + Service.identity.username = "foo"; + + // global record with a different syncID than our engine has + let engine = makeRotaryEngine(); + let global = new ServerWBO('global', + {engines: {rotary: {version: engine.version, + syncID: 'foobar'}}}); + server.registerPathHandler("/1.1/foo/storage/meta/global", global.handler()); + + try { + + // Confirm initial environment + do_check_eq(engine.syncID, 'fake-guid-00'); + do_check_eq(engine._tracker.changedIDs["rekolok"], undefined); + + engine.lastSync = Date.now() / 1000; + engine.lastSyncLocal = Date.now(); + engine._syncStartup(); + + // The engine has assumed the server's syncID + do_check_eq(engine.syncID, 'foobar'); + + // Sync was reset + do_check_eq(engine.lastSync, 0); + + } finally { + cleanAndGo(server); + } +}); + + +add_test(function test_processIncoming_emptyServer() { + _("SyncEngine._processIncoming working with an empty server backend"); + + let collection = new ServerCollection(); + let server = sync_httpd_setup({ + "/1.1/foo/storage/rotary": collection.handler() + }); + + let syncTesting = new SyncTestingInfrastructure(server); + Service.identity.username = "foo"; + + let engine = makeRotaryEngine(); + try { + + // Merely ensure that this code path is run without any errors + engine._processIncoming(); + do_check_eq(engine.lastSync, 0); + + } finally { + cleanAndGo(server); + } +}); + + +add_test(function test_processIncoming_createFromServer() { + _("SyncEngine._processIncoming creates new records from server data"); + + // Some server records that will be downloaded + let collection = new ServerCollection(); + collection.insert('flying', + encryptPayload({id: 'flying', + denomination: "LNER Class A3 4472"})); + collection.insert('scotsman', + encryptPayload({id: 'scotsman', + denomination: "Flying Scotsman"})); + + // Two pathological cases involving relative URIs gone wrong. + let pathologicalPayload = encryptPayload({id: '../pathological', + denomination: "Pathological Case"}); + collection.insert('../pathological', pathologicalPayload); + + let server = sync_httpd_setup({ + "/1.1/foo/storage/rotary": collection.handler(), + "/1.1/foo/storage/rotary/flying": collection.wbo("flying").handler(), + "/1.1/foo/storage/rotary/scotsman": collection.wbo("scotsman").handler() + }); + + let syncTesting = new SyncTestingInfrastructure(server); + Service.identity.username = "foo"; + + generateNewKeys(Service.collectionKeys); + + let engine = makeRotaryEngine(); + let meta_global = Service.recordManager.set(engine.metaURL, + new WBORecord(engine.metaURL)); + meta_global.payload.engines = {rotary: {version: engine.version, + syncID: engine.syncID}}; + + try { + + // Confirm initial environment + do_check_eq(engine.lastSync, 0); + do_check_eq(engine.lastModified, null); + do_check_eq(engine._store.items.flying, undefined); + do_check_eq(engine._store.items.scotsman, undefined); + do_check_eq(engine._store.items['../pathological'], undefined); + + engine._syncStartup(); + engine._processIncoming(); + + // Timestamps of last sync and last server modification are set. + do_check_true(engine.lastSync > 0); + do_check_true(engine.lastModified > 0); + + // Local records have been created from the server data. + do_check_eq(engine._store.items.flying, "LNER Class A3 4472"); + do_check_eq(engine._store.items.scotsman, "Flying Scotsman"); + do_check_eq(engine._store.items['../pathological'], "Pathological Case"); + + } finally { + cleanAndGo(server); + } +}); + + +add_test(function test_processIncoming_reconcile() { + _("SyncEngine._processIncoming updates local records"); + + let collection = new ServerCollection(); + + // This server record is newer than the corresponding client one, + // so it'll update its data. + collection.insert('newrecord', + encryptPayload({id: 'newrecord', + denomination: "New stuff..."})); + + // This server record is newer than the corresponding client one, + // so it'll update its data. + collection.insert('newerserver', + encryptPayload({id: 'newerserver', + denomination: "New data!"})); + + // This server record is 2 mins older than the client counterpart + // but identical to it, so we're expecting the client record's + // changedID to be reset. + collection.insert('olderidentical', + encryptPayload({id: 'olderidentical', + denomination: "Older but identical"})); + collection._wbos.olderidentical.modified -= 120; + + // This item simply has different data than the corresponding client + // record (which is unmodified), so it will update the client as well + collection.insert('updateclient', + encryptPayload({id: 'updateclient', + denomination: "Get this!"})); + + // This is a dupe of 'original'. + collection.insert('duplication', + encryptPayload({id: 'duplication', + denomination: "Original Entry"})); + + // This record is marked as deleted, so we're expecting the client + // record to be removed. + collection.insert('nukeme', + encryptPayload({id: 'nukeme', + denomination: "Nuke me!", + deleted: true})); + + let server = sync_httpd_setup({ + "/1.1/foo/storage/rotary": collection.handler() + }); + + let syncTesting = new SyncTestingInfrastructure(server); + Service.identity.username = "foo"; + + let engine = makeRotaryEngine(); + engine._store.items = {newerserver: "New data, but not as new as server!", + olderidentical: "Older but identical", + updateclient: "Got data?", + original: "Original Entry", + long_original: "Long Original Entry", + nukeme: "Nuke me!"}; + // Make this record 1 min old, thus older than the one on the server + engine._tracker.addChangedID('newerserver', Date.now()/1000 - 60); + // This record has been changed 2 mins later than the one on the server + engine._tracker.addChangedID('olderidentical', Date.now()/1000); + + let meta_global = Service.recordManager.set(engine.metaURL, + new WBORecord(engine.metaURL)); + meta_global.payload.engines = {rotary: {version: engine.version, + syncID: engine.syncID}}; + + try { + + // Confirm initial environment + do_check_eq(engine._store.items.newrecord, undefined); + do_check_eq(engine._store.items.newerserver, "New data, but not as new as server!"); + do_check_eq(engine._store.items.olderidentical, "Older but identical"); + do_check_eq(engine._store.items.updateclient, "Got data?"); + do_check_eq(engine._store.items.nukeme, "Nuke me!"); + do_check_true(engine._tracker.changedIDs['olderidentical'] > 0); + + engine._syncStartup(); + engine._processIncoming(); + + // Timestamps of last sync and last server modification are set. + do_check_true(engine.lastSync > 0); + do_check_true(engine.lastModified > 0); + + // The new record is created. + do_check_eq(engine._store.items.newrecord, "New stuff..."); + + // The 'newerserver' record is updated since the server data is newer. + do_check_eq(engine._store.items.newerserver, "New data!"); + + // The data for 'olderidentical' is identical on the server, so + // it's no longer marked as changed anymore. + do_check_eq(engine._store.items.olderidentical, "Older but identical"); + do_check_eq(engine._tracker.changedIDs['olderidentical'], undefined); + + // Updated with server data. + do_check_eq(engine._store.items.updateclient, "Get this!"); + + // The incoming ID is preferred. + do_check_eq(engine._store.items.original, undefined); + do_check_eq(engine._store.items.duplication, "Original Entry"); + do_check_neq(engine._delete.ids.indexOf("original"), -1); + + // The 'nukeme' record marked as deleted is removed. + do_check_eq(engine._store.items.nukeme, undefined); + } finally { + cleanAndGo(server); + } +}); + +add_test(function test_processIncoming_reconcile_local_deleted() { + _("Ensure local, duplicate ID is deleted on server."); + + // When a duplicate is resolved, the local ID (which is never taken) should + // be deleted on the server. + let [engine, server, user] = createServerAndConfigureClient(); + + let now = Date.now() / 1000 - 10; + engine.lastSync = now; + engine.lastModified = now + 1; + + let record = encryptPayload({id: "DUPE_INCOMING", denomination: "incoming"}); + let wbo = new ServerWBO("DUPE_INCOMING", record, now + 2); + server.insertWBO(user, "rotary", wbo); + + record = encryptPayload({id: "DUPE_LOCAL", denomination: "local"}); + wbo = new ServerWBO("DUPE_LOCAL", record, now - 1); + server.insertWBO(user, "rotary", wbo); + + engine._store.create({id: "DUPE_LOCAL", denomination: "local"}); + do_check_true(engine._store.itemExists("DUPE_LOCAL")); + do_check_eq("DUPE_LOCAL", engine._findDupe({id: "DUPE_INCOMING"})); + + engine._sync(); + + do_check_attribute_count(engine._store.items, 1); + do_check_true("DUPE_INCOMING" in engine._store.items); + + let collection = server.getCollection(user, "rotary"); + do_check_eq(1, collection.count()); + do_check_neq(undefined, collection.wbo("DUPE_INCOMING")); + + cleanAndGo(server); +}); + +add_test(function test_processIncoming_reconcile_equivalent() { + _("Ensure proper handling of incoming records that match local."); + + let [engine, server, user] = createServerAndConfigureClient(); + + let now = Date.now() / 1000 - 10; + engine.lastSync = now; + engine.lastModified = now + 1; + + let record = encryptPayload({id: "entry", denomination: "denomination"}); + let wbo = new ServerWBO("entry", record, now + 2); + server.insertWBO(user, "rotary", wbo); + + engine._store.items = {entry: "denomination"}; + do_check_true(engine._store.itemExists("entry")); + + engine._sync(); + + do_check_attribute_count(engine._store.items, 1); + + cleanAndGo(server); +}); + +add_test(function test_processIncoming_reconcile_locally_deleted_dupe_new() { + _("Ensure locally deleted duplicate record newer than incoming is handled."); + + // This is a somewhat complicated test. It ensures that if a client receives + // a modified record for an item that is deleted locally but with a different + // ID that the incoming record is ignored. This is a corner case for record + // handling, but it needs to be supported. + let [engine, server, user] = createServerAndConfigureClient(); + + let now = Date.now() / 1000 - 10; + engine.lastSync = now; + engine.lastModified = now + 1; + + let record = encryptPayload({id: "DUPE_INCOMING", denomination: "incoming"}); + let wbo = new ServerWBO("DUPE_INCOMING", record, now + 2); + server.insertWBO(user, "rotary", wbo); + + // Simulate a locally-deleted item. + engine._store.items = {}; + engine._tracker.addChangedID("DUPE_LOCAL", now + 3); + do_check_false(engine._store.itemExists("DUPE_LOCAL")); + do_check_false(engine._store.itemExists("DUPE_INCOMING")); + do_check_eq("DUPE_LOCAL", engine._findDupe({id: "DUPE_INCOMING"})); + + engine._sync(); + + // After the sync, the server's payload for the original ID should be marked + // as deleted. + do_check_empty(engine._store.items); + let collection = server.getCollection(user, "rotary"); + do_check_eq(1, collection.count()); + wbo = collection.wbo("DUPE_INCOMING"); + do_check_neq(null, wbo); + let payload = JSON.parse(JSON.parse(wbo.payload).ciphertext); + do_check_true(payload.deleted); + + cleanAndGo(server); +}); + +add_test(function test_processIncoming_reconcile_locally_deleted_dupe_old() { + _("Ensure locally deleted duplicate record older than incoming is restored."); + + // This is similar to the above test except it tests the condition where the + // incoming record is newer than the local deletion, therefore overriding it. + + let [engine, server, user] = createServerAndConfigureClient(); + + let now = Date.now() / 1000 - 10; + engine.lastSync = now; + engine.lastModified = now + 1; + + let record = encryptPayload({id: "DUPE_INCOMING", denomination: "incoming"}); + let wbo = new ServerWBO("DUPE_INCOMING", record, now + 2); + server.insertWBO(user, "rotary", wbo); + + // Simulate a locally-deleted item. + engine._store.items = {}; + engine._tracker.addChangedID("DUPE_LOCAL", now + 1); + do_check_false(engine._store.itemExists("DUPE_LOCAL")); + do_check_false(engine._store.itemExists("DUPE_INCOMING")); + do_check_eq("DUPE_LOCAL", engine._findDupe({id: "DUPE_INCOMING"})); + + engine._sync(); + + // Since the remote change is newer, the incoming item should exist locally. + do_check_attribute_count(engine._store.items, 1); + do_check_true("DUPE_INCOMING" in engine._store.items); + do_check_eq("incoming", engine._store.items.DUPE_INCOMING); + + let collection = server.getCollection(user, "rotary"); + do_check_eq(1, collection.count()); + wbo = collection.wbo("DUPE_INCOMING"); + let payload = JSON.parse(JSON.parse(wbo.payload).ciphertext); + do_check_eq("incoming", payload.denomination); + + cleanAndGo(server); +}); + +add_test(function test_processIncoming_reconcile_changed_dupe() { + _("Ensure that locally changed duplicate record is handled properly."); + + let [engine, server, user] = createServerAndConfigureClient(); + + let now = Date.now() / 1000 - 10; + engine.lastSync = now; + engine.lastModified = now + 1; + + // The local record is newer than the incoming one, so it should be retained. + let record = encryptPayload({id: "DUPE_INCOMING", denomination: "incoming"}); + let wbo = new ServerWBO("DUPE_INCOMING", record, now + 2); + server.insertWBO(user, "rotary", wbo); + + engine._store.create({id: "DUPE_LOCAL", denomination: "local"}); + engine._tracker.addChangedID("DUPE_LOCAL", now + 3); + do_check_true(engine._store.itemExists("DUPE_LOCAL")); + do_check_eq("DUPE_LOCAL", engine._findDupe({id: "DUPE_INCOMING"})); + + engine._sync(); + + // The ID should have been changed to incoming. + do_check_attribute_count(engine._store.items, 1); + do_check_true("DUPE_INCOMING" in engine._store.items); + + // On the server, the local ID should be deleted and the incoming ID should + // have its payload set to what was in the local record. + let collection = server.getCollection(user, "rotary"); + do_check_eq(1, collection.count()); + wbo = collection.wbo("DUPE_INCOMING"); + do_check_neq(undefined, wbo); + let payload = JSON.parse(JSON.parse(wbo.payload).ciphertext); + do_check_eq("local", payload.denomination); + + cleanAndGo(server); +}); + +add_test(function test_processIncoming_reconcile_changed_dupe_new() { + _("Ensure locally changed duplicate record older than incoming is ignored."); + + // This test is similar to the above except the incoming record is younger + // than the local record. The incoming record should be authoritative. + let [engine, server, user] = createServerAndConfigureClient(); + + let now = Date.now() / 1000 - 10; + engine.lastSync = now; + engine.lastModified = now + 1; + + let record = encryptPayload({id: "DUPE_INCOMING", denomination: "incoming"}); + let wbo = new ServerWBO("DUPE_INCOMING", record, now + 2); + server.insertWBO(user, "rotary", wbo); + + engine._store.create({id: "DUPE_LOCAL", denomination: "local"}); + engine._tracker.addChangedID("DUPE_LOCAL", now + 1); + do_check_true(engine._store.itemExists("DUPE_LOCAL")); + do_check_eq("DUPE_LOCAL", engine._findDupe({id: "DUPE_INCOMING"})); + + engine._sync(); + + // The ID should have been changed to incoming. + do_check_attribute_count(engine._store.items, 1); + do_check_true("DUPE_INCOMING" in engine._store.items); + + // On the server, the local ID should be deleted and the incoming ID should + // have its payload retained. + let collection = server.getCollection(user, "rotary"); + do_check_eq(1, collection.count()); + wbo = collection.wbo("DUPE_INCOMING"); + do_check_neq(undefined, wbo); + let payload = JSON.parse(JSON.parse(wbo.payload).ciphertext); + do_check_eq("incoming", payload.denomination); + cleanAndGo(server); +}); + +add_test(function test_processIncoming_mobile_batchSize() { + _("SyncEngine._processIncoming doesn't fetch everything at once on mobile clients"); + + Svc.Prefs.set("client.type", "mobile"); + Service.identity.username = "foo"; + + // A collection that logs each GET + let collection = new ServerCollection(); + collection.get_log = []; + collection._get = collection.get; + collection.get = function (options) { + this.get_log.push(options); + return this._get(options); + }; + + // Let's create some 234 server side records. They're all at least + // 10 minutes old. + for (let i = 0; i < 234; i++) { + let id = 'record-no-' + i; + let payload = encryptPayload({id: id, denomination: "Record No. " + i}); + let wbo = new ServerWBO(id, payload); + wbo.modified = Date.now()/1000 - 60*(i+10); + collection.insertWBO(wbo); + } + + let server = sync_httpd_setup({ + "/1.1/foo/storage/rotary": collection.handler() + }); + + let syncTesting = new SyncTestingInfrastructure(server); + + let engine = makeRotaryEngine(); + let meta_global = Service.recordManager.set(engine.metaURL, + new WBORecord(engine.metaURL)); + meta_global.payload.engines = {rotary: {version: engine.version, + syncID: engine.syncID}}; + + try { + + _("On a mobile client, we get new records from the server in batches of 50."); + engine._syncStartup(); + engine._processIncoming(); + do_check_attribute_count(engine._store.items, 234); + do_check_true('record-no-0' in engine._store.items); + do_check_true('record-no-49' in engine._store.items); + do_check_true('record-no-50' in engine._store.items); + do_check_true('record-no-233' in engine._store.items); + + // Verify that the right number of GET requests with the right + // kind of parameters were made. + do_check_eq(collection.get_log.length, + Math.ceil(234 / MOBILE_BATCH_SIZE) + 1); + do_check_eq(collection.get_log[0].full, 1); + do_check_eq(collection.get_log[0].limit, MOBILE_BATCH_SIZE); + do_check_eq(collection.get_log[1].full, undefined); + do_check_eq(collection.get_log[1].limit, undefined); + for (let i = 1; i <= Math.floor(234 / MOBILE_BATCH_SIZE); i++) { + do_check_eq(collection.get_log[i+1].full, 1); + do_check_eq(collection.get_log[i+1].limit, undefined); + if (i < Math.floor(234 / MOBILE_BATCH_SIZE)) + do_check_eq(collection.get_log[i+1].ids.length, MOBILE_BATCH_SIZE); + else + do_check_eq(collection.get_log[i+1].ids.length, 234 % MOBILE_BATCH_SIZE); + } + + } finally { + cleanAndGo(server); + } +}); + + +add_task(function *test_processIncoming_store_toFetch() { + _("If processIncoming fails in the middle of a batch on mobile, state is saved in toFetch and lastSync."); + Service.identity.username = "foo"; + Svc.Prefs.set("client.type", "mobile"); + + // A collection that throws at the fourth get. + let collection = new ServerCollection(); + collection._get_calls = 0; + collection._get = collection.get; + collection.get = function() { + this._get_calls += 1; + if (this._get_calls > 3) { + throw "Abort on fourth call!"; + } + return this._get.apply(this, arguments); + }; + + // Let's create three batches worth of server side records. + for (var i = 0; i < MOBILE_BATCH_SIZE * 3; i++) { + let id = 'record-no-' + i; + let payload = encryptPayload({id: id, denomination: "Record No. " + id}); + let wbo = new ServerWBO(id, payload); + wbo.modified = Date.now()/1000 + 60 * (i - MOBILE_BATCH_SIZE * 3); + collection.insertWBO(wbo); + } + + let engine = makeRotaryEngine(); + engine.enabled = true; + + let server = sync_httpd_setup({ + "/1.1/foo/storage/rotary": collection.handler() + }); + + let syncTesting = new SyncTestingInfrastructure(server); + + let meta_global = Service.recordManager.set(engine.metaURL, + new WBORecord(engine.metaURL)); + meta_global.payload.engines = {rotary: {version: engine.version, + syncID: engine.syncID}}; + try { + + // Confirm initial environment + do_check_eq(engine.lastSync, 0); + do_check_empty(engine._store.items); + + let error; + try { + yield sync_engine_and_validate_telem(engine, true); + } catch (ex) { + error = ex; + } + + // Only the first two batches have been applied. + do_check_eq(Object.keys(engine._store.items).length, + MOBILE_BATCH_SIZE * 2); + + // The third batch is stuck in toFetch. lastSync has been moved forward to + // the last successful item's timestamp. + do_check_eq(engine.toFetch.length, MOBILE_BATCH_SIZE); + do_check_eq(engine.lastSync, collection.wbo("record-no-99").modified); + + } finally { + yield promiseClean(server); + } +}); + + +add_test(function test_processIncoming_resume_toFetch() { + _("toFetch and previousFailed items left over from previous syncs are fetched on the next sync, along with new items."); + Service.identity.username = "foo"; + + const LASTSYNC = Date.now() / 1000; + + // Server records that will be downloaded + let collection = new ServerCollection(); + collection.insert('flying', + encryptPayload({id: 'flying', + denomination: "LNER Class A3 4472"})); + collection.insert('scotsman', + encryptPayload({id: 'scotsman', + denomination: "Flying Scotsman"})); + collection.insert('rekolok', + encryptPayload({id: 'rekolok', + denomination: "Rekonstruktionslokomotive"})); + for (let i = 0; i < 3; i++) { + let id = 'failed' + i; + let payload = encryptPayload({id: id, denomination: "Record No. " + i}); + let wbo = new ServerWBO(id, payload); + wbo.modified = LASTSYNC - 10; + collection.insertWBO(wbo); + } + + collection.wbo("flying").modified = + collection.wbo("scotsman").modified = LASTSYNC - 10; + collection._wbos.rekolok.modified = LASTSYNC + 10; + + // Time travel 10 seconds into the future but still download the above WBOs. + let engine = makeRotaryEngine(); + engine.lastSync = LASTSYNC; + engine.toFetch = ["flying", "scotsman"]; + engine.previousFailed = ["failed0", "failed1", "failed2"]; + + let server = sync_httpd_setup({ + "/1.1/foo/storage/rotary": collection.handler() + }); + + let syncTesting = new SyncTestingInfrastructure(server); + + let meta_global = Service.recordManager.set(engine.metaURL, + new WBORecord(engine.metaURL)); + meta_global.payload.engines = {rotary: {version: engine.version, + syncID: engine.syncID}}; + try { + + // Confirm initial environment + do_check_eq(engine._store.items.flying, undefined); + do_check_eq(engine._store.items.scotsman, undefined); + do_check_eq(engine._store.items.rekolok, undefined); + + engine._syncStartup(); + engine._processIncoming(); + + // Local records have been created from the server data. + do_check_eq(engine._store.items.flying, "LNER Class A3 4472"); + do_check_eq(engine._store.items.scotsman, "Flying Scotsman"); + do_check_eq(engine._store.items.rekolok, "Rekonstruktionslokomotive"); + do_check_eq(engine._store.items.failed0, "Record No. 0"); + do_check_eq(engine._store.items.failed1, "Record No. 1"); + do_check_eq(engine._store.items.failed2, "Record No. 2"); + do_check_eq(engine.previousFailed.length, 0); + } finally { + cleanAndGo(server); + } +}); + + +add_test(function test_processIncoming_applyIncomingBatchSize_smaller() { + _("Ensure that a number of incoming items less than applyIncomingBatchSize is still applied."); + Service.identity.username = "foo"; + + // Engine that doesn't like the first and last record it's given. + const APPLY_BATCH_SIZE = 10; + let engine = makeRotaryEngine(); + engine.applyIncomingBatchSize = APPLY_BATCH_SIZE; + engine._store._applyIncomingBatch = engine._store.applyIncomingBatch; + engine._store.applyIncomingBatch = function (records) { + let failed1 = records.shift(); + let failed2 = records.pop(); + this._applyIncomingBatch(records); + return [failed1.id, failed2.id]; + }; + + // Let's create less than a batch worth of server side records. + let collection = new ServerCollection(); + for (let i = 0; i < APPLY_BATCH_SIZE - 1; i++) { + let id = 'record-no-' + i; + let payload = encryptPayload({id: id, denomination: "Record No. " + id}); + collection.insert(id, payload); + } + + let server = sync_httpd_setup({ + "/1.1/foo/storage/rotary": collection.handler() + }); + + let syncTesting = new SyncTestingInfrastructure(server); + + let meta_global = Service.recordManager.set(engine.metaURL, + new WBORecord(engine.metaURL)); + meta_global.payload.engines = {rotary: {version: engine.version, + syncID: engine.syncID}}; + try { + + // Confirm initial environment + do_check_empty(engine._store.items); + + engine._syncStartup(); + engine._processIncoming(); + + // Records have been applied and the expected failures have failed. + do_check_attribute_count(engine._store.items, APPLY_BATCH_SIZE - 1 - 2); + do_check_eq(engine.toFetch.length, 0); + do_check_eq(engine.previousFailed.length, 2); + do_check_eq(engine.previousFailed[0], "record-no-0"); + do_check_eq(engine.previousFailed[1], "record-no-8"); + + } finally { + cleanAndGo(server); + } +}); + + +add_test(function test_processIncoming_applyIncomingBatchSize_multiple() { + _("Ensure that incoming items are applied according to applyIncomingBatchSize."); + Service.identity.username = "foo"; + + const APPLY_BATCH_SIZE = 10; + + // Engine that applies records in batches. + let engine = makeRotaryEngine(); + engine.applyIncomingBatchSize = APPLY_BATCH_SIZE; + let batchCalls = 0; + engine._store._applyIncomingBatch = engine._store.applyIncomingBatch; + engine._store.applyIncomingBatch = function (records) { + batchCalls += 1; + do_check_eq(records.length, APPLY_BATCH_SIZE); + this._applyIncomingBatch.apply(this, arguments); + }; + + // Let's create three batches worth of server side records. + let collection = new ServerCollection(); + for (let i = 0; i < APPLY_BATCH_SIZE * 3; i++) { + let id = 'record-no-' + i; + let payload = encryptPayload({id: id, denomination: "Record No. " + id}); + collection.insert(id, payload); + } + + let server = sync_httpd_setup({ + "/1.1/foo/storage/rotary": collection.handler() + }); + + let syncTesting = new SyncTestingInfrastructure(server); + + let meta_global = Service.recordManager.set(engine.metaURL, + new WBORecord(engine.metaURL)); + meta_global.payload.engines = {rotary: {version: engine.version, + syncID: engine.syncID}}; + try { + + // Confirm initial environment + do_check_empty(engine._store.items); + + engine._syncStartup(); + engine._processIncoming(); + + // Records have been applied in 3 batches. + do_check_eq(batchCalls, 3); + do_check_attribute_count(engine._store.items, APPLY_BATCH_SIZE * 3); + + } finally { + cleanAndGo(server); + } +}); + + +add_test(function test_processIncoming_notify_count() { + _("Ensure that failed records are reported only once."); + Service.identity.username = "foo"; + + const APPLY_BATCH_SIZE = 5; + const NUMBER_OF_RECORDS = 15; + + // Engine that fails the first record. + let engine = makeRotaryEngine(); + engine.applyIncomingBatchSize = APPLY_BATCH_SIZE; + engine._store._applyIncomingBatch = engine._store.applyIncomingBatch; + engine._store.applyIncomingBatch = function (records) { + engine._store._applyIncomingBatch(records.slice(1)); + return [records[0].id]; + }; + + // Create a batch of server side records. + let collection = new ServerCollection(); + for (var i = 0; i < NUMBER_OF_RECORDS; i++) { + let id = 'record-no-' + i; + let payload = encryptPayload({id: id, denomination: "Record No. " + id}); + collection.insert(id, payload); + } + + let server = sync_httpd_setup({ + "/1.1/foo/storage/rotary": collection.handler() + }); + + let syncTesting = new SyncTestingInfrastructure(server); + + let meta_global = Service.recordManager.set(engine.metaURL, + new WBORecord(engine.metaURL)); + meta_global.payload.engines = {rotary: {version: engine.version, + syncID: engine.syncID}}; + try { + // Confirm initial environment. + do_check_eq(engine.lastSync, 0); + do_check_eq(engine.toFetch.length, 0); + do_check_eq(engine.previousFailed.length, 0); + do_check_empty(engine._store.items); + + let called = 0; + let counts; + function onApplied(count) { + _("Called with " + JSON.stringify(counts)); + counts = count; + called++; + } + Svc.Obs.add("weave:engine:sync:applied", onApplied); + + // Do sync. + engine._syncStartup(); + engine._processIncoming(); + + // Confirm failures. + do_check_attribute_count(engine._store.items, 12); + do_check_eq(engine.previousFailed.length, 3); + do_check_eq(engine.previousFailed[0], "record-no-0"); + do_check_eq(engine.previousFailed[1], "record-no-5"); + do_check_eq(engine.previousFailed[2], "record-no-10"); + + // There are newly failed records and they are reported. + do_check_eq(called, 1); + do_check_eq(counts.failed, 3); + do_check_eq(counts.applied, 15); + do_check_eq(counts.newFailed, 3); + do_check_eq(counts.succeeded, 12); + + // Sync again, 1 of the failed items are the same, the rest didn't fail. + engine._processIncoming(); + + // Confirming removed failures. + do_check_attribute_count(engine._store.items, 14); + do_check_eq(engine.previousFailed.length, 1); + do_check_eq(engine.previousFailed[0], "record-no-0"); + + do_check_eq(called, 2); + do_check_eq(counts.failed, 1); + do_check_eq(counts.applied, 3); + do_check_eq(counts.newFailed, 0); + do_check_eq(counts.succeeded, 2); + + Svc.Obs.remove("weave:engine:sync:applied", onApplied); + } finally { + cleanAndGo(server); + } +}); + + +add_test(function test_processIncoming_previousFailed() { + _("Ensure that failed records are retried."); + Service.identity.username = "foo"; + Svc.Prefs.set("client.type", "mobile"); + + const APPLY_BATCH_SIZE = 4; + const NUMBER_OF_RECORDS = 14; + + // Engine that fails the first 2 records. + let engine = makeRotaryEngine(); + engine.mobileGUIDFetchBatchSize = engine.applyIncomingBatchSize = APPLY_BATCH_SIZE; + engine._store._applyIncomingBatch = engine._store.applyIncomingBatch; + engine._store.applyIncomingBatch = function (records) { + engine._store._applyIncomingBatch(records.slice(2)); + return [records[0].id, records[1].id]; + }; + + // Create a batch of server side records. + let collection = new ServerCollection(); + for (var i = 0; i < NUMBER_OF_RECORDS; i++) { + let id = 'record-no-' + i; + let payload = encryptPayload({id: id, denomination: "Record No. " + i}); + collection.insert(id, payload); + } + + let server = sync_httpd_setup({ + "/1.1/foo/storage/rotary": collection.handler() + }); + + let syncTesting = new SyncTestingInfrastructure(server); + + let meta_global = Service.recordManager.set(engine.metaURL, + new WBORecord(engine.metaURL)); + meta_global.payload.engines = {rotary: {version: engine.version, + syncID: engine.syncID}}; + try { + // Confirm initial environment. + do_check_eq(engine.lastSync, 0); + do_check_eq(engine.toFetch.length, 0); + do_check_eq(engine.previousFailed.length, 0); + do_check_empty(engine._store.items); + + // Initial failed items in previousFailed to be reset. + let previousFailed = [Utils.makeGUID(), Utils.makeGUID(), Utils.makeGUID()]; + engine.previousFailed = previousFailed; + do_check_eq(engine.previousFailed, previousFailed); + + // Do sync. + engine._syncStartup(); + engine._processIncoming(); + + // Expected result: 4 sync batches with 2 failures each => 8 failures + do_check_attribute_count(engine._store.items, 6); + do_check_eq(engine.previousFailed.length, 8); + do_check_eq(engine.previousFailed[0], "record-no-0"); + do_check_eq(engine.previousFailed[1], "record-no-1"); + do_check_eq(engine.previousFailed[2], "record-no-4"); + do_check_eq(engine.previousFailed[3], "record-no-5"); + do_check_eq(engine.previousFailed[4], "record-no-8"); + do_check_eq(engine.previousFailed[5], "record-no-9"); + do_check_eq(engine.previousFailed[6], "record-no-12"); + do_check_eq(engine.previousFailed[7], "record-no-13"); + + // Sync again with the same failed items (records 0, 1, 8, 9). + engine._processIncoming(); + + // A second sync with the same failed items should not add the same items again. + // Items that did not fail a second time should no longer be in previousFailed. + do_check_attribute_count(engine._store.items, 10); + do_check_eq(engine.previousFailed.length, 4); + do_check_eq(engine.previousFailed[0], "record-no-0"); + do_check_eq(engine.previousFailed[1], "record-no-1"); + do_check_eq(engine.previousFailed[2], "record-no-8"); + do_check_eq(engine.previousFailed[3], "record-no-9"); + + // Refetched items that didn't fail the second time are in engine._store.items. + do_check_eq(engine._store.items['record-no-4'], "Record No. 4"); + do_check_eq(engine._store.items['record-no-5'], "Record No. 5"); + do_check_eq(engine._store.items['record-no-12'], "Record No. 12"); + do_check_eq(engine._store.items['record-no-13'], "Record No. 13"); + } finally { + cleanAndGo(server); + } +}); + + +add_test(function test_processIncoming_failed_records() { + _("Ensure that failed records from _reconcile and applyIncomingBatch are refetched."); + Service.identity.username = "foo"; + + // Let's create three and a bit batches worth of server side records. + let collection = new ServerCollection(); + const NUMBER_OF_RECORDS = MOBILE_BATCH_SIZE * 3 + 5; + for (let i = 0; i < NUMBER_OF_RECORDS; i++) { + let id = 'record-no-' + i; + let payload = encryptPayload({id: id, denomination: "Record No. " + id}); + let wbo = new ServerWBO(id, payload); + wbo.modified = Date.now()/1000 + 60 * (i - MOBILE_BATCH_SIZE * 3); + collection.insertWBO(wbo); + } + + // Engine that batches but likes to throw on a couple of records, + // two in each batch: the even ones fail in reconcile, the odd ones + // in applyIncoming. + const BOGUS_RECORDS = ["record-no-" + 42, + "record-no-" + 23, + "record-no-" + (42 + MOBILE_BATCH_SIZE), + "record-no-" + (23 + MOBILE_BATCH_SIZE), + "record-no-" + (42 + MOBILE_BATCH_SIZE * 2), + "record-no-" + (23 + MOBILE_BATCH_SIZE * 2), + "record-no-" + (2 + MOBILE_BATCH_SIZE * 3), + "record-no-" + (1 + MOBILE_BATCH_SIZE * 3)]; + let engine = makeRotaryEngine(); + engine.applyIncomingBatchSize = MOBILE_BATCH_SIZE; + + engine.__reconcile = engine._reconcile; + engine._reconcile = function _reconcile(record) { + if (BOGUS_RECORDS.indexOf(record.id) % 2 == 0) { + throw "I don't like this record! Baaaaaah!"; + } + return this.__reconcile.apply(this, arguments); + }; + engine._store._applyIncoming = engine._store.applyIncoming; + engine._store.applyIncoming = function (record) { + if (BOGUS_RECORDS.indexOf(record.id) % 2 == 1) { + throw "I don't like this record! Baaaaaah!"; + } + return this._applyIncoming.apply(this, arguments); + }; + + // Keep track of requests made of a collection. + let count = 0; + let uris = []; + function recording_handler(collection) { + let h = collection.handler(); + return function(req, res) { + ++count; + uris.push(req.path + "?" + req.queryString); + return h(req, res); + }; + } + let server = sync_httpd_setup({ + "/1.1/foo/storage/rotary": recording_handler(collection) + }); + + let syncTesting = new SyncTestingInfrastructure(server); + + let meta_global = Service.recordManager.set(engine.metaURL, + new WBORecord(engine.metaURL)); + meta_global.payload.engines = {rotary: {version: engine.version, + syncID: engine.syncID}}; + + try { + + // Confirm initial environment + do_check_eq(engine.lastSync, 0); + do_check_eq(engine.toFetch.length, 0); + do_check_eq(engine.previousFailed.length, 0); + do_check_empty(engine._store.items); + + let observerSubject; + let observerData; + Svc.Obs.add("weave:engine:sync:applied", function onApplied(subject, data) { + Svc.Obs.remove("weave:engine:sync:applied", onApplied); + observerSubject = subject; + observerData = data; + }); + + engine._syncStartup(); + engine._processIncoming(); + + // Ensure that all records but the bogus 4 have been applied. + do_check_attribute_count(engine._store.items, + NUMBER_OF_RECORDS - BOGUS_RECORDS.length); + + // Ensure that the bogus records will be fetched again on the next sync. + do_check_eq(engine.previousFailed.length, BOGUS_RECORDS.length); + engine.previousFailed.sort(); + BOGUS_RECORDS.sort(); + for (let i = 0; i < engine.previousFailed.length; i++) { + do_check_eq(engine.previousFailed[i], BOGUS_RECORDS[i]); + } + + // Ensure the observer was notified + do_check_eq(observerData, engine.name); + do_check_eq(observerSubject.failed, BOGUS_RECORDS.length); + do_check_eq(observerSubject.newFailed, BOGUS_RECORDS.length); + + // Testing batching of failed item fetches. + // Try to sync again. Ensure that we split the request into chunks to avoid + // URI length limitations. + function batchDownload(batchSize) { + count = 0; + uris = []; + engine.guidFetchBatchSize = batchSize; + engine._processIncoming(); + _("Tried again. Requests: " + count + "; URIs: " + JSON.stringify(uris)); + return count; + } + + // There are 8 bad records, so this needs 3 fetches. + _("Test batching with ID batch size 3, normal mobile batch size."); + do_check_eq(batchDownload(3), 3); + + // Now see with a more realistic limit. + _("Test batching with sufficient ID batch size."); + do_check_eq(batchDownload(BOGUS_RECORDS.length), 1); + + // If we're on mobile, that limit is used by default. + _("Test batching with tiny mobile batch size."); + Svc.Prefs.set("client.type", "mobile"); + engine.mobileGUIDFetchBatchSize = 2; + do_check_eq(batchDownload(BOGUS_RECORDS.length), 4); + + } finally { + cleanAndGo(server); + } +}); + + +add_task(function *test_processIncoming_decrypt_failed() { + _("Ensure that records failing to decrypt are either replaced or refetched."); + + Service.identity.username = "foo"; + + // Some good and some bogus records. One doesn't contain valid JSON, + // the other will throw during decrypt. + let collection = new ServerCollection(); + collection._wbos.flying = new ServerWBO( + 'flying', encryptPayload({id: 'flying', + denomination: "LNER Class A3 4472"})); + collection._wbos.nojson = new ServerWBO("nojson", "This is invalid JSON"); + collection._wbos.nojson2 = new ServerWBO("nojson2", "This is invalid JSON"); + collection._wbos.scotsman = new ServerWBO( + 'scotsman', encryptPayload({id: 'scotsman', + denomination: "Flying Scotsman"})); + collection._wbos.nodecrypt = new ServerWBO("nodecrypt", "Decrypt this!"); + collection._wbos.nodecrypt2 = new ServerWBO("nodecrypt2", "Decrypt this!"); + + // Patch the fake crypto service to throw on the record above. + Svc.Crypto._decrypt = Svc.Crypto.decrypt; + Svc.Crypto.decrypt = function (ciphertext) { + if (ciphertext == "Decrypt this!") { + throw "Derp! Cipher finalized failed. Im ur crypto destroyin ur recordz."; + } + return this._decrypt.apply(this, arguments); + }; + + // Some broken records also exist locally. + let engine = makeRotaryEngine(); + engine.enabled = true; + engine._store.items = {nojson: "Valid JSON", + nodecrypt: "Valid ciphertext"}; + + let server = sync_httpd_setup({ + "/1.1/foo/storage/rotary": collection.handler() + }); + + let syncTesting = new SyncTestingInfrastructure(server); + + let meta_global = Service.recordManager.set(engine.metaURL, + new WBORecord(engine.metaURL)); + meta_global.payload.engines = {rotary: {version: engine.version, + syncID: engine.syncID}}; + try { + + // Confirm initial state + do_check_eq(engine.toFetch.length, 0); + do_check_eq(engine.previousFailed.length, 0); + + let observerSubject; + let observerData; + Svc.Obs.add("weave:engine:sync:applied", function onApplied(subject, data) { + Svc.Obs.remove("weave:engine:sync:applied", onApplied); + observerSubject = subject; + observerData = data; + }); + + engine.lastSync = collection.wbo("nojson").modified - 1; + let ping = yield sync_engine_and_validate_telem(engine, true); + do_check_eq(ping.engines[0].incoming.applied, 2); + do_check_eq(ping.engines[0].incoming.failed, 4); + do_check_eq(ping.engines[0].incoming.newFailed, 4); + + do_check_eq(engine.previousFailed.length, 4); + do_check_eq(engine.previousFailed[0], "nojson"); + do_check_eq(engine.previousFailed[1], "nojson2"); + do_check_eq(engine.previousFailed[2], "nodecrypt"); + do_check_eq(engine.previousFailed[3], "nodecrypt2"); + + // Ensure the observer was notified + do_check_eq(observerData, engine.name); + do_check_eq(observerSubject.applied, 2); + do_check_eq(observerSubject.failed, 4); + + } finally { + yield promiseClean(server); + } +}); + + +add_test(function test_uploadOutgoing_toEmptyServer() { + _("SyncEngine._uploadOutgoing uploads new records to server"); + + Service.identity.username = "foo"; + let collection = new ServerCollection(); + collection._wbos.flying = new ServerWBO('flying'); + collection._wbos.scotsman = new ServerWBO('scotsman'); + + let server = sync_httpd_setup({ + "/1.1/foo/storage/rotary": collection.handler(), + "/1.1/foo/storage/rotary/flying": collection.wbo("flying").handler(), + "/1.1/foo/storage/rotary/scotsman": collection.wbo("scotsman").handler() + }); + + let syncTesting = new SyncTestingInfrastructure(server); + generateNewKeys(Service.collectionKeys); + + let engine = makeRotaryEngine(); + engine.lastSync = 123; // needs to be non-zero so that tracker is queried + engine._store.items = {flying: "LNER Class A3 4472", + scotsman: "Flying Scotsman"}; + // Mark one of these records as changed + engine._tracker.addChangedID('scotsman', 0); + + let meta_global = Service.recordManager.set(engine.metaURL, + new WBORecord(engine.metaURL)); + meta_global.payload.engines = {rotary: {version: engine.version, + syncID: engine.syncID}}; + + try { + + // Confirm initial environment + do_check_eq(engine.lastSyncLocal, 0); + do_check_eq(collection.payload("flying"), undefined); + do_check_eq(collection.payload("scotsman"), undefined); + + engine._syncStartup(); + engine._uploadOutgoing(); + + // Local timestamp has been set. + do_check_true(engine.lastSyncLocal > 0); + + // Ensure the marked record ('scotsman') has been uploaded and is + // no longer marked. + do_check_eq(collection.payload("flying"), undefined); + do_check_true(!!collection.payload("scotsman")); + do_check_eq(JSON.parse(collection.wbo("scotsman").data.ciphertext).id, + "scotsman"); + do_check_eq(engine._tracker.changedIDs["scotsman"], undefined); + + // The 'flying' record wasn't marked so it wasn't uploaded + do_check_eq(collection.payload("flying"), undefined); + + } finally { + cleanAndGo(server); + } +}); + + +add_task(function *test_uploadOutgoing_failed() { + _("SyncEngine._uploadOutgoing doesn't clear the tracker of objects that failed to upload."); + + Service.identity.username = "foo"; + let collection = new ServerCollection(); + // We only define the "flying" WBO on the server, not the "scotsman" + // and "peppercorn" ones. + collection._wbos.flying = new ServerWBO('flying'); + + let server = sync_httpd_setup({ + "/1.1/foo/storage/rotary": collection.handler() + }); + + let syncTesting = new SyncTestingInfrastructure(server); + + let engine = makeRotaryEngine(); + engine.lastSync = 123; // needs to be non-zero so that tracker is queried + engine._store.items = {flying: "LNER Class A3 4472", + scotsman: "Flying Scotsman", + peppercorn: "Peppercorn Class"}; + // Mark these records as changed + const FLYING_CHANGED = 12345; + const SCOTSMAN_CHANGED = 23456; + const PEPPERCORN_CHANGED = 34567; + engine._tracker.addChangedID('flying', FLYING_CHANGED); + engine._tracker.addChangedID('scotsman', SCOTSMAN_CHANGED); + engine._tracker.addChangedID('peppercorn', PEPPERCORN_CHANGED); + + let meta_global = Service.recordManager.set(engine.metaURL, + new WBORecord(engine.metaURL)); + meta_global.payload.engines = {rotary: {version: engine.version, + syncID: engine.syncID}}; + + try { + + // Confirm initial environment + do_check_eq(engine.lastSyncLocal, 0); + do_check_eq(collection.payload("flying"), undefined); + do_check_eq(engine._tracker.changedIDs['flying'], FLYING_CHANGED); + do_check_eq(engine._tracker.changedIDs['scotsman'], SCOTSMAN_CHANGED); + do_check_eq(engine._tracker.changedIDs['peppercorn'], PEPPERCORN_CHANGED); + + engine.enabled = true; + yield sync_engine_and_validate_telem(engine, true); + + // Local timestamp has been set. + do_check_true(engine.lastSyncLocal > 0); + + // Ensure the 'flying' record has been uploaded and is no longer marked. + do_check_true(!!collection.payload("flying")); + do_check_eq(engine._tracker.changedIDs['flying'], undefined); + + // The 'scotsman' and 'peppercorn' records couldn't be uploaded so + // they weren't cleared from the tracker. + do_check_eq(engine._tracker.changedIDs['scotsman'], SCOTSMAN_CHANGED); + do_check_eq(engine._tracker.changedIDs['peppercorn'], PEPPERCORN_CHANGED); + + } finally { + yield promiseClean(server); + } +}); + +/* A couple of "functional" tests to ensure we split records into appropriate + POST requests. More comprehensive unit-tests for this "batching" are in + test_postqueue.js. +*/ +add_test(function test_uploadOutgoing_MAX_UPLOAD_RECORDS() { + _("SyncEngine._uploadOutgoing uploads in batches of MAX_UPLOAD_RECORDS"); + + Service.identity.username = "foo"; + let collection = new ServerCollection(); + + // Let's count how many times the client posts to the server + var noOfUploads = 0; + collection.post = (function(orig) { + return function(data, request) { + // This test doesn't arrange for batch semantics - so we expect the + // first request to come in with batch=true and the others to have no + // batch related headers at all (as the first response did not provide + // a batch ID) + if (noOfUploads == 0) { + do_check_eq(request.queryString, "batch=true"); + } else { + do_check_eq(request.queryString, ""); + } + noOfUploads++; + return orig.call(this, data, request); + }; + }(collection.post)); + + // Create a bunch of records (and server side handlers) + let engine = makeRotaryEngine(); + for (var i = 0; i < 234; i++) { + let id = 'record-no-' + i; + engine._store.items[id] = "Record No. " + i; + engine._tracker.addChangedID(id, 0); + collection.insert(id); + } + + let meta_global = Service.recordManager.set(engine.metaURL, + new WBORecord(engine.metaURL)); + meta_global.payload.engines = {rotary: {version: engine.version, + syncID: engine.syncID}}; + + let server = sync_httpd_setup({ + "/1.1/foo/storage/rotary": collection.handler() + }); + + let syncTesting = new SyncTestingInfrastructure(server); + + try { + + // Confirm initial environment. + do_check_eq(noOfUploads, 0); + + engine._syncStartup(); + engine._uploadOutgoing(); + + // Ensure all records have been uploaded. + for (i = 0; i < 234; i++) { + do_check_true(!!collection.payload('record-no-' + i)); + } + + // Ensure that the uploads were performed in batches of MAX_UPLOAD_RECORDS. + do_check_eq(noOfUploads, Math.ceil(234/MAX_UPLOAD_RECORDS)); + + } finally { + cleanAndGo(server); + } +}); + +add_test(function test_uploadOutgoing_largeRecords() { + _("SyncEngine._uploadOutgoing throws on records larger than MAX_UPLOAD_BYTES"); + + Service.identity.username = "foo"; + let collection = new ServerCollection(); + + let engine = makeRotaryEngine(); + engine.allowSkippedRecord = false; + engine._store.items["large-item"] = "Y".repeat(MAX_UPLOAD_BYTES*2); + engine._tracker.addChangedID("large-item", 0); + collection.insert("large-item"); + + + let meta_global = Service.recordManager.set(engine.metaURL, + new WBORecord(engine.metaURL)); + meta_global.payload.engines = {rotary: {version: engine.version, + syncID: engine.syncID}}; + + let server = sync_httpd_setup({ + "/1.1/foo/storage/rotary": collection.handler() + }); + + let syncTesting = new SyncTestingInfrastructure(server); + + try { + engine._syncStartup(); + let error = null; + try { + engine._uploadOutgoing(); + } catch (e) { + error = e; + } + ok(!!error); + } finally { + cleanAndGo(server); + } +}); + + +add_test(function test_syncFinish_noDelete() { + _("SyncEngine._syncFinish resets tracker's score"); + + let server = httpd_setup({}); + + let syncTesting = new SyncTestingInfrastructure(server); + let engine = makeRotaryEngine(); + engine._delete = {}; // Nothing to delete + engine._tracker.score = 100; + + // _syncFinish() will reset the engine's score. + engine._syncFinish(); + do_check_eq(engine.score, 0); + server.stop(run_next_test); +}); + + +add_test(function test_syncFinish_deleteByIds() { + _("SyncEngine._syncFinish deletes server records slated for deletion (list of record IDs)."); + + Service.identity.username = "foo"; + let collection = new ServerCollection(); + collection._wbos.flying = new ServerWBO( + 'flying', encryptPayload({id: 'flying', + denomination: "LNER Class A3 4472"})); + collection._wbos.scotsman = new ServerWBO( + 'scotsman', encryptPayload({id: 'scotsman', + denomination: "Flying Scotsman"})); + collection._wbos.rekolok = new ServerWBO( + 'rekolok', encryptPayload({id: 'rekolok', + denomination: "Rekonstruktionslokomotive"})); + + let server = httpd_setup({ + "/1.1/foo/storage/rotary": collection.handler() + }); + let syncTesting = new SyncTestingInfrastructure(server); + + let engine = makeRotaryEngine(); + try { + engine._delete = {ids: ['flying', 'rekolok']}; + engine._syncFinish(); + + // The 'flying' and 'rekolok' records were deleted while the + // 'scotsman' one wasn't. + do_check_eq(collection.payload("flying"), undefined); + do_check_true(!!collection.payload("scotsman")); + do_check_eq(collection.payload("rekolok"), undefined); + + // The deletion todo list has been reset. + do_check_eq(engine._delete.ids, undefined); + + } finally { + cleanAndGo(server); + } +}); + + +add_test(function test_syncFinish_deleteLotsInBatches() { + _("SyncEngine._syncFinish deletes server records in batches of 100 (list of record IDs)."); + + Service.identity.username = "foo"; + let collection = new ServerCollection(); + + // Let's count how many times the client does a DELETE request to the server + var noOfUploads = 0; + collection.delete = (function(orig) { + return function() { + noOfUploads++; + return orig.apply(this, arguments); + }; + }(collection.delete)); + + // Create a bunch of records on the server + let now = Date.now(); + for (var i = 0; i < 234; i++) { + let id = 'record-no-' + i; + let payload = encryptPayload({id: id, denomination: "Record No. " + i}); + let wbo = new ServerWBO(id, payload); + wbo.modified = now / 1000 - 60 * (i + 110); + collection.insertWBO(wbo); + } + + let server = httpd_setup({ + "/1.1/foo/storage/rotary": collection.handler() + }); + + let syncTesting = new SyncTestingInfrastructure(server); + + let engine = makeRotaryEngine(); + try { + + // Confirm initial environment + do_check_eq(noOfUploads, 0); + + // Declare what we want to have deleted: all records no. 100 and + // up and all records that are less than 200 mins old (which are + // records 0 thru 90). + engine._delete = {ids: [], + newer: now / 1000 - 60 * 200.5}; + for (i = 100; i < 234; i++) { + engine._delete.ids.push('record-no-' + i); + } + + engine._syncFinish(); + + // Ensure that the appropriate server data has been wiped while + // preserving records 90 thru 200. + for (i = 0; i < 234; i++) { + let id = 'record-no-' + i; + if (i <= 90 || i >= 100) { + do_check_eq(collection.payload(id), undefined); + } else { + do_check_true(!!collection.payload(id)); + } + } + + // The deletion was done in batches + do_check_eq(noOfUploads, 2 + 1); + + // The deletion todo list has been reset. + do_check_eq(engine._delete.ids, undefined); + + } finally { + cleanAndGo(server); + } +}); + + +add_task(function *test_sync_partialUpload() { + _("SyncEngine.sync() keeps changedIDs that couldn't be uploaded."); + + Service.identity.username = "foo"; + + let collection = new ServerCollection(); + let server = sync_httpd_setup({ + "/1.1/foo/storage/rotary": collection.handler() + }); + let syncTesting = new SyncTestingInfrastructure(server); + generateNewKeys(Service.collectionKeys); + + let engine = makeRotaryEngine(); + engine.lastSync = 123; // needs to be non-zero so that tracker is queried + engine.lastSyncLocal = 456; + + // Let the third upload fail completely + var noOfUploads = 0; + collection.post = (function(orig) { + return function() { + if (noOfUploads == 2) + throw "FAIL!"; + noOfUploads++; + return orig.apply(this, arguments); + }; + }(collection.post)); + + // Create a bunch of records (and server side handlers) + for (let i = 0; i < 234; i++) { + let id = 'record-no-' + i; + engine._store.items[id] = "Record No. " + i; + engine._tracker.addChangedID(id, i); + // Let two items in the first upload batch fail. + if ((i != 23) && (i != 42)) { + collection.insert(id); + } + } + + let meta_global = Service.recordManager.set(engine.metaURL, + new WBORecord(engine.metaURL)); + meta_global.payload.engines = {rotary: {version: engine.version, + syncID: engine.syncID}}; + + try { + + engine.enabled = true; + let error; + try { + yield sync_engine_and_validate_telem(engine, true); + } catch (ex) { + error = ex; + } + + ok(!!error); + + // The timestamp has been updated. + do_check_true(engine.lastSyncLocal > 456); + + for (let i = 0; i < 234; i++) { + let id = 'record-no-' + i; + // Ensure failed records are back in the tracker: + // * records no. 23 and 42 were rejected by the server, + // * records no. 200 and higher couldn't be uploaded because we failed + // hard on the 3rd upload. + if ((i == 23) || (i == 42) || (i >= 200)) + do_check_eq(engine._tracker.changedIDs[id], i); + else + do_check_false(id in engine._tracker.changedIDs); + } + + } finally { + yield promiseClean(server); + } +}); + +add_test(function test_canDecrypt_noCryptoKeys() { + _("SyncEngine.canDecrypt returns false if the engine fails to decrypt items on the server, e.g. due to a missing crypto key collection."); + Service.identity.username = "foo"; + + // Wipe collection keys so we can test the desired scenario. + Service.collectionKeys.clear(); + + let collection = new ServerCollection(); + collection._wbos.flying = new ServerWBO( + 'flying', encryptPayload({id: 'flying', + denomination: "LNER Class A3 4472"})); + + let server = sync_httpd_setup({ + "/1.1/foo/storage/rotary": collection.handler() + }); + + let syncTesting = new SyncTestingInfrastructure(server); + let engine = makeRotaryEngine(); + try { + + do_check_false(engine.canDecrypt()); + + } finally { + cleanAndGo(server); + } +}); + +add_test(function test_canDecrypt_true() { + _("SyncEngine.canDecrypt returns true if the engine can decrypt the items on the server."); + Service.identity.username = "foo"; + + generateNewKeys(Service.collectionKeys); + + let collection = new ServerCollection(); + collection._wbos.flying = new ServerWBO( + 'flying', encryptPayload({id: 'flying', + denomination: "LNER Class A3 4472"})); + + let server = sync_httpd_setup({ + "/1.1/foo/storage/rotary": collection.handler() + }); + + let syncTesting = new SyncTestingInfrastructure(server); + let engine = makeRotaryEngine(); + try { + + do_check_true(engine.canDecrypt()); + + } finally { + cleanAndGo(server); + } + +}); + +add_test(function test_syncapplied_observer() { + Service.identity.username = "foo"; + + const NUMBER_OF_RECORDS = 10; + + let engine = makeRotaryEngine(); + + // Create a batch of server side records. + let collection = new ServerCollection(); + for (var i = 0; i < NUMBER_OF_RECORDS; i++) { + let id = 'record-no-' + i; + let payload = encryptPayload({id: id, denomination: "Record No. " + id}); + collection.insert(id, payload); + } + + let server = httpd_setup({ + "/1.1/foo/storage/rotary": collection.handler() + }); + + let syncTesting = new SyncTestingInfrastructure(server); + + let meta_global = Service.recordManager.set(engine.metaURL, + new WBORecord(engine.metaURL)); + meta_global.payload.engines = {rotary: {version: engine.version, + syncID: engine.syncID}}; + + let numApplyCalls = 0; + let engine_name; + let count; + function onApplied(subject, data) { + numApplyCalls++; + engine_name = data; + count = subject; + } + + Svc.Obs.add("weave:engine:sync:applied", onApplied); + + try { + Service.scheduler.hasIncomingItems = false; + + // Do sync. + engine._syncStartup(); + engine._processIncoming(); + + do_check_attribute_count(engine._store.items, 10); + + do_check_eq(numApplyCalls, 1); + do_check_eq(engine_name, "rotary"); + do_check_eq(count.applied, 10); + + do_check_true(Service.scheduler.hasIncomingItems); + } finally { + cleanAndGo(server); + Service.scheduler.hasIncomingItems = false; + Svc.Obs.remove("weave:engine:sync:applied", onApplied); + } +}); diff --git a/services/sync/tests/unit/test_syncscheduler.js b/services/sync/tests/unit/test_syncscheduler.js new file mode 100644 index 0000000000..b066eae827 --- /dev/null +++ b/services/sync/tests/unit/test_syncscheduler.js @@ -0,0 +1,1033 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +Cu.import("resource://services-sync/constants.js"); +Cu.import("resource://services-sync/engines.js"); +Cu.import("resource://services-sync/engines/clients.js"); +Cu.import("resource://services-sync/policies.js"); +Cu.import("resource://services-sync/record.js"); +Cu.import("resource://services-sync/service.js"); +Cu.import("resource://services-sync/status.js"); +Cu.import("resource://services-sync/util.js"); +Cu.import("resource://testing-common/services/sync/utils.js"); + +Service.engineManager.clear(); + +function CatapultEngine() { + SyncEngine.call(this, "Catapult", Service); +} +CatapultEngine.prototype = { + __proto__: SyncEngine.prototype, + exception: null, // tests fill this in + _sync: function _sync() { + throw this.exception; + } +}; + +Service.engineManager.register(CatapultEngine); + +var scheduler = new SyncScheduler(Service); +var clientsEngine = Service.clientsEngine; + +// Don't remove stale clients when syncing. This is a test-only workaround +// that lets us add clients directly to the store, without losing them on +// the next sync. +clientsEngine._removeRemoteClient = id => {}; + +function sync_httpd_setup() { + let global = new ServerWBO("global", { + syncID: Service.syncID, + storageVersion: STORAGE_VERSION, + engines: {clients: {version: clientsEngine.version, + syncID: clientsEngine.syncID}} + }); + let clientsColl = new ServerCollection({}, true); + + // Tracking info/collections. + let collectionsHelper = track_collections_helper(); + let upd = collectionsHelper.with_updated_collection; + + return httpd_setup({ + "/1.1/johndoe/storage/meta/global": upd("meta", global.handler()), + "/1.1/johndoe/info/collections": collectionsHelper.handler, + "/1.1/johndoe/storage/crypto/keys": + upd("crypto", (new ServerWBO("keys")).handler()), + "/1.1/johndoe/storage/clients": upd("clients", clientsColl.handler()), + "/user/1.0/johndoe/node/weave": httpd_handler(200, "OK", "null") + }); +} + +function setUp(server) { + let deferred = Promise.defer(); + configureIdentity({username: "johndoe"}).then(() => { + Service.clusterURL = server.baseURI + "/"; + + generateNewKeys(Service.collectionKeys); + let serverKeys = Service.collectionKeys.asWBO("crypto", "keys"); + serverKeys.encrypt(Service.identity.syncKeyBundle); + let result = serverKeys.upload(Service.resource(Service.cryptoKeysURL)).success; + deferred.resolve(result); + }); + return deferred.promise; +} + +function cleanUpAndGo(server) { + let deferred = Promise.defer(); + Utils.nextTick(function () { + clientsEngine._store.wipe(); + Service.startOver(); + if (server) { + server.stop(deferred.resolve); + } else { + deferred.resolve(); + } + }); + return deferred.promise; +} + +function run_test() { + initTestLogging("Trace"); + + Log.repository.getLogger("Sync.Service").level = Log.Level.Trace; + Log.repository.getLogger("Sync.scheduler").level = Log.Level.Trace; + validate_all_future_pings(); + + // The scheduler checks Weave.fxaEnabled to determine whether to use + // FxA defaults or legacy defaults. As .fxaEnabled checks the username, we + // set a username here then reset the default to ensure they are used. + ensureLegacyIdentityManager(); + setBasicCredentials("johndoe"); + scheduler.setDefaults(); + + run_next_test(); +} + +add_test(function test_prefAttributes() { + _("Test various attributes corresponding to preferences."); + + const INTERVAL = 42 * 60 * 1000; // 42 minutes + const THRESHOLD = 3142; + const SCORE = 2718; + const TIMESTAMP1 = 1275493471649; + + _("The 'nextSync' attribute stores a millisecond timestamp rounded down to the nearest second."); + do_check_eq(scheduler.nextSync, 0); + scheduler.nextSync = TIMESTAMP1; + do_check_eq(scheduler.nextSync, Math.floor(TIMESTAMP1 / 1000) * 1000); + + _("'syncInterval' defaults to singleDeviceInterval."); + do_check_eq(Svc.Prefs.get('syncInterval'), undefined); + do_check_eq(scheduler.syncInterval, scheduler.singleDeviceInterval); + + _("'syncInterval' corresponds to a preference setting."); + scheduler.syncInterval = INTERVAL; + do_check_eq(scheduler.syncInterval, INTERVAL); + do_check_eq(Svc.Prefs.get('syncInterval'), INTERVAL); + + _("'syncThreshold' corresponds to preference, defaults to SINGLE_USER_THRESHOLD"); + do_check_eq(Svc.Prefs.get('syncThreshold'), undefined); + do_check_eq(scheduler.syncThreshold, SINGLE_USER_THRESHOLD); + scheduler.syncThreshold = THRESHOLD; + do_check_eq(scheduler.syncThreshold, THRESHOLD); + + _("'globalScore' corresponds to preference, defaults to zero."); + do_check_eq(Svc.Prefs.get('globalScore'), 0); + do_check_eq(scheduler.globalScore, 0); + scheduler.globalScore = SCORE; + do_check_eq(scheduler.globalScore, SCORE); + do_check_eq(Svc.Prefs.get('globalScore'), SCORE); + + _("Intervals correspond to default preferences."); + do_check_eq(scheduler.singleDeviceInterval, + Svc.Prefs.get("scheduler.sync11.singleDeviceInterval") * 1000); + do_check_eq(scheduler.idleInterval, + Svc.Prefs.get("scheduler.idleInterval") * 1000); + do_check_eq(scheduler.activeInterval, + Svc.Prefs.get("scheduler.activeInterval") * 1000); + do_check_eq(scheduler.immediateInterval, + Svc.Prefs.get("scheduler.immediateInterval") * 1000); + + _("Custom values for prefs will take effect after a restart."); + Svc.Prefs.set("scheduler.sync11.singleDeviceInterval", 420); + Svc.Prefs.set("scheduler.idleInterval", 230); + Svc.Prefs.set("scheduler.activeInterval", 180); + Svc.Prefs.set("scheduler.immediateInterval", 31415); + scheduler.setDefaults(); + do_check_eq(scheduler.idleInterval, 230000); + do_check_eq(scheduler.singleDeviceInterval, 420000); + do_check_eq(scheduler.activeInterval, 180000); + do_check_eq(scheduler.immediateInterval, 31415000); + + _("Custom values for interval prefs can't be less than 60 seconds."); + Svc.Prefs.set("scheduler.sync11.singleDeviceInterval", 42); + Svc.Prefs.set("scheduler.idleInterval", 50); + Svc.Prefs.set("scheduler.activeInterval", 50); + Svc.Prefs.set("scheduler.immediateInterval", 10); + scheduler.setDefaults(); + do_check_eq(scheduler.idleInterval, 60000); + do_check_eq(scheduler.singleDeviceInterval, 60000); + do_check_eq(scheduler.activeInterval, 60000); + do_check_eq(scheduler.immediateInterval, 60000); + + Svc.Prefs.resetBranch(""); + scheduler.setDefaults(); + run_next_test(); +}); + +add_identity_test(this, function* test_updateClientMode() { + _("Test updateClientMode adjusts scheduling attributes based on # of clients appropriately"); + do_check_eq(scheduler.syncThreshold, SINGLE_USER_THRESHOLD); + do_check_eq(scheduler.syncInterval, scheduler.singleDeviceInterval); + do_check_false(scheduler.numClients > 1); + do_check_false(scheduler.idle); + + // Trigger a change in interval & threshold by adding a client. + clientsEngine._store.create({id: "foo", cleartext: "bar"}); + scheduler.updateClientMode(); + + do_check_eq(scheduler.syncThreshold, MULTI_DEVICE_THRESHOLD); + do_check_eq(scheduler.syncInterval, scheduler.activeInterval); + do_check_true(scheduler.numClients > 1); + do_check_false(scheduler.idle); + + // Resets the number of clients to 0. + clientsEngine.resetClient(); + scheduler.updateClientMode(); + + // Goes back to single user if # clients is 1. + do_check_eq(scheduler.numClients, 1); + do_check_eq(scheduler.syncThreshold, SINGLE_USER_THRESHOLD); + do_check_eq(scheduler.syncInterval, scheduler.singleDeviceInterval); + do_check_false(scheduler.numClients > 1); + do_check_false(scheduler.idle); + + yield cleanUpAndGo(); +}); + +add_identity_test(this, function* test_masterpassword_locked_retry_interval() { + _("Test Status.login = MASTER_PASSWORD_LOCKED results in reschedule at MASTER_PASSWORD interval"); + let loginFailed = false; + Svc.Obs.add("weave:service:login:error", function onLoginError() { + Svc.Obs.remove("weave:service:login:error", onLoginError); + loginFailed = true; + }); + + let rescheduleInterval = false; + + let oldScheduleAtInterval = SyncScheduler.prototype.scheduleAtInterval; + SyncScheduler.prototype.scheduleAtInterval = function (interval) { + rescheduleInterval = true; + do_check_eq(interval, MASTER_PASSWORD_LOCKED_RETRY_INTERVAL); + }; + + let oldVerifyLogin = Service.verifyLogin; + Service.verifyLogin = function () { + Status.login = MASTER_PASSWORD_LOCKED; + return false; + }; + + let server = sync_httpd_setup(); + yield setUp(server); + + Service.sync(); + + do_check_true(loginFailed); + do_check_eq(Status.login, MASTER_PASSWORD_LOCKED); + do_check_true(rescheduleInterval); + + Service.verifyLogin = oldVerifyLogin; + SyncScheduler.prototype.scheduleAtInterval = oldScheduleAtInterval; + + yield cleanUpAndGo(server); +}); + +add_identity_test(this, function* test_calculateBackoff() { + do_check_eq(Status.backoffInterval, 0); + + // Test no interval larger than the maximum backoff is used if + // Status.backoffInterval is smaller. + Status.backoffInterval = 5; + let backoffInterval = Utils.calculateBackoff(50, MAXIMUM_BACKOFF_INTERVAL, + Status.backoffInterval); + + do_check_eq(backoffInterval, MAXIMUM_BACKOFF_INTERVAL); + + // Test Status.backoffInterval is used if it is + // larger than MAXIMUM_BACKOFF_INTERVAL. + Status.backoffInterval = MAXIMUM_BACKOFF_INTERVAL + 10; + backoffInterval = Utils.calculateBackoff(50, MAXIMUM_BACKOFF_INTERVAL, + Status.backoffInterval); + + do_check_eq(backoffInterval, MAXIMUM_BACKOFF_INTERVAL + 10); + + yield cleanUpAndGo(); +}); + +add_identity_test(this, function* test_scheduleNextSync_nowOrPast() { + let deferred = Promise.defer(); + Svc.Obs.add("weave:service:sync:finish", function onSyncFinish() { + Svc.Obs.remove("weave:service:sync:finish", onSyncFinish); + cleanUpAndGo(server).then(deferred.resolve); + }); + + let server = sync_httpd_setup(); + yield setUp(server); + + // We're late for a sync... + scheduler.scheduleNextSync(-1); + yield deferred.promise; +}); + +add_identity_test(this, function* test_scheduleNextSync_future_noBackoff() { + _("scheduleNextSync() uses the current syncInterval if no interval is provided."); + // Test backoffInterval is 0 as expected. + do_check_eq(Status.backoffInterval, 0); + + _("Test setting sync interval when nextSync == 0"); + scheduler.nextSync = 0; + scheduler.scheduleNextSync(); + + // nextSync - Date.now() might be smaller than expectedInterval + // since some time has passed since we called scheduleNextSync(). + do_check_true(scheduler.nextSync - Date.now() + <= scheduler.syncInterval); + do_check_eq(scheduler.syncTimer.delay, scheduler.syncInterval); + + _("Test setting sync interval when nextSync != 0"); + scheduler.nextSync = Date.now() + scheduler.singleDeviceInterval; + scheduler.scheduleNextSync(); + + // nextSync - Date.now() might be smaller than expectedInterval + // since some time has passed since we called scheduleNextSync(). + do_check_true(scheduler.nextSync - Date.now() + <= scheduler.syncInterval); + do_check_true(scheduler.syncTimer.delay <= scheduler.syncInterval); + + _("Scheduling requests for intervals larger than the current one will be ignored."); + // Request a sync at a longer interval. The sync that's already scheduled + // for sooner takes precedence. + let nextSync = scheduler.nextSync; + let timerDelay = scheduler.syncTimer.delay; + let requestedInterval = scheduler.syncInterval * 10; + scheduler.scheduleNextSync(requestedInterval); + do_check_eq(scheduler.nextSync, nextSync); + do_check_eq(scheduler.syncTimer.delay, timerDelay); + + // We can schedule anything we want if there isn't a sync scheduled. + scheduler.nextSync = 0; + scheduler.scheduleNextSync(requestedInterval); + do_check_true(scheduler.nextSync <= Date.now() + requestedInterval); + do_check_eq(scheduler.syncTimer.delay, requestedInterval); + + // Request a sync at the smallest possible interval (0 triggers now). + scheduler.scheduleNextSync(1); + do_check_true(scheduler.nextSync <= Date.now() + 1); + do_check_eq(scheduler.syncTimer.delay, 1); + + yield cleanUpAndGo(); +}); + +add_identity_test(this, function* test_scheduleNextSync_future_backoff() { + _("scheduleNextSync() will honour backoff in all scheduling requests."); + // Let's take a backoff interval that's bigger than the default sync interval. + const BACKOFF = 7337; + Status.backoffInterval = scheduler.syncInterval + BACKOFF; + + _("Test setting sync interval when nextSync == 0"); + scheduler.nextSync = 0; + scheduler.scheduleNextSync(); + + // nextSync - Date.now() might be smaller than expectedInterval + // since some time has passed since we called scheduleNextSync(). + do_check_true(scheduler.nextSync - Date.now() + <= Status.backoffInterval); + do_check_eq(scheduler.syncTimer.delay, Status.backoffInterval); + + _("Test setting sync interval when nextSync != 0"); + scheduler.nextSync = Date.now() + scheduler.singleDeviceInterval; + scheduler.scheduleNextSync(); + + // nextSync - Date.now() might be smaller than expectedInterval + // since some time has passed since we called scheduleNextSync(). + do_check_true(scheduler.nextSync - Date.now() + <= Status.backoffInterval); + do_check_true(scheduler.syncTimer.delay <= Status.backoffInterval); + + // Request a sync at a longer interval. The sync that's already scheduled + // for sooner takes precedence. + let nextSync = scheduler.nextSync; + let timerDelay = scheduler.syncTimer.delay; + let requestedInterval = scheduler.syncInterval * 10; + do_check_true(requestedInterval > Status.backoffInterval); + scheduler.scheduleNextSync(requestedInterval); + do_check_eq(scheduler.nextSync, nextSync); + do_check_eq(scheduler.syncTimer.delay, timerDelay); + + // We can schedule anything we want if there isn't a sync scheduled. + scheduler.nextSync = 0; + scheduler.scheduleNextSync(requestedInterval); + do_check_true(scheduler.nextSync <= Date.now() + requestedInterval); + do_check_eq(scheduler.syncTimer.delay, requestedInterval); + + // Request a sync at the smallest possible interval (0 triggers now). + scheduler.scheduleNextSync(1); + do_check_true(scheduler.nextSync <= Date.now() + Status.backoffInterval); + do_check_eq(scheduler.syncTimer.delay, Status.backoffInterval); + + yield cleanUpAndGo(); +}); + +add_identity_test(this, function* test_handleSyncError() { + let server = sync_httpd_setup(); + yield setUp(server); + + // Force sync to fail. + Svc.Prefs.set("firstSync", "notReady"); + + _("Ensure expected initial environment."); + do_check_eq(scheduler._syncErrors, 0); + do_check_false(Status.enforceBackoff); + do_check_eq(scheduler.syncInterval, scheduler.singleDeviceInterval); + do_check_eq(Status.backoffInterval, 0); + + // Trigger sync with an error several times & observe + // functionality of handleSyncError() + _("Test first error calls scheduleNextSync on default interval"); + Service.sync(); + do_check_true(scheduler.nextSync <= Date.now() + scheduler.singleDeviceInterval); + do_check_eq(scheduler.syncTimer.delay, scheduler.singleDeviceInterval); + do_check_eq(scheduler._syncErrors, 1); + do_check_false(Status.enforceBackoff); + scheduler.syncTimer.clear(); + + _("Test second error still calls scheduleNextSync on default interval"); + Service.sync(); + do_check_true(scheduler.nextSync <= Date.now() + scheduler.singleDeviceInterval); + do_check_eq(scheduler.syncTimer.delay, scheduler.singleDeviceInterval); + do_check_eq(scheduler._syncErrors, 2); + do_check_false(Status.enforceBackoff); + scheduler.syncTimer.clear(); + + _("Test third error sets Status.enforceBackoff and calls scheduleAtInterval"); + Service.sync(); + let maxInterval = scheduler._syncErrors * (2 * MINIMUM_BACKOFF_INTERVAL); + do_check_eq(Status.backoffInterval, 0); + do_check_true(scheduler.nextSync <= (Date.now() + maxInterval)); + do_check_true(scheduler.syncTimer.delay <= maxInterval); + do_check_eq(scheduler._syncErrors, 3); + do_check_true(Status.enforceBackoff); + + // Status.enforceBackoff is false but there are still errors. + Status.resetBackoff(); + do_check_false(Status.enforceBackoff); + do_check_eq(scheduler._syncErrors, 3); + scheduler.syncTimer.clear(); + + _("Test fourth error still calls scheduleAtInterval even if enforceBackoff was reset"); + Service.sync(); + maxInterval = scheduler._syncErrors * (2 * MINIMUM_BACKOFF_INTERVAL); + do_check_true(scheduler.nextSync <= Date.now() + maxInterval); + do_check_true(scheduler.syncTimer.delay <= maxInterval); + do_check_eq(scheduler._syncErrors, 4); + do_check_true(Status.enforceBackoff); + scheduler.syncTimer.clear(); + + _("Arrange for a successful sync to reset the scheduler error count"); + let deferred = Promise.defer(); + Svc.Obs.add("weave:service:sync:finish", function onSyncFinish() { + Svc.Obs.remove("weave:service:sync:finish", onSyncFinish); + cleanUpAndGo(server).then(deferred.resolve); + }); + Svc.Prefs.set("firstSync", "wipeRemote"); + scheduler.scheduleNextSync(-1); + yield deferred.promise; +}); + +add_identity_test(this, function* test_client_sync_finish_updateClientMode() { + let server = sync_httpd_setup(); + yield setUp(server); + + // Confirm defaults. + do_check_eq(scheduler.syncThreshold, SINGLE_USER_THRESHOLD); + do_check_eq(scheduler.syncInterval, scheduler.singleDeviceInterval); + do_check_false(scheduler.idle); + + // Trigger a change in interval & threshold by adding a client. + clientsEngine._store.create({id: "foo", cleartext: "bar"}); + do_check_false(scheduler.numClients > 1); + scheduler.updateClientMode(); + Service.sync(); + + do_check_eq(scheduler.syncThreshold, MULTI_DEVICE_THRESHOLD); + do_check_eq(scheduler.syncInterval, scheduler.activeInterval); + do_check_true(scheduler.numClients > 1); + do_check_false(scheduler.idle); + + // Resets the number of clients to 0. + clientsEngine.resetClient(); + Service.sync(); + + // Goes back to single user if # clients is 1. + do_check_eq(scheduler.numClients, 1); + do_check_eq(scheduler.syncThreshold, SINGLE_USER_THRESHOLD); + do_check_eq(scheduler.syncInterval, scheduler.singleDeviceInterval); + do_check_false(scheduler.numClients > 1); + do_check_false(scheduler.idle); + + yield cleanUpAndGo(server); +}); + +add_identity_test(this, function* test_autoconnect_nextSync_past() { + let deferred = Promise.defer(); + // nextSync will be 0 by default, so it's way in the past. + + Svc.Obs.add("weave:service:sync:finish", function onSyncFinish() { + Svc.Obs.remove("weave:service:sync:finish", onSyncFinish); + cleanUpAndGo(server).then(deferred.resolve); + }); + + let server = sync_httpd_setup(); + yield setUp(server); + + scheduler.delayedAutoConnect(0); + yield deferred.promise; +}); + +add_identity_test(this, function* test_autoconnect_nextSync_future() { + let deferred = Promise.defer(); + let previousSync = Date.now() + scheduler.syncInterval / 2; + scheduler.nextSync = previousSync; + // nextSync rounds to the nearest second. + let expectedSync = scheduler.nextSync; + let expectedInterval = expectedSync - Date.now() - 1000; + + // Ensure we don't actually try to sync (or log in for that matter). + function onLoginStart() { + do_throw("Should not get here!"); + } + Svc.Obs.add("weave:service:login:start", onLoginStart); + + waitForZeroTimer(function () { + do_check_eq(scheduler.nextSync, expectedSync); + do_check_true(scheduler.syncTimer.delay >= expectedInterval); + + Svc.Obs.remove("weave:service:login:start", onLoginStart); + cleanUpAndGo().then(deferred.resolve); + }); + + yield configureIdentity({username: "johndoe"}); + scheduler.delayedAutoConnect(0); + yield deferred.promise; +}); + +// XXX - this test can't be run with the browserid identity as it relies +// on the syncKey getter behaving in a certain way... +add_task(function* test_autoconnect_mp_locked() { + let server = sync_httpd_setup(); + yield setUp(server); + + // Pretend user did not unlock master password. + let origLocked = Utils.mpLocked; + Utils.mpLocked = () => true; + + let origGetter = Service.identity.__lookupGetter__("syncKey"); + let origSetter = Service.identity.__lookupSetter__("syncKey"); + delete Service.identity.syncKey; + Service.identity.__defineGetter__("syncKey", function() { + _("Faking Master Password entry cancelation."); + throw "User canceled Master Password entry"; + }); + + let deferred = Promise.defer(); + // A locked master password will still trigger a sync, but then we'll hit + // MASTER_PASSWORD_LOCKED and hence MASTER_PASSWORD_LOCKED_RETRY_INTERVAL. + Svc.Obs.add("weave:service:login:error", function onLoginError() { + Svc.Obs.remove("weave:service:login:error", onLoginError); + Utils.nextTick(function aLittleBitAfterLoginError() { + do_check_eq(Status.login, MASTER_PASSWORD_LOCKED); + + Utils.mpLocked = origLocked; + delete Service.identity.syncKey; + Service.identity.__defineGetter__("syncKey", origGetter); + Service.identity.__defineSetter__("syncKey", origSetter); + + cleanUpAndGo(server).then(deferred.resolve); + }); + }); + + scheduler.delayedAutoConnect(0); + yield deferred.promise; +}); + +add_identity_test(this, function* test_no_autoconnect_during_wizard() { + let server = sync_httpd_setup(); + yield setUp(server); + + // Simulate the Sync setup wizard. + Svc.Prefs.set("firstSync", "notReady"); + + // Ensure we don't actually try to sync (or log in for that matter). + function onLoginStart() { + do_throw("Should not get here!"); + } + Svc.Obs.add("weave:service:login:start", onLoginStart); + + let deferred = Promise.defer(); + waitForZeroTimer(function () { + Svc.Obs.remove("weave:service:login:start", onLoginStart); + cleanUpAndGo(server).then(deferred.resolve); + }); + + scheduler.delayedAutoConnect(0); + yield deferred.promise; +}); + +add_identity_test(this, function* test_no_autoconnect_status_not_ok() { + let server = sync_httpd_setup(); + + // Ensure we don't actually try to sync (or log in for that matter). + function onLoginStart() { + do_throw("Should not get here!"); + } + Svc.Obs.add("weave:service:login:start", onLoginStart); + + let deferred = Promise.defer(); + waitForZeroTimer(function () { + Svc.Obs.remove("weave:service:login:start", onLoginStart); + + do_check_eq(Status.service, CLIENT_NOT_CONFIGURED); + do_check_eq(Status.login, LOGIN_FAILED_NO_USERNAME); + + cleanUpAndGo(server).then(deferred.resolve); + }); + + scheduler.delayedAutoConnect(0); + yield deferred.promise; +}); + +add_identity_test(this, function* test_autoconnectDelay_pref() { + let deferred = Promise.defer(); + Svc.Obs.add("weave:service:sync:finish", function onSyncFinish() { + Svc.Obs.remove("weave:service:sync:finish", onSyncFinish); + cleanUpAndGo(server).then(deferred.resolve); + }); + + Svc.Prefs.set("autoconnectDelay", 1); + + let server = sync_httpd_setup(); + yield setUp(server); + + Svc.Obs.notify("weave:service:ready"); + + // autoconnectDelay pref is multiplied by 1000. + do_check_eq(scheduler._autoTimer.delay, 1000); + do_check_eq(Status.service, STATUS_OK); + yield deferred.promise; +}); + +add_identity_test(this, function* test_idle_adjustSyncInterval() { + // Confirm defaults. + do_check_eq(scheduler.idle, false); + + // Single device: nothing changes. + scheduler.observe(null, "idle", Svc.Prefs.get("scheduler.idleTime")); + do_check_eq(scheduler.idle, true); + do_check_eq(scheduler.syncInterval, scheduler.singleDeviceInterval); + + // Multiple devices: switch to idle interval. + scheduler.idle = false; + clientsEngine._store.create({id: "foo", cleartext: "bar"}); + scheduler.updateClientMode(); + scheduler.observe(null, "idle", Svc.Prefs.get("scheduler.idleTime")); + do_check_eq(scheduler.idle, true); + do_check_eq(scheduler.syncInterval, scheduler.idleInterval); + + yield cleanUpAndGo(); +}); + +add_identity_test(this, function* test_back_triggersSync() { + // Confirm defaults. + do_check_false(scheduler.idle); + do_check_eq(Status.backoffInterval, 0); + + // Set up: Define 2 clients and put the system in idle. + scheduler.numClients = 2; + scheduler.observe(null, "idle", Svc.Prefs.get("scheduler.idleTime")); + do_check_true(scheduler.idle); + + let deferred = Promise.defer(); + // We don't actually expect the sync (or the login, for that matter) to + // succeed. We just want to ensure that it was attempted. + Svc.Obs.add("weave:service:login:error", function onLoginError() { + Svc.Obs.remove("weave:service:login:error", onLoginError); + cleanUpAndGo().then(deferred.resolve); + }); + + // Send an 'active' event to trigger sync soonish. + scheduler.observe(null, "active", Svc.Prefs.get("scheduler.idleTime")); + yield deferred.promise; +}); + +add_identity_test(this, function* test_active_triggersSync_observesBackoff() { + // Confirm defaults. + do_check_false(scheduler.idle); + + // Set up: Set backoff, define 2 clients and put the system in idle. + const BACKOFF = 7337; + Status.backoffInterval = scheduler.idleInterval + BACKOFF; + scheduler.numClients = 2; + scheduler.observe(null, "idle", Svc.Prefs.get("scheduler.idleTime")); + do_check_eq(scheduler.idle, true); + + function onLoginStart() { + do_throw("Shouldn't have kicked off a sync!"); + } + Svc.Obs.add("weave:service:login:start", onLoginStart); + + let deferred = Promise.defer(); + timer = Utils.namedTimer(function () { + Svc.Obs.remove("weave:service:login:start", onLoginStart); + + do_check_true(scheduler.nextSync <= Date.now() + Status.backoffInterval); + do_check_eq(scheduler.syncTimer.delay, Status.backoffInterval); + + cleanUpAndGo().then(deferred.resolve); + }, IDLE_OBSERVER_BACK_DELAY * 1.5, {}, "timer"); + + // Send an 'active' event to try to trigger sync soonish. + scheduler.observe(null, "active", Svc.Prefs.get("scheduler.idleTime")); + yield deferred.promise; +}); + +add_identity_test(this, function* test_back_debouncing() { + _("Ensure spurious back-then-idle events, as observed on OS X, don't trigger a sync."); + + // Confirm defaults. + do_check_eq(scheduler.idle, false); + + // Set up: Define 2 clients and put the system in idle. + scheduler.numClients = 2; + scheduler.observe(null, "idle", Svc.Prefs.get("scheduler.idleTime")); + do_check_eq(scheduler.idle, true); + + function onLoginStart() { + do_throw("Shouldn't have kicked off a sync!"); + } + Svc.Obs.add("weave:service:login:start", onLoginStart); + + // Create spurious back-then-idle events as observed on OS X: + scheduler.observe(null, "active", Svc.Prefs.get("scheduler.idleTime")); + scheduler.observe(null, "idle", Svc.Prefs.get("scheduler.idleTime")); + + let deferred = Promise.defer(); + timer = Utils.namedTimer(function () { + Svc.Obs.remove("weave:service:login:start", onLoginStart); + cleanUpAndGo().then(deferred.resolve); + }, IDLE_OBSERVER_BACK_DELAY * 1.5, {}, "timer"); + yield deferred.promise; +}); + +add_identity_test(this, function* test_no_sync_node() { + // Test when Status.sync == NO_SYNC_NODE_FOUND + // it is not overwritten on sync:finish + let server = sync_httpd_setup(); + yield setUp(server); + + Service.serverURL = server.baseURI + "/"; + + Service.sync(); + do_check_eq(Status.sync, NO_SYNC_NODE_FOUND); + do_check_eq(scheduler.syncTimer.delay, NO_SYNC_NODE_INTERVAL); + + yield cleanUpAndGo(server); +}); + +add_identity_test(this, function* test_sync_failed_partial_500s() { + _("Test a 5xx status calls handleSyncError."); + scheduler._syncErrors = MAX_ERROR_COUNT_BEFORE_BACKOFF; + let server = sync_httpd_setup(); + + let engine = Service.engineManager.get("catapult"); + engine.enabled = true; + engine.exception = {status: 500}; + + do_check_eq(Status.sync, SYNC_SUCCEEDED); + + do_check_true(yield setUp(server)); + + Service.sync(); + + do_check_eq(Status.service, SYNC_FAILED_PARTIAL); + + let maxInterval = scheduler._syncErrors * (2 * MINIMUM_BACKOFF_INTERVAL); + do_check_eq(Status.backoffInterval, 0); + do_check_true(Status.enforceBackoff); + do_check_eq(scheduler._syncErrors, 4); + do_check_true(scheduler.nextSync <= (Date.now() + maxInterval)); + do_check_true(scheduler.syncTimer.delay <= maxInterval); + + yield cleanUpAndGo(server); +}); + +add_identity_test(this, function* test_sync_failed_partial_400s() { + _("Test a non-5xx status doesn't call handleSyncError."); + scheduler._syncErrors = MAX_ERROR_COUNT_BEFORE_BACKOFF; + let server = sync_httpd_setup(); + + let engine = Service.engineManager.get("catapult"); + engine.enabled = true; + engine.exception = {status: 400}; + + // Have multiple devices for an active interval. + clientsEngine._store.create({id: "foo", cleartext: "bar"}); + + do_check_eq(Status.sync, SYNC_SUCCEEDED); + + do_check_true(yield setUp(server)); + + Service.sync(); + + do_check_eq(Status.service, SYNC_FAILED_PARTIAL); + do_check_eq(scheduler.syncInterval, scheduler.activeInterval); + + do_check_eq(Status.backoffInterval, 0); + do_check_false(Status.enforceBackoff); + do_check_eq(scheduler._syncErrors, 0); + do_check_true(scheduler.nextSync <= (Date.now() + scheduler.activeInterval)); + do_check_true(scheduler.syncTimer.delay <= scheduler.activeInterval); + + yield cleanUpAndGo(server); +}); + +add_identity_test(this, function* test_sync_X_Weave_Backoff() { + let server = sync_httpd_setup(); + yield setUp(server); + + // Use an odd value on purpose so that it doesn't happen to coincide with one + // of the sync intervals. + const BACKOFF = 7337; + + // Extend info/collections so that we can put it into server maintenance mode. + const INFO_COLLECTIONS = "/1.1/johndoe/info/collections"; + let infoColl = server._handler._overridePaths[INFO_COLLECTIONS]; + let serverBackoff = false; + function infoCollWithBackoff(request, response) { + if (serverBackoff) { + response.setHeader("X-Weave-Backoff", "" + BACKOFF); + } + infoColl(request, response); + } + server.registerPathHandler(INFO_COLLECTIONS, infoCollWithBackoff); + + // Pretend we have two clients so that the regular sync interval is + // sufficiently low. + clientsEngine._store.create({id: "foo", cleartext: "bar"}); + let rec = clientsEngine._store.createRecord("foo", "clients"); + rec.encrypt(Service.collectionKeys.keyForCollection("clients")); + rec.upload(Service.resource(clientsEngine.engineURL + rec.id)); + + // Sync once to log in and get everything set up. Let's verify our initial + // values. + Service.sync(); + do_check_eq(Status.backoffInterval, 0); + do_check_eq(Status.minimumNextSync, 0); + do_check_eq(scheduler.syncInterval, scheduler.activeInterval); + do_check_true(scheduler.nextSync <= + Date.now() + scheduler.syncInterval); + // Sanity check that we picked the right value for BACKOFF: + do_check_true(scheduler.syncInterval < BACKOFF * 1000); + + // Turn on server maintenance and sync again. + serverBackoff = true; + Service.sync(); + + do_check_true(Status.backoffInterval >= BACKOFF * 1000); + // Allowing 3 seconds worth of of leeway between when Status.minimumNextSync + // was set and when this line gets executed. + let minimumExpectedDelay = (BACKOFF - 3) * 1000; + do_check_true(Status.minimumNextSync >= Date.now() + minimumExpectedDelay); + + // Verify that the next sync is actually going to wait that long. + do_check_true(scheduler.nextSync >= Date.now() + minimumExpectedDelay); + do_check_true(scheduler.syncTimer.delay >= minimumExpectedDelay); + + yield cleanUpAndGo(server); +}); + +add_identity_test(this, function* test_sync_503_Retry_After() { + let server = sync_httpd_setup(); + yield setUp(server); + + // Use an odd value on purpose so that it doesn't happen to coincide with one + // of the sync intervals. + const BACKOFF = 7337; + + // Extend info/collections so that we can put it into server maintenance mode. + const INFO_COLLECTIONS = "/1.1/johndoe/info/collections"; + let infoColl = server._handler._overridePaths[INFO_COLLECTIONS]; + let serverMaintenance = false; + function infoCollWithMaintenance(request, response) { + if (!serverMaintenance) { + infoColl(request, response); + return; + } + response.setHeader("Retry-After", "" + BACKOFF); + response.setStatusLine(request.httpVersion, 503, "Service Unavailable"); + } + server.registerPathHandler(INFO_COLLECTIONS, infoCollWithMaintenance); + + // Pretend we have two clients so that the regular sync interval is + // sufficiently low. + clientsEngine._store.create({id: "foo", cleartext: "bar"}); + let rec = clientsEngine._store.createRecord("foo", "clients"); + rec.encrypt(Service.collectionKeys.keyForCollection("clients")); + rec.upload(Service.resource(clientsEngine.engineURL + rec.id)); + + // Sync once to log in and get everything set up. Let's verify our initial + // values. + Service.sync(); + do_check_false(Status.enforceBackoff); + do_check_eq(Status.backoffInterval, 0); + do_check_eq(Status.minimumNextSync, 0); + do_check_eq(scheduler.syncInterval, scheduler.activeInterval); + do_check_true(scheduler.nextSync <= + Date.now() + scheduler.syncInterval); + // Sanity check that we picked the right value for BACKOFF: + do_check_true(scheduler.syncInterval < BACKOFF * 1000); + + // Turn on server maintenance and sync again. + serverMaintenance = true; + Service.sync(); + + do_check_true(Status.enforceBackoff); + do_check_true(Status.backoffInterval >= BACKOFF * 1000); + // Allowing 3 seconds worth of of leeway between when Status.minimumNextSync + // was set and when this line gets executed. + let minimumExpectedDelay = (BACKOFF - 3) * 1000; + do_check_true(Status.minimumNextSync >= Date.now() + minimumExpectedDelay); + + // Verify that the next sync is actually going to wait that long. + do_check_true(scheduler.nextSync >= Date.now() + minimumExpectedDelay); + do_check_true(scheduler.syncTimer.delay >= minimumExpectedDelay); + + yield cleanUpAndGo(server); +}); + +add_identity_test(this, function* test_loginError_recoverable_reschedules() { + _("Verify that a recoverable login error schedules a new sync."); + yield configureIdentity({username: "johndoe"}); + Service.serverURL = "http://localhost:1234/"; + Service.clusterURL = Service.serverURL; + Service.persistLogin(); + Status.resetSync(); // reset Status.login + + let deferred = Promise.defer(); + Svc.Obs.add("weave:service:login:error", function onLoginError() { + Svc.Obs.remove("weave:service:login:error", onLoginError); + Utils.nextTick(function aLittleBitAfterLoginError() { + do_check_eq(Status.login, LOGIN_FAILED_NETWORK_ERROR); + + let expectedNextSync = Date.now() + scheduler.syncInterval; + do_check_true(scheduler.nextSync > Date.now()); + do_check_true(scheduler.nextSync <= expectedNextSync); + do_check_true(scheduler.syncTimer.delay > 0); + do_check_true(scheduler.syncTimer.delay <= scheduler.syncInterval); + + Svc.Obs.remove("weave:service:sync:start", onSyncStart); + cleanUpAndGo().then(deferred.resolve); + }); + }); + + // Let's set it up so that a sync is overdue, both in terms of previously + // scheduled syncs and the global score. We still do not expect an immediate + // sync because we just tried (duh). + scheduler.nextSync = Date.now() - 100000; + scheduler.globalScore = SINGLE_USER_THRESHOLD + 1; + function onSyncStart() { + do_throw("Shouldn't have started a sync!"); + } + Svc.Obs.add("weave:service:sync:start", onSyncStart); + + // Sanity check. + do_check_eq(scheduler.syncTimer, null); + do_check_eq(Status.checkSetup(), STATUS_OK); + do_check_eq(Status.login, LOGIN_SUCCEEDED); + + scheduler.scheduleNextSync(0); + yield deferred.promise; +}); + +add_identity_test(this, function* test_loginError_fatal_clearsTriggers() { + _("Verify that a fatal login error clears sync triggers."); + yield configureIdentity({username: "johndoe"}); + + let server = httpd_setup({ + "/1.1/johndoe/info/collections": httpd_handler(401, "Unauthorized") + }); + + Service.serverURL = server.baseURI + "/"; + Service.clusterURL = Service.serverURL; + Service.persistLogin(); + Status.resetSync(); // reset Status.login + + let deferred = Promise.defer(); + Svc.Obs.add("weave:service:login:error", function onLoginError() { + Svc.Obs.remove("weave:service:login:error", onLoginError); + Utils.nextTick(function aLittleBitAfterLoginError() { + + if (isConfiguredWithLegacyIdentity()) { + // for the "legacy" identity, a 401 on info/collections means the + // password is wrong, so we enter a "login rejected" state. + do_check_eq(Status.login, LOGIN_FAILED_LOGIN_REJECTED); + + do_check_eq(scheduler.nextSync, 0); + do_check_eq(scheduler.syncTimer, null); + } else { + // For the FxA identity, a 401 on info/collections means a transient + // error, probably due to an inability to fetch a token. + do_check_eq(Status.login, LOGIN_FAILED_NETWORK_ERROR); + // syncs should still be scheduled. + do_check_true(scheduler.nextSync > Date.now()); + do_check_true(scheduler.syncTimer.delay > 0); + } + cleanUpAndGo(server).then(deferred.resolve); + }); + }); + + // Sanity check. + do_check_eq(scheduler.nextSync, 0); + do_check_eq(scheduler.syncTimer, null); + do_check_eq(Status.checkSetup(), STATUS_OK); + do_check_eq(Status.login, LOGIN_SUCCEEDED); + + scheduler.scheduleNextSync(0); + yield deferred.promise; +}); + +add_identity_test(this, function* test_proper_interval_on_only_failing() { + _("Ensure proper behavior when only failed records are applied."); + + // If an engine reports that no records succeeded, we shouldn't decrease the + // sync interval. + do_check_false(scheduler.hasIncomingItems); + const INTERVAL = 10000000; + scheduler.syncInterval = INTERVAL; + + Svc.Obs.notify("weave:service:sync:applied", { + applied: 2, + succeeded: 0, + failed: 2, + newFailed: 2, + reconciled: 0 + }); + + let deferred = Promise.defer(); + Utils.nextTick(function() { + scheduler.adjustSyncInterval(); + do_check_false(scheduler.hasIncomingItems); + do_check_eq(scheduler.syncInterval, scheduler.singleDeviceInterval); + + deferred.resolve(); + }); + yield deferred.promise; +}); diff --git a/services/sync/tests/unit/test_syncstoragerequest.js b/services/sync/tests/unit/test_syncstoragerequest.js new file mode 100644 index 0000000000..14e5daade7 --- /dev/null +++ b/services/sync/tests/unit/test_syncstoragerequest.js @@ -0,0 +1,220 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +Cu.import("resource://gre/modules/Log.jsm"); +Cu.import("resource://services-sync/constants.js"); +Cu.import("resource://services-sync/rest.js"); +Cu.import("resource://services-sync/service.js"); +Cu.import("resource://services-sync/util.js"); +Cu.import("resource://testing-common/services/sync/utils.js"); + +var httpProtocolHandler = Cc["@mozilla.org/network/protocol;1?name=http"] + .getService(Ci.nsIHttpProtocolHandler); + +function run_test() { + Log.repository.getLogger("Sync.RESTRequest").level = Log.Level.Trace; + initTestLogging(); + + ensureLegacyIdentityManager(); + + run_next_test(); +} + +add_test(function test_user_agent_desktop() { + let handler = httpd_handler(200, "OK"); + let server = httpd_setup({"/resource": handler}); + + let expectedUA = Services.appinfo.name + "/" + Services.appinfo.version + + " (" + httpProtocolHandler.oscpu + ")" + + " FxSync/" + WEAVE_VERSION + "." + + Services.appinfo.appBuildID + ".desktop"; + + let request = new SyncStorageRequest(server.baseURI + "/resource"); + request.onComplete = function onComplete(error) { + do_check_eq(error, null); + do_check_eq(this.response.status, 200); + do_check_eq(handler.request.getHeader("User-Agent"), expectedUA); + server.stop(run_next_test); + }; + do_check_eq(request.get(), request); +}); + +add_test(function test_user_agent_mobile() { + let handler = httpd_handler(200, "OK"); + let server = httpd_setup({"/resource": handler}); + + Svc.Prefs.set("client.type", "mobile"); + let expectedUA = Services.appinfo.name + "/" + Services.appinfo.version + + " (" + httpProtocolHandler.oscpu + ")" + + " FxSync/" + WEAVE_VERSION + "." + + Services.appinfo.appBuildID + ".mobile"; + + let request = new SyncStorageRequest(server.baseURI + "/resource"); + request.get(function (error) { + do_check_eq(error, null); + do_check_eq(this.response.status, 200); + do_check_eq(handler.request.getHeader("User-Agent"), expectedUA); + Svc.Prefs.resetBranch(""); + server.stop(run_next_test); + }); +}); + +add_test(function test_auth() { + let handler = httpd_handler(200, "OK"); + let server = httpd_setup({"/resource": handler}); + + setBasicCredentials("johndoe", "ilovejane", "XXXXXXXXX"); + + let request = Service.getStorageRequest(server.baseURI + "/resource"); + request.get(function (error) { + do_check_eq(error, null); + do_check_eq(this.response.status, 200); + do_check_true(basic_auth_matches(handler.request, "johndoe", "ilovejane")); + + Svc.Prefs.reset(""); + + server.stop(run_next_test); + }); +}); + +/** + * The X-Weave-Timestamp header updates SyncStorageRequest.serverTime. + */ +add_test(function test_weave_timestamp() { + const TIMESTAMP = 1274380461; + function handler(request, response) { + response.setHeader("X-Weave-Timestamp", "" + TIMESTAMP, false); + response.setStatusLine(request.httpVersion, 200, "OK"); + } + let server = httpd_setup({"/resource": handler}); + + do_check_eq(SyncStorageRequest.serverTime, undefined); + let request = new SyncStorageRequest(server.baseURI + "/resource"); + request.get(function (error) { + do_check_eq(error, null); + do_check_eq(this.response.status, 200); + do_check_eq(SyncStorageRequest.serverTime, TIMESTAMP); + delete SyncStorageRequest.serverTime; + server.stop(run_next_test); + }); +}); + +/** + * The X-Weave-Backoff header notifies an observer. + */ +add_test(function test_weave_backoff() { + function handler(request, response) { + response.setHeader("X-Weave-Backoff", '600', false); + response.setStatusLine(request.httpVersion, 200, "OK"); + } + let server = httpd_setup({"/resource": handler}); + + let backoffInterval; + Svc.Obs.add("weave:service:backoff:interval", function onBackoff(subject) { + Svc.Obs.remove("weave:service:backoff:interval", onBackoff); + backoffInterval = subject; + }); + + let request = new SyncStorageRequest(server.baseURI + "/resource"); + request.get(function (error) { + do_check_eq(error, null); + do_check_eq(this.response.status, 200); + do_check_eq(backoffInterval, 600); + server.stop(run_next_test); + }); +}); + +/** + * X-Weave-Quota-Remaining header notifies observer on successful requests. + */ +add_test(function test_weave_quota_notice() { + function handler(request, response) { + response.setHeader("X-Weave-Quota-Remaining", '1048576', false); + response.setStatusLine(request.httpVersion, 200, "OK"); + } + let server = httpd_setup({"/resource": handler}); + + let quotaValue; + Svc.Obs.add("weave:service:quota:remaining", function onQuota(subject) { + Svc.Obs.remove("weave:service:quota:remaining", onQuota); + quotaValue = subject; + }); + + let request = new SyncStorageRequest(server.baseURI + "/resource"); + request.get(function (error) { + do_check_eq(error, null); + do_check_eq(this.response.status, 200); + do_check_eq(quotaValue, 1048576); + server.stop(run_next_test); + }); +}); + +/** + * X-Weave-Quota-Remaining header doesn't notify observer on failed requests. + */ +add_test(function test_weave_quota_error() { + function handler(request, response) { + response.setHeader("X-Weave-Quota-Remaining", '1048576', false); + response.setStatusLine(request.httpVersion, 400, "Bad Request"); + } + let server = httpd_setup({"/resource": handler}); + + let quotaValue; + function onQuota(subject) { + quotaValue = subject; + } + Svc.Obs.add("weave:service:quota:remaining", onQuota); + + let request = new SyncStorageRequest(server.baseURI + "/resource"); + request.get(function (error) { + do_check_eq(error, null); + do_check_eq(this.response.status, 400); + do_check_eq(quotaValue, undefined); + Svc.Obs.remove("weave:service:quota:remaining", onQuota); + server.stop(run_next_test); + }); +}); + +add_test(function test_abort() { + function handler(request, response) { + response.setHeader("X-Weave-Timestamp", "" + TIMESTAMP, false); + response.setHeader("X-Weave-Quota-Remaining", '1048576', false); + response.setHeader("X-Weave-Backoff", '600', false); + response.setStatusLine(request.httpVersion, 200, "OK"); + } + let server = httpd_setup({"/resource": handler}); + + let request = new SyncStorageRequest(server.baseURI + "/resource"); + + // Aborting a request that hasn't been sent yet is pointless and will throw. + do_check_throws(function () { + request.abort(); + }); + + function throwy() { + do_throw("Shouldn't have gotten here!"); + } + + Svc.Obs.add("weave:service:backoff:interval", throwy); + Svc.Obs.add("weave:service:quota:remaining", throwy); + request.onProgress = request.onComplete = throwy; + + request.get(); + request.abort(); + do_check_eq(request.status, request.ABORTED); + + // Aborting an already aborted request is pointless and will throw. + do_check_throws(function () { + request.abort(); + }); + + Utils.nextTick(function () { + // Verify that we didn't try to process any of the values. + do_check_eq(SyncStorageRequest.serverTime, undefined); + + Svc.Obs.remove("weave:service:backoff:interval", throwy); + Svc.Obs.remove("weave:service:quota:remaining", throwy); + + server.stop(run_next_test); + }); +}); diff --git a/services/sync/tests/unit/test_tab_engine.js b/services/sync/tests/unit/test_tab_engine.js new file mode 100644 index 0000000000..0492502306 --- /dev/null +++ b/services/sync/tests/unit/test_tab_engine.js @@ -0,0 +1,141 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +Cu.import("resource://services-sync/constants.js"); +Cu.import("resource://services-sync/engines/tabs.js"); +Cu.import("resource://services-sync/record.js"); +Cu.import("resource://services-sync/service.js"); +Cu.import("resource://services-sync/util.js"); +Cu.import("resource://testing-common/services/sync/utils.js"); + +function getMocks() { + let engine = new TabEngine(Service); + let store = engine._store; + store.getTabState = mockGetTabState; + store.shouldSkipWindow = mockShouldSkipWindow; + return [engine, store]; +} + +function run_test() { + run_next_test(); +} + +add_test(function test_getOpenURLs() { + _("Test getOpenURLs."); + let [engine, store] = getMocks(); + + let superLongURL = "http://" + (new Array(MAX_UPLOAD_BYTES).join("w")) + ".com/"; + let urls = ["http://bar.com", "http://foo.com", "http://foobar.com", superLongURL]; + function fourURLs() { + return urls.pop(); + } + store.getWindowEnumerator = mockGetWindowEnumerator.bind(this, fourURLs, 1, 4); + + let matches; + + _(" test matching works (true)"); + let openurlsset = engine.getOpenURLs(); + matches = openurlsset.has("http://foo.com"); + ok(matches); + + _(" test matching works (false)"); + matches = openurlsset.has("http://barfoo.com"); + ok(!matches); + + _(" test matching works (too long)"); + matches = openurlsset.has(superLongURL); + ok(!matches); + + run_next_test(); +}); + +add_test(function test_tab_engine_skips_incoming_local_record() { + _("Ensure incoming records that match local client ID are never applied."); + let [engine, store] = getMocks(); + let localID = engine.service.clientsEngine.localID; + let apply = store.applyIncoming; + let applied = []; + + store.applyIncoming = function (record) { + notEqual(record.id, localID, "Only apply tab records from remote clients"); + applied.push(record); + apply.call(store, record); + } + + let collection = new ServerCollection(); + + _("Creating remote tab record with local client ID"); + let localRecord = encryptPayload({id: localID, clientName: "local"}); + collection.insert(localID, localRecord); + + _("Creating remote tab record with a different client ID"); + let remoteID = "different"; + let remoteRecord = encryptPayload({id: remoteID, clientName: "not local"}); + collection.insert(remoteID, remoteRecord); + + _("Setting up Sync server"); + let server = sync_httpd_setup({ + "/1.1/foo/storage/tabs": collection.handler() + }); + + let syncTesting = new SyncTestingInfrastructure(server); + Service.identity.username = "foo"; + + let meta_global = Service.recordManager.set(engine.metaURL, + new WBORecord(engine.metaURL)); + meta_global.payload.engines = {tabs: {version: engine.version, + syncID: engine.syncID}}; + + generateNewKeys(Service.collectionKeys); + + let syncFinish = engine._syncFinish; + engine._syncFinish = function () { + equal(applied.length, 1, "Remote client record was applied"); + equal(applied[0].id, remoteID, "Remote client ID matches"); + + syncFinish.call(engine); + run_next_test(); + } + + _("Start sync"); + engine._sync(); +}); + +add_test(function test_reconcile() { + let [engine, store] = getMocks(); + + _("Setup engine for reconciling"); + engine._syncStartup(); + + _("Create an incoming remote record"); + let remoteRecord = {id: "remote id", + cleartext: "stuff and things!", + modified: 1000}; + + ok(engine._reconcile(remoteRecord), "Apply a recently modified remote record"); + + remoteRecord.modified = 0; + ok(engine._reconcile(remoteRecord), "Apply a remote record modified long ago"); + + // Remote tab records are never tracked locally, so the only + // time they're skipped is when they're marked as deleted. + remoteRecord.deleted = true; + ok(!engine._reconcile(remoteRecord), "Skip a deleted remote record"); + + _("Create an incoming local record"); + // The locally tracked tab record always takes precedence over its + // remote counterparts. + let localRecord = {id: engine.service.clientsEngine.localID, + cleartext: "this should always be skipped", + modified: 2000}; + + ok(!engine._reconcile(localRecord), "Skip incoming local if recently modified"); + + localRecord.modified = 0; + ok(!engine._reconcile(localRecord), "Skip incoming local if modified long ago"); + + localRecord.deleted = true; + ok(!engine._reconcile(localRecord), "Skip incoming local if deleted"); + + run_next_test(); +}); diff --git a/services/sync/tests/unit/test_tab_store.js b/services/sync/tests/unit/test_tab_store.js new file mode 100644 index 0000000000..93b60f0c7a --- /dev/null +++ b/services/sync/tests/unit/test_tab_store.js @@ -0,0 +1,116 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +Cu.import("resource://services-sync/engines/tabs.js"); +Cu.import("resource://services-sync/service.js"); +Cu.import("resource://services-sync/util.js"); +Cu.import("resource://testing-common/services/common/utils.js"); + +function getMockStore() { + let engine = new TabEngine(Service); + let store = engine._store; + store.getTabState = mockGetTabState; + store.shouldSkipWindow = mockShouldSkipWindow; + return store; +} + +function test_create() { + let store = new TabEngine(Service)._store; + + _("Create a first record"); + let rec = {id: "id1", + clientName: "clientName1", + cleartext: { "foo": "bar" }, + modified: 1000}; + store.applyIncoming(rec); + deepEqual(store._remoteClients["id1"], { lastModified: 1000, foo: "bar" }); + + _("Create a second record"); + rec = {id: "id2", + clientName: "clientName2", + cleartext: { "foo2": "bar2" }, + modified: 2000}; + store.applyIncoming(rec); + deepEqual(store._remoteClients["id2"], { lastModified: 2000, foo2: "bar2" }); + + _("Create a third record"); + rec = {id: "id3", + clientName: "clientName3", + cleartext: { "foo3": "bar3" }, + modified: 3000}; + store.applyIncoming(rec); + deepEqual(store._remoteClients["id3"], { lastModified: 3000, foo3: "bar3" }); +} + +function test_getAllTabs() { + let store = getMockStore(); + let tabs; + + let threeUrls = ["http://foo.com", "http://fuubar.com", "http://barbar.com"]; + + store.getWindowEnumerator = mockGetWindowEnumerator.bind(this, "http://bar.com", 1, 1, () => 2, () => threeUrls); + + _("Get all tabs."); + tabs = store.getAllTabs(); + _("Tabs: " + JSON.stringify(tabs)); + equal(tabs.length, 1); + equal(tabs[0].title, "title"); + equal(tabs[0].urlHistory.length, 2); + equal(tabs[0].urlHistory[0], "http://foo.com"); + equal(tabs[0].urlHistory[1], "http://bar.com"); + equal(tabs[0].icon, "image"); + equal(tabs[0].lastUsed, 1); + + _("Get all tabs, and check that filtering works."); + let twoUrls = ["about:foo", "http://fuubar.com"]; + store.getWindowEnumerator = mockGetWindowEnumerator.bind(this, "http://foo.com", 1, 1, () => 2, () => twoUrls); + tabs = store.getAllTabs(true); + _("Filtered: " + JSON.stringify(tabs)); + equal(tabs.length, 0); + + _("Get all tabs, and check that the entries safety limit works."); + let allURLs = []; + for (let i = 0; i < 50; i++) { + allURLs.push("http://foo" + i + ".bar"); + } + allURLs.splice(35, 0, "about:foo", "about:bar", "about:foobar"); + + store.getWindowEnumerator = mockGetWindowEnumerator.bind(this, "http://bar.com", 1, 1, () => 45, () => allURLs); + tabs = store.getAllTabs((url) => url.startsWith("about")); + + _("Sliced: " + JSON.stringify(tabs)); + equal(tabs.length, 1); + equal(tabs[0].urlHistory.length, 25); + equal(tabs[0].urlHistory[0], "http://foo40.bar"); + equal(tabs[0].urlHistory[24], "http://foo16.bar"); +} + +function test_createRecord() { + let store = getMockStore(); + let record; + + store.getTabState = mockGetTabState; + store.shouldSkipWindow = mockShouldSkipWindow; + store.getWindowEnumerator = mockGetWindowEnumerator.bind(this, "http://foo.com", 1, 1); + + let tabs = store.getAllTabs(); + let tabsize = JSON.stringify(tabs[0]).length; + let numtabs = Math.ceil(20000./77.); + + store.getWindowEnumerator = mockGetWindowEnumerator.bind(this, "http://foo.com", 1, 1); + record = store.createRecord("fake-guid"); + ok(record instanceof TabSetRecord); + equal(record.tabs.length, 1); + + _("create a big record"); + store.getWindowEnumerator = mockGetWindowEnumerator.bind(this, "http://foo.com", 1, numtabs); + record = store.createRecord("fake-guid"); + ok(record instanceof TabSetRecord); + equal(record.tabs.length, 256); +} + +function run_test() { + test_create(); + test_getAllTabs(); + test_createRecord(); +} diff --git a/services/sync/tests/unit/test_tab_tracker.js b/services/sync/tests/unit/test_tab_tracker.js new file mode 100644 index 0000000000..f98920a445 --- /dev/null +++ b/services/sync/tests/unit/test_tab_tracker.js @@ -0,0 +1,127 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +Cu.import("resource://services-sync/engines/tabs.js"); +Cu.import("resource://services-sync/service.js"); +Cu.import("resource://services-sync/util.js"); + +var clientsEngine = Service.clientsEngine; + +function fakeSvcWinMediator() { + // actions on windows are captured in logs + let logs = []; + delete Services.wm; + Services.wm = { + getEnumerator: function() { + return { + cnt: 2, + hasMoreElements: function() { + return this.cnt-- > 0; + }, + getNext: function() { + let elt = {addTopics: [], remTopics: [], numAPL: 0, numRPL: 0}; + logs.push(elt); + return { + addEventListener: function(topic) { + elt.addTopics.push(topic); + }, + removeEventListener: function(topic) { + elt.remTopics.push(topic); + }, + gBrowser: { + addProgressListener() { + elt.numAPL++; + }, + removeProgressListener() { + elt.numRPL++; + }, + }, + }; + } + }; + } + }; + return logs; +} + +function run_test() { + let engine = Service.engineManager.get("tabs"); + + _("We assume that tabs have changed at startup."); + let tracker = engine._tracker; + tracker.persistChangedIDs = false; + + do_check_true(tracker.modified); + do_check_true(Utils.deepEquals(Object.keys(engine.getChangedIDs()), + [clientsEngine.localID])); + + let logs; + + _("Test listeners are registered on windows"); + logs = fakeSvcWinMediator(); + Svc.Obs.notify("weave:engine:start-tracking"); + do_check_eq(logs.length, 2); + for (let log of logs) { + do_check_eq(log.addTopics.length, 5); + do_check_true(log.addTopics.indexOf("pageshow") >= 0); + do_check_true(log.addTopics.indexOf("TabOpen") >= 0); + do_check_true(log.addTopics.indexOf("TabClose") >= 0); + do_check_true(log.addTopics.indexOf("TabSelect") >= 0); + do_check_true(log.addTopics.indexOf("unload") >= 0); + do_check_eq(log.remTopics.length, 0); + do_check_eq(log.numAPL, 1, "Added 1 progress listener"); + do_check_eq(log.numRPL, 0, "Didn't remove a progress listener"); + } + + _("Test listeners are unregistered on windows"); + logs = fakeSvcWinMediator(); + Svc.Obs.notify("weave:engine:stop-tracking"); + do_check_eq(logs.length, 2); + for (let log of logs) { + do_check_eq(log.addTopics.length, 0); + do_check_eq(log.remTopics.length, 5); + do_check_true(log.remTopics.indexOf("pageshow") >= 0); + do_check_true(log.remTopics.indexOf("TabOpen") >= 0); + do_check_true(log.remTopics.indexOf("TabClose") >= 0); + do_check_true(log.remTopics.indexOf("TabSelect") >= 0); + do_check_true(log.remTopics.indexOf("unload") >= 0); + do_check_eq(log.numAPL, 0, "Didn't add a progress listener"); + do_check_eq(log.numRPL, 1, "Removed 1 progress listener"); + } + + _("Test tab listener"); + for (let evttype of ["TabOpen", "TabClose", "TabSelect"]) { + // Pretend we just synced. + tracker.clearChangedIDs(); + do_check_false(tracker.modified); + + // Send a fake tab event + tracker.onTab({type: evttype , originalTarget: evttype}); + do_check_true(tracker.modified); + do_check_true(Utils.deepEquals(Object.keys(engine.getChangedIDs()), + [clientsEngine.localID])); + } + + // Pretend we just synced. + tracker.clearChangedIDs(); + do_check_false(tracker.modified); + + tracker.onTab({type: "pageshow", originalTarget: "pageshow"}); + do_check_true(Utils.deepEquals(Object.keys(engine.getChangedIDs()), + [clientsEngine.localID])); + + // Pretend we just synced and saw some progress listeners. + tracker.clearChangedIDs(); + do_check_false(tracker.modified); + tracker.onLocationChange({ isTopLevel: false }, undefined, undefined, 0); + do_check_false(tracker.modified, "non-toplevel request didn't flag as modified"); + + tracker.onLocationChange({ isTopLevel: true }, undefined, undefined, + Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT); + do_check_false(tracker.modified, "location change within the same document request didn't flag as modified"); + + tracker.onLocationChange({ isTopLevel: true }, undefined, undefined, 0); + do_check_true(tracker.modified, "location change for a new top-level document flagged as modified"); + do_check_true(Utils.deepEquals(Object.keys(engine.getChangedIDs()), + [clientsEngine.localID])); +} diff --git a/services/sync/tests/unit/test_telemetry.js b/services/sync/tests/unit/test_telemetry.js new file mode 100644 index 0000000000..50a3d136ba --- /dev/null +++ b/services/sync/tests/unit/test_telemetry.js @@ -0,0 +1,564 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +Cu.import("resource://services-common/observers.js"); +Cu.import("resource://services-sync/telemetry.js"); +Cu.import("resource://services-sync/service.js"); +Cu.import("resource://services-sync/record.js"); +Cu.import("resource://services-sync/resource.js"); +Cu.import("resource://services-sync/constants.js"); +Cu.import("resource://services-sync/engines.js"); +Cu.import("resource://services-sync/engines/bookmarks.js"); +Cu.import("resource://services-sync/engines/clients.js"); +Cu.import("resource://testing-common/services/sync/utils.js"); +Cu.import("resource://testing-common/services/sync/fxa_utils.js"); +Cu.import("resource://testing-common/services/sync/rotaryengine.js"); +Cu.import("resource://gre/modules/osfile.jsm", this); + +Cu.import("resource://gre/modules/PlacesUtils.jsm"); +Cu.import("resource://services-sync/util.js"); + +initTestLogging("Trace"); + +function SteamStore(engine) { + Store.call(this, "Steam", engine); +} + +SteamStore.prototype = { + __proto__: Store.prototype, +}; + +function SteamTracker(name, engine) { + Tracker.call(this, name || "Steam", engine); +} + +SteamTracker.prototype = { + __proto__: Tracker.prototype +}; + +function SteamEngine(service) { + Engine.call(this, "steam", service); +} + +SteamEngine.prototype = { + __proto__: Engine.prototype, + _storeObj: SteamStore, + _trackerObj: SteamTracker, + _errToThrow: null, + _sync() { + if (this._errToThrow) { + throw this._errToThrow; + } + } +}; + +function BogusEngine(service) { + Engine.call(this, "bogus", service); +} + +BogusEngine.prototype = Object.create(SteamEngine.prototype); + +function cleanAndGo(server) { + Svc.Prefs.resetBranch(""); + Svc.Prefs.set("log.logger.engine.rotary", "Trace"); + Service.recordManager.clearCache(); + return new Promise(resolve => server.stop(resolve)); +} + +// Avoid addon manager complaining about not being initialized +Service.engineManager.unregister("addons"); + +add_identity_test(this, function *test_basic() { + let helper = track_collections_helper(); + let upd = helper.with_updated_collection; + + yield configureIdentity({ username: "johndoe" }); + let handlers = { + "/1.1/johndoe/info/collections": helper.handler, + "/1.1/johndoe/storage/crypto/keys": upd("crypto", new ServerWBO("keys").handler()), + "/1.1/johndoe/storage/meta/global": upd("meta", new ServerWBO("global").handler()) + }; + + let collections = ["clients", "bookmarks", "forms", "history", "passwords", "prefs", "tabs"]; + + for (let coll of collections) { + handlers["/1.1/johndoe/storage/" + coll] = upd(coll, new ServerCollection({}, true).handler()); + } + + let server = httpd_setup(handlers); + Service.serverURL = server.baseURI; + + yield sync_and_validate_telem(true); + + yield new Promise(resolve => server.stop(resolve)); +}); + +add_task(function* test_processIncoming_error() { + let engine = new BookmarksEngine(Service); + let store = engine._store; + let server = serverForUsers({"foo": "password"}, { + meta: {global: {engines: {bookmarks: {version: engine.version, + syncID: engine.syncID}}}}, + bookmarks: {} + }); + new SyncTestingInfrastructure(server.server); + let collection = server.user("foo").collection("bookmarks"); + try { + // Create a bogus record that when synced down will provoke a + // network error which in turn provokes an exception in _processIncoming. + const BOGUS_GUID = "zzzzzzzzzzzz"; + let bogus_record = collection.insert(BOGUS_GUID, "I'm a bogus record!"); + bogus_record.get = function get() { + throw "Sync this!"; + }; + // Make the 10 minutes old so it will only be synced in the toFetch phase. + bogus_record.modified = Date.now() / 1000 - 60 * 10; + engine.lastSync = Date.now() / 1000 - 60; + engine.toFetch = [BOGUS_GUID]; + + let error, ping; + try { + yield sync_engine_and_validate_telem(engine, true, errPing => ping = errPing); + } catch(ex) { + error = ex; + } + ok(!!error); + ok(!!ping); + equal(ping.uid, "0".repeat(32)); + deepEqual(ping.failureReason, { + name: "othererror", + error: "error.engine.reason.record_download_fail" + }); + + equal(ping.engines.length, 1); + equal(ping.engines[0].name, "bookmarks"); + deepEqual(ping.engines[0].failureReason, { + name: "othererror", + error: "error.engine.reason.record_download_fail" + }); + + } finally { + store.wipe(); + yield cleanAndGo(server); + } +}); + +add_task(function *test_uploading() { + let engine = new BookmarksEngine(Service); + let store = engine._store; + let server = serverForUsers({"foo": "password"}, { + meta: {global: {engines: {bookmarks: {version: engine.version, + syncID: engine.syncID}}}}, + bookmarks: {} + }); + new SyncTestingInfrastructure(server.server); + + let parent = PlacesUtils.toolbarFolderId; + let uri = Utils.makeURI("http://getfirefox.com/"); + let title = "Get Firefox"; + + let bmk_id = PlacesUtils.bookmarks.insertBookmark(parent, uri, + PlacesUtils.bookmarks.DEFAULT_INDEX, "Get Firefox!"); + + let guid = store.GUIDForId(bmk_id); + let record = store.createRecord(guid); + + let collection = server.user("foo").collection("bookmarks"); + try { + let ping = yield sync_engine_and_validate_telem(engine, false); + ok(!!ping); + equal(ping.engines.length, 1); + equal(ping.engines[0].name, "bookmarks"); + ok(!!ping.engines[0].outgoing); + greater(ping.engines[0].outgoing[0].sent, 0) + ok(!ping.engines[0].incoming); + + PlacesUtils.bookmarks.setItemTitle(bmk_id, "New Title"); + + store.wipe(); + engine.resetClient(); + + ping = yield sync_engine_and_validate_telem(engine, false); + equal(ping.engines.length, 1); + equal(ping.engines[0].name, "bookmarks"); + equal(ping.engines[0].outgoing.length, 1); + ok(!!ping.engines[0].incoming); + + } finally { + // Clean up. + store.wipe(); + yield cleanAndGo(server); + } +}); + +add_task(function *test_upload_failed() { + Service.identity.username = "foo"; + let collection = new ServerCollection(); + collection._wbos.flying = new ServerWBO('flying'); + + let server = sync_httpd_setup({ + "/1.1/foo/storage/rotary": collection.handler() + }); + + let syncTesting = new SyncTestingInfrastructure(server); + + let engine = new RotaryEngine(Service); + engine.lastSync = 123; // needs to be non-zero so that tracker is queried + engine.lastSyncLocal = 456; + engine._store.items = { + flying: "LNER Class A3 4472", + scotsman: "Flying Scotsman", + peppercorn: "Peppercorn Class" + }; + const FLYING_CHANGED = 12345; + const SCOTSMAN_CHANGED = 23456; + const PEPPERCORN_CHANGED = 34567; + engine._tracker.addChangedID("flying", FLYING_CHANGED); + engine._tracker.addChangedID("scotsman", SCOTSMAN_CHANGED); + engine._tracker.addChangedID("peppercorn", PEPPERCORN_CHANGED); + + let meta_global = Service.recordManager.set(engine.metaURL, new WBORecord(engine.metaURL)); + meta_global.payload.engines = { rotary: { version: engine.version, syncID: engine.syncID } }; + + try { + engine.enabled = true; + let ping = yield sync_engine_and_validate_telem(engine, true); + ok(!!ping); + equal(ping.engines.length, 1); + equal(ping.engines[0].incoming, null); + deepEqual(ping.engines[0].outgoing, [{ sent: 3, failed: 2 }]); + engine.lastSync = 123; + engine.lastSyncLocal = 456; + + ping = yield sync_engine_and_validate_telem(engine, true); + ok(!!ping); + equal(ping.engines.length, 1); + equal(ping.engines[0].incoming.reconciled, 1); + deepEqual(ping.engines[0].outgoing, [{ sent: 2, failed: 2 }]); + + } finally { + yield cleanAndGo(server); + } +}); + +add_task(function *test_sync_partialUpload() { + Service.identity.username = "foo"; + + let collection = new ServerCollection(); + let server = sync_httpd_setup({ + "/1.1/foo/storage/rotary": collection.handler() + }); + let syncTesting = new SyncTestingInfrastructure(server); + generateNewKeys(Service.collectionKeys); + + let engine = new RotaryEngine(Service); + engine.lastSync = 123; + engine.lastSyncLocal = 456; + + + // Create a bunch of records (and server side handlers) + for (let i = 0; i < 234; i++) { + let id = 'record-no-' + i; + engine._store.items[id] = "Record No. " + i; + engine._tracker.addChangedID(id, i); + // Let two items in the first upload batch fail. + if (i != 23 && i != 42) { + collection.insert(id); + } + } + + let meta_global = Service.recordManager.set(engine.metaURL, + new WBORecord(engine.metaURL)); + meta_global.payload.engines = {rotary: {version: engine.version, + syncID: engine.syncID}}; + + try { + engine.enabled = true; + let ping = yield sync_engine_and_validate_telem(engine, true); + + ok(!!ping); + ok(!ping.failureReason); + equal(ping.engines.length, 1); + equal(ping.engines[0].name, "rotary"); + ok(!ping.engines[0].incoming); + ok(!ping.engines[0].failureReason); + deepEqual(ping.engines[0].outgoing, [{ sent: 234, failed: 2 }]); + + collection.post = function() { throw "Failure"; } + + engine._store.items["record-no-1000"] = "Record No. 1000"; + engine._tracker.addChangedID("record-no-1000", 1000); + collection.insert("record-no-1000", 1000); + + engine.lastSync = 123; + engine.lastSyncLocal = 456; + ping = null; + + try { + // should throw + yield sync_engine_and_validate_telem(engine, true, errPing => ping = errPing); + } catch (e) {} + // It would be nice if we had a more descriptive error for this... + let uploadFailureError = { + name: "othererror", + error: "error.engine.reason.record_upload_fail" + }; + + ok(!!ping); + deepEqual(ping.failureReason, uploadFailureError); + equal(ping.engines.length, 1); + equal(ping.engines[0].name, "rotary"); + deepEqual(ping.engines[0].incoming, { + failed: 1, + newFailed: 1, + reconciled: 232 + }); + ok(!ping.engines[0].outgoing); + deepEqual(ping.engines[0].failureReason, uploadFailureError); + + } finally { + yield cleanAndGo(server); + } +}); + +add_task(function* test_generic_engine_fail() { + Service.engineManager.register(SteamEngine); + let engine = Service.engineManager.get("steam"); + engine.enabled = true; + let store = engine._store; + let server = serverForUsers({"foo": "password"}, { + meta: {global: {engines: {steam: {version: engine.version, + syncID: engine.syncID}}}}, + steam: {} + }); + new SyncTestingInfrastructure(server.server); + let e = new Error("generic failure message") + engine._errToThrow = e; + + try { + let ping = yield sync_and_validate_telem(true); + equal(ping.status.service, SYNC_FAILED_PARTIAL); + deepEqual(ping.engines.find(e => e.name === "steam").failureReason, { + name: "unexpectederror", + error: String(e) + }); + } finally { + Service.engineManager.unregister(engine); + yield cleanAndGo(server); + } +}); + +add_task(function* test_engine_fail_ioerror() { + Service.engineManager.register(SteamEngine); + let engine = Service.engineManager.get("steam"); + engine.enabled = true; + let store = engine._store; + let server = serverForUsers({"foo": "password"}, { + meta: {global: {engines: {steam: {version: engine.version, + syncID: engine.syncID}}}}, + steam: {} + }); + new SyncTestingInfrastructure(server.server); + // create an IOError to re-throw as part of Sync. + try { + // (Note that fakeservices.js has replaced Utils.jsonMove etc, but for + // this test we need the real one so we get real exceptions from the + // filesystem.) + yield Utils._real_jsonMove("file-does-not-exist", "anything", {}); + } catch (ex) { + engine._errToThrow = ex; + } + ok(engine._errToThrow, "expecting exception"); + + try { + let ping = yield sync_and_validate_telem(true); + equal(ping.status.service, SYNC_FAILED_PARTIAL); + let failureReason = ping.engines.find(e => e.name === "steam").failureReason; + equal(failureReason.name, "unexpectederror"); + // ensure the profile dir in the exception message has been stripped. + ok(!failureReason.error.includes(OS.Constants.Path.profileDir), failureReason.error); + ok(failureReason.error.includes("[profileDir]"), failureReason.error); + } finally { + Service.engineManager.unregister(engine); + yield cleanAndGo(server); + } +}); + +add_task(function* test_initial_sync_engines() { + Service.engineManager.register(SteamEngine); + let engine = Service.engineManager.get("steam"); + engine.enabled = true; + let store = engine._store; + let engines = {}; + // These are the only ones who actually have things to sync at startup. + let engineNames = ["clients", "bookmarks", "prefs", "tabs"]; + let conf = { meta: { global: { engines } } }; + for (let e of engineNames) { + engines[e] = { version: engine.version, syncID: engine.syncID }; + conf[e] = {}; + } + let server = serverForUsers({"foo": "password"}, conf); + new SyncTestingInfrastructure(server.server); + try { + let ping = yield wait_for_ping(() => Service.sync(), true); + + equal(ping.engines.find(e => e.name === "clients").outgoing[0].sent, 1); + equal(ping.engines.find(e => e.name === "tabs").outgoing[0].sent, 1); + + // for the rest we don't care about specifics + for (let e of ping.engines) { + if (!engineNames.includes(engine.name)) { + continue; + } + greaterOrEqual(e.took, 1); + ok(!!e.outgoing) + equal(e.outgoing.length, 1); + notEqual(e.outgoing[0].sent, undefined); + equal(e.outgoing[0].failed, undefined); + } + } finally { + yield cleanAndGo(server); + } +}); + +add_task(function* test_nserror() { + Service.engineManager.register(SteamEngine); + let engine = Service.engineManager.get("steam"); + engine.enabled = true; + let store = engine._store; + let server = serverForUsers({"foo": "password"}, { + meta: {global: {engines: {steam: {version: engine.version, + syncID: engine.syncID}}}}, + steam: {} + }); + new SyncTestingInfrastructure(server.server); + engine._errToThrow = Components.Exception("NS_ERROR_UNKNOWN_HOST", Cr.NS_ERROR_UNKNOWN_HOST); + try { + let ping = yield sync_and_validate_telem(true); + deepEqual(ping.status, { + service: SYNC_FAILED_PARTIAL, + sync: LOGIN_FAILED_NETWORK_ERROR + }); + let enginePing = ping.engines.find(e => e.name === "steam"); + deepEqual(enginePing.failureReason, { + name: "nserror", + code: Cr.NS_ERROR_UNKNOWN_HOST + }); + } finally { + Service.engineManager.unregister(engine); + yield cleanAndGo(server); + } +}); + +add_identity_test(this, function *test_discarding() { + let helper = track_collections_helper(); + let upd = helper.with_updated_collection; + let telem = get_sync_test_telemetry(); + telem.maxPayloadCount = 2; + telem.submissionInterval = Infinity; + let oldSubmit = telem.submit; + + let server; + try { + + yield configureIdentity({ username: "johndoe" }); + let handlers = { + "/1.1/johndoe/info/collections": helper.handler, + "/1.1/johndoe/storage/crypto/keys": upd("crypto", new ServerWBO("keys").handler()), + "/1.1/johndoe/storage/meta/global": upd("meta", new ServerWBO("global").handler()) + }; + + let collections = ["clients", "bookmarks", "forms", "history", "passwords", "prefs", "tabs"]; + + for (let coll of collections) { + handlers["/1.1/johndoe/storage/" + coll] = upd(coll, new ServerCollection({}, true).handler()); + } + + server = httpd_setup(handlers); + Service.serverURL = server.baseURI; + telem.submit = () => ok(false, "Submitted telemetry ping when we should not have"); + + for (let i = 0; i < 5; ++i) { + Service.sync(); + } + telem.submit = oldSubmit; + telem.submissionInterval = -1; + let ping = yield sync_and_validate_telem(true, true); // with this we've synced 6 times + equal(ping.syncs.length, 2); + equal(ping.discarded, 4); + } finally { + telem.maxPayloadCount = 500; + telem.submissionInterval = -1; + telem.submit = oldSubmit; + if (server) { + yield new Promise(resolve => server.stop(resolve)); + } + } +}) + +add_task(function* test_no_foreign_engines_in_error_ping() { + Service.engineManager.register(BogusEngine); + let engine = Service.engineManager.get("bogus"); + engine.enabled = true; + let store = engine._store; + let server = serverForUsers({"foo": "password"}, { + meta: {global: {engines: {bogus: {version: engine.version, syncID: engine.syncID}}}}, + steam: {} + }); + engine._errToThrow = new Error("Oh no!"); + new SyncTestingInfrastructure(server.server); + try { + let ping = yield sync_and_validate_telem(true); + equal(ping.status.service, SYNC_FAILED_PARTIAL); + ok(ping.engines.every(e => e.name !== "bogus")); + } finally { + Service.engineManager.unregister(engine); + yield cleanAndGo(server); + } +}); + +add_task(function* test_sql_error() { + Service.engineManager.register(SteamEngine); + let engine = Service.engineManager.get("steam"); + engine.enabled = true; + let store = engine._store; + let server = serverForUsers({"foo": "password"}, { + meta: {global: {engines: {steam: {version: engine.version, + syncID: engine.syncID}}}}, + steam: {} + }); + new SyncTestingInfrastructure(server.server); + engine._sync = function() { + // Just grab a DB connection and issue a bogus SQL statement synchronously. + let db = PlacesUtils.history.QueryInterface(Ci.nsPIPlacesDatabase).DBConnection; + Async.querySpinningly(db.createAsyncStatement("select bar from foo")); + }; + try { + let ping = yield sync_and_validate_telem(true); + let enginePing = ping.engines.find(e => e.name === "steam"); + deepEqual(enginePing.failureReason, { name: "sqlerror", code: 1 }); + } finally { + Service.engineManager.unregister(engine); + yield cleanAndGo(server); + } +}); + +add_task(function* test_no_foreign_engines_in_success_ping() { + Service.engineManager.register(BogusEngine); + let engine = Service.engineManager.get("bogus"); + engine.enabled = true; + let store = engine._store; + let server = serverForUsers({"foo": "password"}, { + meta: {global: {engines: {bogus: {version: engine.version, syncID: engine.syncID}}}}, + steam: {} + }); + + new SyncTestingInfrastructure(server.server); + try { + let ping = yield sync_and_validate_telem(); + ok(ping.engines.every(e => e.name !== "bogus")); + } finally { + Service.engineManager.unregister(engine); + yield cleanAndGo(server); + } +});
\ No newline at end of file diff --git a/services/sync/tests/unit/test_tracker_addChanged.js b/services/sync/tests/unit/test_tracker_addChanged.js new file mode 100644 index 0000000000..e73bd1162f --- /dev/null +++ b/services/sync/tests/unit/test_tracker_addChanged.js @@ -0,0 +1,59 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +Cu.import("resource://services-sync/engines.js"); +Cu.import("resource://services-sync/service.js"); +Cu.import("resource://services-sync/util.js"); + +function run_test() { + run_next_test(); +} + +add_test(function test_tracker_basics() { + let tracker = new Tracker("Tracker", Service); + tracker.persistChangedIDs = false; + + let id = "the_id!"; + + _("Make sure nothing exists yet.."); + do_check_eq(tracker.changedIDs[id], null); + + _("Make sure adding of time 0 works"); + tracker.addChangedID(id, 0); + do_check_eq(tracker.changedIDs[id], 0); + + _("A newer time will replace the old 0"); + tracker.addChangedID(id, 10); + do_check_eq(tracker.changedIDs[id], 10); + + _("An older time will not replace the newer 10"); + tracker.addChangedID(id, 5); + do_check_eq(tracker.changedIDs[id], 10); + + _("Adding without time defaults to current time"); + tracker.addChangedID(id); + do_check_true(tracker.changedIDs[id] > 10); + + run_next_test(); +}); + +add_test(function test_tracker_persistence() { + let tracker = new Tracker("Tracker", Service); + let id = "abcdef"; + + tracker.persistChangedIDs = true; + tracker.onSavedChangedIDs = function () { + _("IDs saved."); + do_check_eq(5, tracker.changedIDs[id]); + + // Verify the write by reading the file back. + Utils.jsonLoad("changes/tracker", this, function (json) { + do_check_eq(5, json[id]); + tracker.persistChangedIDs = false; + delete tracker.onSavedChangedIDs; + run_next_test(); + }); + }; + + tracker.addChangedID(id, 5); +}); diff --git a/services/sync/tests/unit/test_upgrade_old_sync_key.js b/services/sync/tests/unit/test_upgrade_old_sync_key.js new file mode 100644 index 0000000000..ff75a435ad --- /dev/null +++ b/services/sync/tests/unit/test_upgrade_old_sync_key.js @@ -0,0 +1,49 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +Cu.import("resource://services-sync/constants.js"); +Cu.import("resource://services-sync/service.js"); +Cu.import("resource://services-sync/util.js"); +Cu.import("resource://testing-common/services/sync/utils.js"); + +// Test upgrade of a dashed old-style sync key. +function run_test() { + const PBKDF2_KEY_BYTES = 16; + initTestLogging("Trace"); + ensureLegacyIdentityManager(); + + let passphrase = "abcde-abcde-abcde-abcde"; + do_check_false(Utils.isPassphrase(passphrase)); + + let normalized = Utils.normalizePassphrase(passphrase); + _("Normalized: " + normalized); + + // Still not a modern passphrase... + do_check_false(Utils.isPassphrase(normalized)); + + // ... but different. + do_check_neq(normalized, passphrase); + do_check_eq(normalized, "abcdeabcdeabcdeabcde"); + + // Now run through the upgrade. + Service.identity.account = "johndoe"; + Service.syncID = "1234567890"; + Service.identity.syncKey = normalized; // UI normalizes. + do_check_false(Utils.isPassphrase(Service.identity.syncKey)); + Service.upgradeSyncKey(Service.syncID); + let upgraded = Service.identity.syncKey; + _("Upgraded: " + upgraded); + do_check_true(Utils.isPassphrase(upgraded)); + + // Now let's verify that it's been derived correctly, from the normalized + // version, and the encoded sync ID. + _("Sync ID: " + Service.syncID); + let derivedKeyStr = + Utils.derivePresentableKeyFromPassphrase(normalized, + btoa(Service.syncID), + PBKDF2_KEY_BYTES, true); + _("Derived: " + derivedKeyStr); + + // Success! + do_check_eq(derivedKeyStr, upgraded); +} diff --git a/services/sync/tests/unit/test_utils_catch.js b/services/sync/tests/unit/test_utils_catch.js new file mode 100644 index 0000000000..5f50bf7e43 --- /dev/null +++ b/services/sync/tests/unit/test_utils_catch.js @@ -0,0 +1,94 @@ +Cu.import("resource://services-sync/util.js"); +Cu.import("resource://services-sync/service.js"); + +function run_test() { + _("Make sure catch when copied to an object will correctly catch stuff"); + let ret, rightThis, didCall, didThrow, wasTen, wasLocked; + let obj = { + catch: Utils.catch, + _log: { + debug: function(str) { + didThrow = str.search(/^Exception/) == 0; + }, + info: function(str) { + wasLocked = str.indexOf("Cannot start sync: already syncing?") == 0; + } + }, + + func: function() { + return this.catch(function() { + rightThis = this == obj; + didCall = true; + return 5; + })(); + }, + + throwy: function() { + return this.catch(function() { + rightThis = this == obj; + didCall = true; + throw 10; + })(); + }, + + callbacky: function() { + return this.catch(function() { + rightThis = this == obj; + didCall = true; + throw 10; + }, function(ex) { + wasTen = (ex == 10) + })(); + }, + + lockedy: function() { + return this.catch(function() { + rightThis = this == obj; + didCall = true; + throw("Could not acquire lock."); + })(); + } + }; + + _("Make sure a normal call will call and return"); + rightThis = didCall = didThrow = wasLocked = false; + ret = obj.func(); + do_check_eq(ret, 5); + do_check_true(rightThis); + do_check_true(didCall); + do_check_false(didThrow); + do_check_eq(wasTen, undefined); + do_check_false(wasLocked); + + _("Make sure catch/throw results in debug call and caller doesn't need to handle exception"); + rightThis = didCall = didThrow = wasLocked = false; + ret = obj.throwy(); + do_check_eq(ret, undefined); + do_check_true(rightThis); + do_check_true(didCall); + do_check_true(didThrow); + do_check_eq(wasTen, undefined); + do_check_false(wasLocked); + + _("Test callback for exception testing."); + rightThis = didCall = didThrow = wasLocked = false; + ret = obj.callbacky(); + do_check_eq(ret, undefined); + do_check_true(rightThis); + do_check_true(didCall); + do_check_true(didThrow); + do_check_true(wasTen); + do_check_false(wasLocked); + + _("Test the lock-aware catch that Service uses."); + obj.catch = Service._catch; + rightThis = didCall = didThrow = wasLocked = false; + wasTen = undefined; + ret = obj.lockedy(); + do_check_eq(ret, undefined); + do_check_true(rightThis); + do_check_true(didCall); + do_check_true(didThrow); + do_check_eq(wasTen, undefined); + do_check_true(wasLocked); +} diff --git a/services/sync/tests/unit/test_utils_deepEquals.js b/services/sync/tests/unit/test_utils_deepEquals.js new file mode 100644 index 0000000000..c75fa0cfa2 --- /dev/null +++ b/services/sync/tests/unit/test_utils_deepEquals.js @@ -0,0 +1,44 @@ +_("Make sure Utils.deepEquals correctly finds items that are deeply equal"); +Cu.import("resource://services-sync/util.js"); + +function run_test() { + let data = '[NaN, undefined, null, true, false, Infinity, 0, 1, "a", "b", {a: 1}, {a: "a"}, [{a: 1}], [{a: true}], {a: 1, b: 2}, [1, 2], [1, 2, 3]]'; + _("Generating two copies of data:", data); + let d1 = eval(data); + let d2 = eval(data); + + d1.forEach(function(a) { + _("Testing", a, typeof a, JSON.stringify([a])); + let numMatch = 0; + + d2.forEach(function(b) { + if (Utils.deepEquals(a, b)) { + numMatch++; + _("Found a match", b, typeof b, JSON.stringify([b])); + } + }); + + let expect = 1; + if (isNaN(a) && typeof a == "number") { + expect = 0; + _("Checking NaN should result in no matches"); + } + + _("Making sure we found the correct # match:", expect); + _("Actual matches:", numMatch); + do_check_eq(numMatch, expect); + }); + + _("Make sure adding undefined properties doesn't affect equalness"); + let a = {}; + let b = { a: undefined }; + do_check_true(Utils.deepEquals(a, b)); + a.b = 5; + do_check_false(Utils.deepEquals(a, b)); + b.b = 5; + do_check_true(Utils.deepEquals(a, b)); + a.c = undefined; + do_check_true(Utils.deepEquals(a, b)); + b.d = undefined; + do_check_true(Utils.deepEquals(a, b)); +} diff --git a/services/sync/tests/unit/test_utils_deferGetSet.js b/services/sync/tests/unit/test_utils_deferGetSet.js new file mode 100644 index 0000000000..9d58a9873e --- /dev/null +++ b/services/sync/tests/unit/test_utils_deferGetSet.js @@ -0,0 +1,49 @@ +_("Make sure various combinations of deferGetSet arguments correctly defer getting/setting properties to another object"); +Cu.import("resource://services-sync/util.js"); + +function run_test() { + let base = function() {}; + base.prototype = { + dst: {}, + + get a() { + return "a"; + }, + set b(val) { + this.dst.b = val + "!!!"; + } + }; + let src = new base(); + + _("get/set a single property"); + Utils.deferGetSet(base, "dst", "foo"); + src.foo = "bar"; + do_check_eq(src.dst.foo, "bar"); + do_check_eq(src.foo, "bar"); + + _("editing the target also updates the source"); + src.dst.foo = "baz"; + do_check_eq(src.dst.foo, "baz"); + do_check_eq(src.foo, "baz"); + + _("handle multiple properties"); + Utils.deferGetSet(base, "dst", ["p1", "p2"]); + src.p1 = "v1"; + src.p2 = "v2"; + do_check_eq(src.p1, "v1"); + do_check_eq(src.dst.p1, "v1"); + do_check_eq(src.p2, "v2"); + do_check_eq(src.dst.p2, "v2"); + + _("make sure existing getter keeps its functionality"); + Utils.deferGetSet(base, "dst", "a"); + src.a = "not a"; + do_check_eq(src.dst.a, "not a"); + do_check_eq(src.a, "a"); + + _("make sure existing setter keeps its functionality"); + Utils.deferGetSet(base, "dst", "b"); + src.b = "b"; + do_check_eq(src.dst.b, "b!!!"); + do_check_eq(src.b, "b!!!"); +} diff --git a/services/sync/tests/unit/test_utils_deriveKey.js b/services/sync/tests/unit/test_utils_deriveKey.js new file mode 100644 index 0000000000..17dd889c70 --- /dev/null +++ b/services/sync/tests/unit/test_utils_deriveKey.js @@ -0,0 +1,66 @@ +Cu.import("resource://services-crypto/WeaveCrypto.js"); +Cu.import("resource://services-sync/util.js"); + +var cryptoSvc = new WeaveCrypto(); + +function run_test() { + if (this.gczeal) { + _("Running deriveKey tests with gczeal(2)."); + gczeal(2); + } else { + _("Running deriveKey tests with default gczeal."); + } + + var iv = cryptoSvc.generateRandomIV(); + var der_passphrase = "secret phrase"; + var der_salt = "RE5YUHpQcGl3bg=="; // btoa("DNXPzPpiwn") + + _("Testing deriveKeyFromPassphrase. Input is \"" + der_passphrase + "\", \"" + der_salt + "\" (base64-encoded)."); + + // Test friendly-ing. + do_check_eq("abcdefghijk8mn9pqrstuvwxyz234567", + Utils.base32ToFriendly("ABCDEFGHIJKLMNOPQRSTUVWXYZ234567")); + do_check_eq("ABCDEFGHIJKLMNOPQRSTUVWXYZ234567", + Utils.base32FromFriendly( + Utils.base32ToFriendly("ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"))); + + // Test translation. + do_check_false(Utils.isPassphrase("o-5wmnu-o5tqc-7lz2h-amkbw-izqzi")); // Wrong charset. + do_check_false(Utils.isPassphrase("O-5WMNU-O5TQC-7LZ2H-AMKBW-IZQZI")); // Wrong charset. + do_check_true(Utils.isPassphrase("9-5wmnu-95tqc-78z2h-amkbw-izqzi")); + do_check_true(Utils.isPassphrase("9-5WMNU-95TQC-78Z2H-AMKBW-IZQZI")); // isPassphrase normalizes. + do_check_true(Utils.isPassphrase( + Utils.normalizePassphrase("9-5WMNU-95TQC-78Z2H-AMKBW-IZQZI"))); + + // Base64. We don't actually use this in anger, particularly not with a 32-byte key. + var der_key = Utils.deriveEncodedKeyFromPassphrase(der_passphrase, der_salt); + _("Derived key in base64: " + der_key); + do_check_eq(cryptoSvc.decrypt(cryptoSvc.encrypt("bacon", der_key, iv), der_key, iv), "bacon"); + + // Base64, 16-byte output. + var der_key = Utils.deriveEncodedKeyFromPassphrase(der_passphrase, der_salt, 16); + _("Derived key in base64: " + der_key); + do_check_eq("d2zG0d2cBfXnRwMUGyMwyg==", der_key); + do_check_eq(cryptoSvc.decrypt(cryptoSvc.encrypt("bacon", der_key, iv), der_key, iv), "bacon"); + + // Base32. Again, specify '16' to avoid it generating a 256-bit key string. + var b32key = Utils.derivePresentableKeyFromPassphrase(der_passphrase, der_salt, 16); + var hyphenated = Utils.hyphenatePassphrase(b32key); + do_check_true(Utils.isPassphrase(b32key)); + + _("Derived key in base32: " + b32key); + do_check_eq(b32key.length, 26); + do_check_eq(hyphenated.length, 31); // 1 char, plus 5 groups of 5, hyphenated = 5 + (5*5) + 1 = 31. + do_check_eq(hyphenated, "9-5wmnu-95tqc-78z2h-amkbw-izqzi"); + + if (this.gczeal) + gczeal(0); + + // Test the equivalence of our NSS and JS versions. + // Will only work on FF4, of course. + // Note that we don't add gczeal here: the pure-JS implementation is + // astonishingly slow, and this check takes five minutes to run. + do_check_eq( + Utils.deriveEncodedKeyFromPassphrase(der_passphrase, der_salt, 16, false), + Utils.deriveEncodedKeyFromPassphrase(der_passphrase, der_salt, 16, true)); +} diff --git a/services/sync/tests/unit/test_utils_getErrorString.js b/services/sync/tests/unit/test_utils_getErrorString.js new file mode 100644 index 0000000000..d64e435402 --- /dev/null +++ b/services/sync/tests/unit/test_utils_getErrorString.js @@ -0,0 +1,14 @@ +Cu.import("resource://services-sync/util.js"); + +function run_test() { + let str; + + // we just test whether the returned string includes the + // string "unknown", should be good enough + + str = Utils.getErrorString("error.login.reason.account"); + do_check_true(str.match(/unknown/i) == null); + + str = Utils.getErrorString("foobar"); + do_check_true(str.match(/unknown/i) != null); +} diff --git a/services/sync/tests/unit/test_utils_json.js b/services/sync/tests/unit/test_utils_json.js new file mode 100644 index 0000000000..efa7d9b4de --- /dev/null +++ b/services/sync/tests/unit/test_utils_json.js @@ -0,0 +1,114 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +Cu.import("resource://gre/modules/FileUtils.jsm"); +Cu.import("resource://services-sync/util.js"); + +function run_test() { + initTestLogging(); + run_next_test(); +} + +add_test(function test_roundtrip() { + _("Do a simple write of an array to json and read"); + Utils.jsonSave("foo", {}, ["v1", "v2"], ensureThrows(function(error) { + do_check_eq(error, null); + + Utils.jsonLoad("foo", {}, ensureThrows(function(val) { + let foo = val; + do_check_eq(typeof foo, "object"); + do_check_eq(foo.length, 2); + do_check_eq(foo[0], "v1"); + do_check_eq(foo[1], "v2"); + run_next_test(); + })); + })); +}); + +add_test(function test_string() { + _("Try saving simple strings"); + Utils.jsonSave("str", {}, "hi", ensureThrows(function(error) { + do_check_eq(error, null); + + Utils.jsonLoad("str", {}, ensureThrows(function(val) { + let str = val; + do_check_eq(typeof str, "string"); + do_check_eq(str.length, 2); + do_check_eq(str[0], "h"); + do_check_eq(str[1], "i"); + run_next_test(); + })); + })); +}); + +add_test(function test_number() { + _("Try saving a number"); + Utils.jsonSave("num", {}, 42, ensureThrows(function(error) { + do_check_eq(error, null); + + Utils.jsonLoad("num", {}, ensureThrows(function(val) { + let num = val; + do_check_eq(typeof num, "number"); + do_check_eq(num, 42); + run_next_test(); + })); + })); +}); + +add_test(function test_nonexistent_file() { + _("Try loading a non-existent file."); + Utils.jsonLoad("non-existent", {}, ensureThrows(function(val) { + do_check_eq(val, undefined); + run_next_test(); + })); +}); + +add_test(function test_save_logging() { + _("Verify that writes are logged."); + let trace; + Utils.jsonSave("log", {_log: {trace: function(msg) { trace = msg; }}}, + "hi", ensureThrows(function () { + do_check_true(!!trace); + run_next_test(); + })); +}); + +add_test(function test_load_logging() { + _("Verify that reads and read errors are logged."); + + // Write a file with some invalid JSON + let filePath = "weave/log.json"; + let file = FileUtils.getFile("ProfD", filePath.split("/"), true); + let fos = Cc["@mozilla.org/network/file-output-stream;1"] + .createInstance(Ci.nsIFileOutputStream); + let flags = FileUtils.MODE_WRONLY | FileUtils.MODE_CREATE + | FileUtils.MODE_TRUNCATE; + fos.init(file, flags, FileUtils.PERMS_FILE, fos.DEFER_OPEN); + let stream = Cc["@mozilla.org/intl/converter-output-stream;1"] + .createInstance(Ci.nsIConverterOutputStream); + stream.init(fos, "UTF-8", 4096, 0x0000); + stream.writeString("invalid json!"); + stream.close(); + + let trace, debug; + let obj = { + _log: { + trace: function(msg) { + trace = msg; + }, + debug: function(msg) { + debug = msg; + } + } + }; + Utils.jsonLoad("log", obj, ensureThrows(function(val) { + do_check_true(!val); + do_check_true(!!trace); + do_check_true(!!debug); + run_next_test(); + })); +}); + +add_task(function* test_undefined_callback() { + yield Utils.jsonSave("foo", {}, ["v1", "v2"]); +}); diff --git a/services/sync/tests/unit/test_utils_keyEncoding.js b/services/sync/tests/unit/test_utils_keyEncoding.js new file mode 100644 index 0000000000..0b39c15755 --- /dev/null +++ b/services/sync/tests/unit/test_utils_keyEncoding.js @@ -0,0 +1,15 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +Cu.import("resource://services-sync/util.js"); + +function run_test() { + do_check_eq(Utils.encodeKeyBase32("foobarbafoobarba"), "mzxw6ytb9jrgcztpn5rgc4tcme"); + do_check_eq(Utils.decodeKeyBase32("mzxw6ytb9jrgcztpn5rgc4tcme"), "foobarbafoobarba"); + do_check_eq( + Utils.encodeKeyBase32("\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01"), + "aeaqcaibaeaqcaibaeaqcaibae"); + do_check_eq( + Utils.decodeKeyBase32("aeaqcaibaeaqcaibaeaqcaibae"), + "\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01"); +} diff --git a/services/sync/tests/unit/test_utils_lazyStrings.js b/services/sync/tests/unit/test_utils_lazyStrings.js new file mode 100644 index 0000000000..68f9b35747 --- /dev/null +++ b/services/sync/tests/unit/test_utils_lazyStrings.js @@ -0,0 +1,14 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +Cu.import("resource://services-common/stringbundle.js"); +Cu.import("resource://services-sync/util.js"); + +function run_test() { + let fn = Utils.lazyStrings("sync"); + do_check_eq(typeof fn, "function"); + let bundle = fn(); + do_check_true(bundle instanceof StringBundle); + let url = bundle.url; + do_check_eq(url, "chrome://weave/locale/services/sync.properties"); +} diff --git a/services/sync/tests/unit/test_utils_lock.js b/services/sync/tests/unit/test_utils_lock.js new file mode 100644 index 0000000000..d1830787e6 --- /dev/null +++ b/services/sync/tests/unit/test_utils_lock.js @@ -0,0 +1,79 @@ +_("Make sure lock prevents calling with a shared lock"); +Cu.import("resource://services-sync/util.js"); + +// Utility that we only use here. + +function do_check_begins(thing, startsWith) { + if (!(thing && thing.indexOf && (thing.indexOf(startsWith) == 0))) + do_throw(thing + " doesn't begin with " + startsWith); +} + +function run_test() { + let ret, rightThis, didCall; + let state, lockState, lockedState, unlockState; + let obj = { + _lock: Utils.lock, + lock: function() { + lockState = ++state; + if (this._locked) { + lockedState = ++state; + return false; + } + this._locked = true; + return true; + }, + unlock: function() { + unlockState = ++state; + this._locked = false; + }, + + func: function() { + return this._lock("Test utils lock", + function() { + rightThis = this == obj; + didCall = true; + return 5; + })(); + }, + + throwy: function() { + return this._lock("Test utils lock throwy", + function() { + rightThis = this == obj; + didCall = true; + this.throwy(); + })(); + } + }; + + _("Make sure a normal call will call and return"); + rightThis = didCall = false; + state = 0; + ret = obj.func(); + do_check_eq(ret, 5); + do_check_true(rightThis); + do_check_true(didCall); + do_check_eq(lockState, 1); + do_check_eq(unlockState, 2); + do_check_eq(state, 2); + + _("Make sure code that calls locked code throws"); + ret = null; + rightThis = didCall = false; + try { + ret = obj.throwy(); + do_throw("throwy internal call should have thrown!"); + } + catch(ex) { + // Should throw an Error, not a string. + do_check_begins(ex, "Could not acquire lock"); + } + do_check_eq(ret, null); + do_check_true(rightThis); + do_check_true(didCall); + _("Lock should be called twice so state 3 is skipped"); + do_check_eq(lockState, 4); + do_check_eq(lockedState, 5); + do_check_eq(unlockState, 6); + do_check_eq(state, 6); +} diff --git a/services/sync/tests/unit/test_utils_makeGUID.js b/services/sync/tests/unit/test_utils_makeGUID.js new file mode 100644 index 0000000000..7ce6728b79 --- /dev/null +++ b/services/sync/tests/unit/test_utils_makeGUID.js @@ -0,0 +1,40 @@ +Cu.import("resource://services-sync/util.js"); + +const base64url = + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_"; + +function run_test() { + _("Make sure makeGUID makes guids of the right length/characters"); + _("Create a bunch of guids to make sure they don't conflict"); + let guids = []; + for (let i = 0; i < 1000; i++) { + let newGuid = Utils.makeGUID(); + _("Generated " + newGuid); + + // Verify that the GUID's length is correct, even when it's URL encoded. + do_check_eq(newGuid.length, 12); + do_check_eq(encodeURIComponent(newGuid).length, 12); + + // Verify that the GUID only contains base64url characters + do_check_true(Array.every(newGuid, function(chr) { + return base64url.indexOf(chr) != -1; + })); + + // Verify that Utils.checkGUID() correctly identifies them as valid. + do_check_true(Utils.checkGUID(newGuid)); + + // Verify uniqueness within our sample of 1000. This could cause random + // failures, but they should be extremely rare. Otherwise we'd have a + // problem with GUID collisions. + do_check_true(guids.every(function(g) { return g != newGuid; })); + guids.push(newGuid); + } + + _("Make sure checkGUID fails for invalid GUIDs"); + do_check_false(Utils.checkGUID(undefined)); + do_check_false(Utils.checkGUID(null)); + do_check_false(Utils.checkGUID("")); + do_check_false(Utils.checkGUID("blergh")); + do_check_false(Utils.checkGUID("ThisGUIDisWayTooLong")); + do_check_false(Utils.checkGUID("Invalid!!!!!")); +} diff --git a/services/sync/tests/unit/test_utils_notify.js b/services/sync/tests/unit/test_utils_notify.js new file mode 100644 index 0000000000..5bd38da5f2 --- /dev/null +++ b/services/sync/tests/unit/test_utils_notify.js @@ -0,0 +1,100 @@ +_("Make sure notify sends out the right notifications"); +Cu.import("resource://services-sync/util.js"); + +function run_test() { + let ret, rightThis, didCall; + let obj = { + notify: Utils.notify("foo:"), + _log: { + trace: function() {} + }, + + func: function() { + return this.notify("bar", "baz", function() { + rightThis = this == obj; + didCall = true; + return 5; + })(); + }, + + throwy: function() { + return this.notify("bad", "one", function() { + rightThis = this == obj; + didCall = true; + throw 10; + })(); + } + }; + + let state = 0; + let makeObs = function(topic) { + let obj = { + observe: function(subject, topic, data) { + this.state = ++state; + this.subject = subject; + this.topic = topic; + this.data = data; + } + }; + + Svc.Obs.add(topic, obj); + return obj; + }; + + _("Make sure a normal call will call and return with notifications"); + rightThis = didCall = false; + let fs = makeObs("foo:bar:start"); + let ff = makeObs("foo:bar:finish"); + let fe = makeObs("foo:bar:error"); + ret = obj.func(); + do_check_eq(ret, 5); + do_check_true(rightThis); + do_check_true(didCall); + + do_check_eq(fs.state, 1); + do_check_eq(fs.subject, undefined); + do_check_eq(fs.topic, "foo:bar:start"); + do_check_eq(fs.data, "baz"); + + do_check_eq(ff.state, 2); + do_check_eq(ff.subject, 5); + do_check_eq(ff.topic, "foo:bar:finish"); + do_check_eq(ff.data, "baz"); + + do_check_eq(fe.state, undefined); + do_check_eq(fe.subject, undefined); + do_check_eq(fe.topic, undefined); + do_check_eq(fe.data, undefined); + + _("Make sure a throwy call will call and throw with notifications"); + ret = null; + rightThis = didCall = false; + let ts = makeObs("foo:bad:start"); + let tf = makeObs("foo:bad:finish"); + let te = makeObs("foo:bad:error"); + try { + ret = obj.throwy(); + do_throw("throwy should have thrown!"); + } + catch(ex) { + do_check_eq(ex, 10); + } + do_check_eq(ret, null); + do_check_true(rightThis); + do_check_true(didCall); + + do_check_eq(ts.state, 3); + do_check_eq(ts.subject, undefined); + do_check_eq(ts.topic, "foo:bad:start"); + do_check_eq(ts.data, "one"); + + do_check_eq(tf.state, undefined); + do_check_eq(tf.subject, undefined); + do_check_eq(tf.topic, undefined); + do_check_eq(tf.data, undefined); + + do_check_eq(te.state, 4); + do_check_eq(te.subject, 10); + do_check_eq(te.topic, "foo:bad:error"); + do_check_eq(te.data, "one"); +} diff --git a/services/sync/tests/unit/test_utils_passphrase.js b/services/sync/tests/unit/test_utils_passphrase.js new file mode 100644 index 0000000000..6d34697be4 --- /dev/null +++ b/services/sync/tests/unit/test_utils_passphrase.js @@ -0,0 +1,73 @@ +Cu.import("resource://services-sync/util.js"); + +function run_test() { + _("Generated passphrase has length 26."); + let pp = Utils.generatePassphrase(); + do_check_eq(pp.length, 26); + + const key = "abcdefghijkmnpqrstuvwxyz23456789"; + _("Passphrase only contains [" + key + "]."); + do_check_true(pp.split('').every(chr => key.indexOf(chr) != -1)); + + _("Hyphenated passphrase has 5 hyphens."); + let hyphenated = Utils.hyphenatePassphrase(pp); + _("H: " + hyphenated); + do_check_eq(hyphenated.length, 31); + do_check_eq(hyphenated[1], '-'); + do_check_eq(hyphenated[7], '-'); + do_check_eq(hyphenated[13], '-'); + do_check_eq(hyphenated[19], '-'); + do_check_eq(hyphenated[25], '-'); + do_check_eq(pp, + hyphenated.slice(0, 1) + hyphenated.slice(2, 7) + + hyphenated.slice(8, 13) + hyphenated.slice(14, 19) + + hyphenated.slice(20, 25) + hyphenated.slice(26, 31)); + + _("Arbitrary hyphenation."); + // We don't allow invalid characters for our base32 character set. + do_check_eq(Utils.hyphenatePassphrase("1234567"), "2-34567"); // Not partial, so no trailing dash. + do_check_eq(Utils.hyphenatePassphrase("1234567890"), "2-34567-89"); + do_check_eq(Utils.hyphenatePassphrase("abcdeabcdeabcdeabcdeabcde"), "a-bcdea-bcdea-bcdea-bcdea-bcde"); + do_check_eq(Utils.hyphenatePartialPassphrase("1234567"), "2-34567-"); + do_check_eq(Utils.hyphenatePartialPassphrase("1234567890"), "2-34567-89"); + do_check_eq(Utils.hyphenatePartialPassphrase("abcdeabcdeabcdeabcdeabcde"), "a-bcdea-bcdea-bcdea-bcdea-bcde"); + + do_check_eq(Utils.hyphenatePartialPassphrase("a"), "a-"); + do_check_eq(Utils.hyphenatePartialPassphrase("1234567"), "2-34567-"); + do_check_eq(Utils.hyphenatePartialPassphrase("a-bcdef-g"), + "a-bcdef-g"); + do_check_eq(Utils.hyphenatePartialPassphrase("abcdefghijklmnop"), + "a-bcdef-ghijk-mnp"); + do_check_eq(Utils.hyphenatePartialPassphrase("abcdefghijklmnopabcde"), + "a-bcdef-ghijk-mnpab-cde"); + do_check_eq(Utils.hyphenatePartialPassphrase("a-bcdef-ghijk-LMNOP-ABCDE-Fg"), + "a-bcdef-ghijk-mnpab-cdefg-"); + // Cuts off. + do_check_eq(Utils.hyphenatePartialPassphrase("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").length, 31); + + _("Normalize passphrase recognizes hyphens."); + do_check_eq(Utils.normalizePassphrase(hyphenated), pp); + + _("Skip whitespace."); + do_check_eq("aaaaaaaaaaaaaaaaaaaaaaaaaa", Utils.normalizePassphrase("aaaaaaaaaaaaaaaaaaaaaaaaaa ")); + do_check_eq("aaaaaaaaaaaaaaaaaaaaaaaaaa", Utils.normalizePassphrase(" aaaaaaaaaaaaaaaaaaaaaaaaaa")); + do_check_eq("aaaaaaaaaaaaaaaaaaaaaaaaaa", Utils.normalizePassphrase(" aaaaaaaaaaaaaaaaaaaaaaaaaa ")); + do_check_eq("aaaaaaaaaaaaaaaaaaaaaaaaaa", Utils.normalizePassphrase(" a-aaaaa-aaaaa-aaaaa-aaaaa-aaaaa ")); + do_check_true(Utils.isPassphrase("aaaaaaaaaaaaaaaaaaaaaaaaaa ")); + do_check_true(Utils.isPassphrase(" aaaaaaaaaaaaaaaaaaaaaaaaaa")); + do_check_true(Utils.isPassphrase(" aaaaaaaaaaaaaaaaaaaaaaaaaa ")); + do_check_true(Utils.isPassphrase(" a-aaaaa-aaaaa-aaaaa-aaaaa-aaaaa ")); + do_check_false(Utils.isPassphrase(" -aaaaa-aaaaa-aaaaa-aaaaa-aaaaa ")); + + _("Normalizing 20-char passphrases."); + do_check_eq(Utils.normalizePassphrase("abcde-abcde-abcde-abcde"), + "abcdeabcdeabcdeabcde"); + do_check_eq(Utils.normalizePassphrase("a-bcde-abcde-abcde-abcde"), + "a-bcde-abcde-abcde-abcde"); + do_check_eq(Utils.normalizePassphrase(" abcde-abcde-abcde-abcde "), + "abcdeabcdeabcdeabcde"); + + _("Normalizing username."); + do_check_eq(Utils.normalizeAccount(" QA1234+boo@mozilla.com "), "QA1234+boo@mozilla.com"); + do_check_eq(Utils.normalizeAccount("QA1234+boo@mozilla.com"), "QA1234+boo@mozilla.com"); +} diff --git a/services/sync/tests/unit/test_warn_on_truncated_response.js b/services/sync/tests/unit/test_warn_on_truncated_response.js new file mode 100644 index 0000000000..1f0d87ba96 --- /dev/null +++ b/services/sync/tests/unit/test_warn_on_truncated_response.js @@ -0,0 +1,95 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +Cu.import("resource://testing-common/httpd.js"); +Cu.import("resource://services-sync/resource.js"); +Cu.import("resource://services-sync/rest.js"); + +function run_test() { + initTestLogging("Trace"); + run_next_test(); +} + +var BODY = "response body"; +// contentLength needs to be longer than the response body +// length in order to get a mismatch between what is sent in +// the response and the content-length header value. +var contentLength = BODY.length + 1; + +function contentHandler(request, response) { + _("Handling request."); + response.setHeader("Content-Type", "text/plain"); + response.setStatusLine(request.httpVersion, 200, "OK"); + response.bodyOutputStream.write(BODY, contentLength); +} + +function getWarningMessages(log) { + let warnMessages = []; + let warn = log.warn; + log.warn = function (message) { + let regEx = /The response body\'s length of: \d+ doesn\'t match the header\'s content-length of: \d+/i + if (message.match(regEx)) { + warnMessages.push(message); + } + warn.call(log, message); + } + return warnMessages; +} + +add_test(function test_resource_logs_content_length_mismatch() { + _("Issuing request."); + let httpServer = httpd_setup({"/content": contentHandler}); + let resource = new Resource(httpServer.baseURI + "/content"); + + let warnMessages = getWarningMessages(resource._log); + let result = resource.get(); + + notEqual(warnMessages.length, 0, "test that a warning was logged"); + notEqual(result.length, contentLength); + equal(result, BODY); + + httpServer.stop(run_next_test); +}); + +add_test(function test_async_resource_logs_content_length_mismatch() { + _("Issuing request."); + let httpServer = httpd_setup({"/content": contentHandler}); + let asyncResource = new AsyncResource(httpServer.baseURI + "/content"); + + let warnMessages = getWarningMessages(asyncResource._log); + + asyncResource.get(function (error, content) { + equal(error, null); + equal(content, BODY); + notEqual(warnMessages.length, 0, "test that warning was logged"); + notEqual(content.length, contentLength); + httpServer.stop(run_next_test); + }); +}); + +add_test(function test_sync_storage_request_logs_content_length_mismatch() { + _("Issuing request."); + let httpServer = httpd_setup({"/content": contentHandler}); + let request = new SyncStorageRequest(httpServer.baseURI + "/content"); + let warnMessages = getWarningMessages(request._log); + + // Setting this affects how received data is read from the underlying + // nsIHttpChannel in rest.js. If it's left as UTF-8 (the default) an + // nsIConverterInputStream is used and the data read from channel's stream + // isn't truncated at the null byte mark (\u0000). Therefore the + // content-length mismatch being tested for doesn't occur. Setting it to + // a falsy value results in an nsIScriptableInputStream being used to read + // the stream, which stops reading at the null byte mark resulting in a + // content-length mismatch. + request.charset = ""; + + request.get(function (error) { + equal(error, null); + equal(this.response.body, BODY); + notEqual(warnMessages.length, 0, "test that a warning was logged"); + notEqual(BODY.length, contentLength); + httpServer.stop(run_next_test); + }); +}); diff --git a/services/sync/tests/unit/xpcshell.ini b/services/sync/tests/unit/xpcshell.ini new file mode 100644 index 0000000000..e5b32e7b15 --- /dev/null +++ b/services/sync/tests/unit/xpcshell.ini @@ -0,0 +1,200 @@ +[DEFAULT] +head = head_appinfo.js ../../../common/tests/unit/head_helpers.js head_helpers.js head_http_server.js head_errorhandler_common.js +tail = +firefox-appdir = browser +support-files = + addon1-search.xml + bootstrap1-search.xml + fake_login_manager.js + missing-sourceuri.xml + missing-xpi-search.xml + places_v10_from_v11.sqlite + rewrite-search.xml + sync_ping_schema.json + systemaddon-search.xml + !/services/common/tests/unit/head_helpers.js + !/toolkit/mozapps/extensions/test/xpcshell/head_addons.js + !/toolkit/components/extensions/test/xpcshell/head_sync.js + +# The manifest is roughly ordered from low-level to high-level. When making +# systemic sweeping changes, this makes it easier to identify errors closer to +# the source. + +# Ensure we can import everything. +[test_load_modules.js] + +# util contains a bunch of functionality used throughout. +[test_utils_catch.js] +[test_utils_deepEquals.js] +[test_utils_deferGetSet.js] +[test_utils_deriveKey.js] +[test_utils_keyEncoding.js] +[test_utils_getErrorString.js] +[test_utils_json.js] +[test_utils_lazyStrings.js] +[test_utils_lock.js] +[test_utils_makeGUID.js] +[test_utils_notify.js] +[test_utils_passphrase.js] + +# We have a number of other libraries that are pretty much standalone. +[test_addon_utils.js] +run-sequentially = Restarts server, can't change pref. +tags = addons +[test_httpd_sync_server.js] +[test_jpakeclient.js] +# Bug 618233: this test produces random failures on Windows 7. +# Bug 676978: test hangs on Android (see also testing/xpcshell/xpcshell.ini) +skip-if = os == "win" || os == "android" + +# HTTP layers. +[test_resource.js] +[test_resource_async.js] +[test_resource_header.js] +[test_resource_ua.js] +[test_syncstoragerequest.js] + +# Generic Sync types. +[test_browserid_identity.js] +[test_collection_inc_get.js] +[test_collection_getBatched.js] +[test_collections_recovery.js] +[test_identity_manager.js] +[test_keys.js] +[test_records_crypto.js] +[test_records_wbo.js] + +# Engine APIs. +[test_engine.js] +[test_engine_abort.js] +[test_enginemanager.js] +[test_syncengine.js] +[test_syncengine_sync.js] +# Bug 676978: test hangs on Android (see also testing/xpcshell/xpcshell.ini) +skip-if = os == "android" +[test_tracker_addChanged.js] + +# Service semantics. +[test_service_attributes.js] +[test_service_changePassword.js] +# Bug 752243: Profile cleanup frequently fails +skip-if = os == "mac" || os == "linux" +[test_service_checkAccount.js] +[test_service_cluster.js] +[test_service_createAccount.js] +# Bug 752243: Profile cleanup frequently fails +skip-if = os == "mac" || os == "linux" +[test_service_detect_upgrade.js] +[test_service_getStorageInfo.js] +[test_service_login.js] +[test_service_migratePrefs.js] +[test_service_passwordUTF8.js] +[test_service_persistLogin.js] +[test_service_set_serverURL.js] +[test_service_startOver.js] +[test_service_startup.js] +[test_service_sync_401.js] +[test_service_sync_locked.js] +[test_service_sync_remoteSetup.js] +# Bug 676978: test hangs on Android (see also testing/xpcshell/xpcshell.ini) +skip-if = os == "android" +[test_service_sync_specified.js] +[test_service_sync_updateEnabledEngines.js] +# Bug 676978: test hangs on Android (see also testing/xpcshell/xpcshell.ini) +skip-if = os == "android" +[test_service_verifyLogin.js] +[test_service_wipeClient.js] +[test_service_wipeServer.js] +# Bug 752243: Profile cleanup frequently fails +skip-if = os == "mac" || os == "linux" + +[test_corrupt_keys.js] +[test_declined.js] +[test_errorhandler_1.js] +[test_errorhandler_2.js] +[test_errorhandler_filelog.js] +# Bug 676978: test hangs on Android (see also testing/xpcshell/xpcshell.ini) +skip-if = os == "android" +[test_errorhandler_sync_checkServerError.js] +# Bug 676978: test hangs on Android (see also testing/xpcshell/xpcshell.ini) +skip-if = os == "android" +[test_errorhandler_eol.js] +[test_hmac_error.js] +[test_interval_triggers.js] +[test_node_reassignment.js] +[test_score_triggers.js] +[test_sendcredentials_controller.js] +[test_status.js] +[test_status_checkSetup.js] +[test_syncscheduler.js] +[test_upgrade_old_sync_key.js] + +# Firefox Accounts specific tests +[test_fxa_startOver.js] +[test_fxa_service_cluster.js] +[test_fxa_node_reassignment.js] + +# Finally, we test each engine. +[test_addons_engine.js] +run-sequentially = Hardcoded port in static files. +tags = addons +[test_addons_reconciler.js] +tags = addons +[test_addons_store.js] +run-sequentially = Hardcoded port in static files. +tags = addons +[test_addons_tracker.js] +tags = addons +[test_bookmark_batch_fail.js] +[test_bookmark_duping.js] +[test_bookmark_engine.js] +[test_bookmark_invalid.js] +[test_bookmark_legacy_microsummaries_support.js] +[test_bookmark_livemarks.js] +[test_bookmark_order.js] +[test_bookmark_places_query_rewriting.js] +[test_bookmark_record.js] +[test_bookmark_smart_bookmarks.js] +[test_bookmark_store.js] +# Too many intermittent "ASSERTION: thread pool wasn't shutdown: '!mPool'" (bug 804479) +skip-if = debug +[test_bookmark_tracker.js] +requesttimeoutfactor = 4 +[test_bookmark_validator.js] +[test_clients_engine.js] +[test_clients_escape.js] +[test_extension_storage_crypto.js] +[test_extension_storage_engine.js] +[test_extension_storage_tracker.js] +[test_forms_store.js] +[test_forms_tracker.js] +# Too many intermittent "ASSERTION: thread pool wasn't shutdown: '!mPool'" (bug 804479) +skip-if = debug +[test_history_engine.js] +[test_history_store.js] +[test_history_tracker.js] +# Too many intermittent "ASSERTION: thread pool wasn't shutdown: '!mPool'" (bug 804479) +skip-if = debug +[test_places_guid_downgrade.js] +[test_password_store.js] +[test_password_validator.js] +[test_password_tracker.js] +# Too many intermittent "ASSERTION: thread pool wasn't shutdown: '!mPool'" (bug 804479) +skip-if = debug +[test_prefs_store.js] +support-files = prefs_test_prefs_store.js +[test_prefs_tracker.js] +[test_tab_engine.js] +[test_tab_store.js] +[test_tab_tracker.js] + +[test_warn_on_truncated_response.js] +[test_postqueue.js] + +# FxA migration +[test_fxa_migration.js] + +# Synced tabs. +[test_syncedtabs.js] + +[test_telemetry.js] |