summaryrefslogtreecommitdiffstats
path: root/services/sync/tests/unit
diff options
context:
space:
mode:
Diffstat (limited to 'services/sync/tests/unit')
-rw-r--r--services/sync/tests/unit/addon1-search.xml27
-rw-r--r--services/sync/tests/unit/bootstrap1-search.xml27
-rw-r--r--services/sync/tests/unit/fake_login_manager.js38
-rw-r--r--services/sync/tests/unit/head_appinfo.js57
-rw-r--r--services/sync/tests/unit/head_errorhandler_common.js112
-rw-r--r--services/sync/tests/unit/head_helpers.js446
-rw-r--r--services/sync/tests/unit/head_http_server.js1044
-rw-r--r--services/sync/tests/unit/missing-sourceuri.xml27
-rw-r--r--services/sync/tests/unit/missing-xpi-search.xml27
-rw-r--r--services/sync/tests/unit/places_v10_from_v11.sqlitebin0 -> 1081344 bytes
-rw-r--r--services/sync/tests/unit/prefs_test_prefs_store.js25
-rw-r--r--services/sync/tests/unit/rewrite-search.xml27
-rw-r--r--services/sync/tests/unit/sync_ping_schema.json198
-rw-r--r--services/sync/tests/unit/systemaddon-search.xml27
-rw-r--r--services/sync/tests/unit/test_addon_utils.js141
-rw-r--r--services/sync/tests/unit/test_addons_engine.js253
-rw-r--r--services/sync/tests/unit/test_addons_reconciler.js195
-rw-r--r--services/sync/tests/unit/test_addons_store.js539
-rw-r--r--services/sync/tests/unit/test_addons_tracker.js177
-rw-r--r--services/sync/tests/unit/test_bookmark_batch_fail.js23
-rw-r--r--services/sync/tests/unit/test_bookmark_duping.js644
-rw-r--r--services/sync/tests/unit/test_bookmark_engine.js665
-rw-r--r--services/sync/tests/unit/test_bookmark_invalid.js63
-rw-r--r--services/sync/tests/unit/test_bookmark_legacy_microsummaries_support.js99
-rw-r--r--services/sync/tests/unit/test_bookmark_livemarks.js134
-rw-r--r--services/sync/tests/unit/test_bookmark_order.js529
-rw-r--r--services/sync/tests/unit/test_bookmark_places_query_rewriting.js60
-rw-r--r--services/sync/tests/unit/test_bookmark_record.js48
-rw-r--r--services/sync/tests/unit/test_bookmark_smart_bookmarks.js235
-rw-r--r--services/sync/tests/unit/test_bookmark_store.js534
-rw-r--r--services/sync/tests/unit/test_bookmark_tracker.js1537
-rw-r--r--services/sync/tests/unit/test_bookmark_validator.js347
-rw-r--r--services/sync/tests/unit/test_browserid_identity.js890
-rw-r--r--services/sync/tests/unit/test_clients_engine.js1439
-rw-r--r--services/sync/tests/unit/test_clients_escape.js64
-rw-r--r--services/sync/tests/unit/test_collection_getBatched.js195
-rw-r--r--services/sync/tests/unit/test_collection_inc_get.js188
-rw-r--r--services/sync/tests/unit/test_collections_recovery.js85
-rw-r--r--services/sync/tests/unit/test_corrupt_keys.js233
-rw-r--r--services/sync/tests/unit/test_declined.js153
-rw-r--r--services/sync/tests/unit/test_engine.js219
-rw-r--r--services/sync/tests/unit/test_engine_abort.js69
-rw-r--r--services/sync/tests/unit/test_enginemanager.js114
-rw-r--r--services/sync/tests/unit/test_errorhandler_1.js913
-rw-r--r--services/sync/tests/unit/test_errorhandler_2.js1012
-rw-r--r--services/sync/tests/unit/test_errorhandler_eol.js137
-rw-r--r--services/sync/tests/unit/test_errorhandler_filelog.js370
-rw-r--r--services/sync/tests/unit/test_errorhandler_sync_checkServerError.js282
-rw-r--r--services/sync/tests/unit/test_extension_storage_crypto.js93
-rw-r--r--services/sync/tests/unit/test_extension_storage_engine.js62
-rw-r--r--services/sync/tests/unit/test_extension_storage_tracker.js38
-rw-r--r--services/sync/tests/unit/test_forms_store.js151
-rw-r--r--services/sync/tests/unit/test_forms_tracker.js72
-rw-r--r--services/sync/tests/unit/test_fxa_migration.js117
-rw-r--r--services/sync/tests/unit/test_fxa_node_reassignment.js368
-rw-r--r--services/sync/tests/unit/test_fxa_service_cluster.js68
-rw-r--r--services/sync/tests/unit/test_fxa_startOver.js63
-rw-r--r--services/sync/tests/unit/test_history_engine.js147
-rw-r--r--services/sync/tests/unit/test_history_store.js297
-rw-r--r--services/sync/tests/unit/test_history_tracker.js203
-rw-r--r--services/sync/tests/unit/test_hmac_error.js248
-rw-r--r--services/sync/tests/unit/test_httpd_sync_server.js285
-rw-r--r--services/sync/tests/unit/test_identity_manager.js284
-rw-r--r--services/sync/tests/unit/test_interval_triggers.js450
-rw-r--r--services/sync/tests/unit/test_jpakeclient.js562
-rw-r--r--services/sync/tests/unit/test_keys.js326
-rw-r--r--services/sync/tests/unit/test_load_modules.js55
-rw-r--r--services/sync/tests/unit/test_node_reassignment.js523
-rw-r--r--services/sync/tests/unit/test_password_store.js199
-rw-r--r--services/sync/tests/unit/test_password_tracker.js101
-rw-r--r--services/sync/tests/unit/test_password_validator.js158
-rw-r--r--services/sync/tests/unit/test_places_guid_downgrade.js215
-rw-r--r--services/sync/tests/unit/test_postqueue.js455
-rw-r--r--services/sync/tests/unit/test_prefs_store.js168
-rw-r--r--services/sync/tests/unit/test_prefs_tracker.js88
-rw-r--r--services/sync/tests/unit/test_records_crypto.js182
-rw-r--r--services/sync/tests/unit/test_records_wbo.js86
-rw-r--r--services/sync/tests/unit/test_resource.js502
-rw-r--r--services/sync/tests/unit/test_resource_async.js730
-rw-r--r--services/sync/tests/unit/test_resource_header.js65
-rw-r--r--services/sync/tests/unit/test_resource_ua.js100
-rw-r--r--services/sync/tests/unit/test_score_triggers.js149
-rw-r--r--services/sync/tests/unit/test_sendcredentials_controller.js102
-rw-r--r--services/sync/tests/unit/test_service_attributes.js118
-rw-r--r--services/sync/tests/unit/test_service_changePassword.js80
-rw-r--r--services/sync/tests/unit/test_service_checkAccount.js41
-rw-r--r--services/sync/tests/unit/test_service_cluster.js110
-rw-r--r--services/sync/tests/unit/test_service_createAccount.js75
-rw-r--r--services/sync/tests/unit/test_service_detect_upgrade.js297
-rw-r--r--services/sync/tests/unit/test_service_getStorageInfo.js94
-rw-r--r--services/sync/tests/unit/test_service_login.js245
-rw-r--r--services/sync/tests/unit/test_service_migratePrefs.js70
-rw-r--r--services/sync/tests/unit/test_service_passwordUTF8.js95
-rw-r--r--services/sync/tests/unit/test_service_persistLogin.js46
-rw-r--r--services/sync/tests/unit/test_service_set_serverURL.js13
-rw-r--r--services/sync/tests/unit/test_service_startOver.js101
-rw-r--r--services/sync/tests/unit/test_service_startup.js49
-rw-r--r--services/sync/tests/unit/test_service_sync_401.js84
-rw-r--r--services/sync/tests/unit/test_service_sync_locked.js37
-rw-r--r--services/sync/tests/unit/test_service_sync_remoteSetup.js237
-rw-r--r--services/sync/tests/unit/test_service_sync_specified.js160
-rw-r--r--services/sync/tests/unit/test_service_sync_updateEnabledEngines.js442
-rw-r--r--services/sync/tests/unit/test_service_verifyLogin.js122
-rw-r--r--services/sync/tests/unit/test_service_wipeClient.js112
-rw-r--r--services/sync/tests/unit/test_service_wipeServer.js242
-rw-r--r--services/sync/tests/unit/test_status.js91
-rw-r--r--services/sync/tests/unit/test_status_checkSetup.js45
-rw-r--r--services/sync/tests/unit/test_syncedtabs.js221
-rw-r--r--services/sync/tests/unit/test_syncengine.js204
-rw-r--r--services/sync/tests/unit/test_syncengine_sync.js1855
-rw-r--r--services/sync/tests/unit/test_syncscheduler.js1033
-rw-r--r--services/sync/tests/unit/test_syncstoragerequest.js220
-rw-r--r--services/sync/tests/unit/test_tab_engine.js141
-rw-r--r--services/sync/tests/unit/test_tab_store.js116
-rw-r--r--services/sync/tests/unit/test_tab_tracker.js127
-rw-r--r--services/sync/tests/unit/test_telemetry.js564
-rw-r--r--services/sync/tests/unit/test_tracker_addChanged.js59
-rw-r--r--services/sync/tests/unit/test_upgrade_old_sync_key.js49
-rw-r--r--services/sync/tests/unit/test_utils_catch.js94
-rw-r--r--services/sync/tests/unit/test_utils_deepEquals.js44
-rw-r--r--services/sync/tests/unit/test_utils_deferGetSet.js49
-rw-r--r--services/sync/tests/unit/test_utils_deriveKey.js66
-rw-r--r--services/sync/tests/unit/test_utils_getErrorString.js14
-rw-r--r--services/sync/tests/unit/test_utils_json.js114
-rw-r--r--services/sync/tests/unit/test_utils_keyEncoding.js15
-rw-r--r--services/sync/tests/unit/test_utils_lazyStrings.js14
-rw-r--r--services/sync/tests/unit/test_utils_lock.js79
-rw-r--r--services/sync/tests/unit/test_utils_makeGUID.js40
-rw-r--r--services/sync/tests/unit/test_utils_notify.js100
-rw-r--r--services/sync/tests/unit/test_utils_passphrase.js73
-rw-r--r--services/sync/tests/unit/test_warn_on_truncated_response.js95
-rw-r--r--services/sync/tests/unit/xpcshell.ini200
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 000000000..1211d0c97
--- /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 000000000..b4538fba0
--- /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 000000000..6f3148c45
--- /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 000000000..d2a680df5
--- /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 000000000..f4af60d9d
--- /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 000000000..3c59e1de5
--- /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 000000000..26f62310c
--- /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 000000000..dbc83e17f
--- /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 000000000..9b547cdb3
--- /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
new file mode 100644
index 000000000..e3f9ef446
--- /dev/null
+++ b/services/sync/tests/unit/places_v10_from_v11.sqlite
Binary files differ
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 000000000..109757a35
--- /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 000000000..15476b1ab
--- /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 000000000..56114fb93
--- /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 000000000..d34e3937c
--- /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 000000000..bbbd81d0d
--- /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 000000000..64e4e32e8
--- /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 000000000..d93bdfc03
--- /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 000000000..b52cfab31
--- /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 000000000..01bf37ab9
--- /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 000000000..cf52fefb7
--- /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 000000000..1e6c6ed2e
--- /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 000000000..9de6c5c0d
--- /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 000000000..af476a7f9
--- /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 000000000..207372ed6
--- /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 000000000..8adde76d8
--- /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 000000000..7625a813f
--- /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 000000000..0ddf81583
--- /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 000000000..194fef5e2
--- /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 000000000..942cf2761
--- /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 000000000..902206ba6
--- /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 000000000..9b9242579
--- /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 000000000..cc0b3b08f
--- /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 000000000..531c01bf6
--- /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 000000000..d2123f80a
--- /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 000000000..8c8cd63e3
--- /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 000000000..c6523d497
--- /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 000000000..7747c0ef3
--- /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 000000000..0e7f54676
--- /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 000000000..009461c2a
--- /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 000000000..e9e9b002a
--- /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 000000000..be637efc8
--- /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 000000000..8ec866443
--- /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 000000000..8917cc5bc
--- /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 000000000..ea2070b48
--- /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 000000000..41f8ee727
--- /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 000000000..c8d2ff4be
--- /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 000000000..993a478fd
--- /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 000000000..953f59fcb
--- /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 000000000..f93e4970d
--- /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 000000000..1b2792703
--- /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 000000000..fac51a897
--- /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 000000000..6963df1c0
--- /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 000000000..f14e208b3
--- /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 000000000..0ca770e28
--- /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 000000000..3e4cefd53
--- /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 000000000..b4f83a7fe
--- /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 000000000..629379648
--- /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 000000000..fd5067ce9
--- /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 000000000..207b621e0
--- /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 000000000..5ed022fb0
--- /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 000000000..272c0de47
--- /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 000000000..943dbfd73
--- /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 000000000..1ac198ade
--- /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 000000000..eca5ec289
--- /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 000000000..783edb460
--- /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 000000000..a828b619c
--- /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 000000000..0b222520c
--- /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 000000000..66d21b6f1
--- /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 000000000..d232d5e63
--- /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 000000000..09ca141a6
--- /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 000000000..a4a148fbe
--- /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 000000000..2f99c4a93
--- /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 000000000..e60008a96
--- /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 000000000..9c321bceb
--- /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 000000000..17ccaa43e
--- /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 000000000..392a746ef
--- /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 000000000..e3277b0a7
--- /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 000000000..8f5534c92
--- /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 000000000..0db91a1b5
--- /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 000000000..4f28e01da
--- /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 000000000..31c2cd379
--- /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 000000000..513be685a
--- /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 000000000..42e5ec8e8
--- /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 000000000..931c7741a
--- /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 000000000..12b0ad00e
--- /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 000000000..618348d1a
--- /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 000000000..65f0c3a95
--- /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 000000000..93c6f78e3
--- /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 000000000..0f46832d9
--- /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 000000000..841dceb78
--- /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 000000000..42c163915
--- /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 000000000..89a147c06
--- /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 000000000..e781050b3
--- /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 000000000..9d4a1e51a
--- /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 000000000..6fef2bfaa
--- /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 000000000..899420548
--- /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 000000000..5148f6d13
--- /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 000000000..9e9db8137
--- /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 000000000..ee952c7ee
--- /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 000000000..83dbf3cd7
--- /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 000000000..7cb0f9d9c
--- /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 000000000..ee1800fd3
--- /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 000000000..2a27fd1b0
--- /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 000000000..aab769229
--- /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 000000000..9320f4b88
--- /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 000000000..378aafe90
--- /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 000000000..64a6aac93
--- /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 000000000..fe2cb6d1b
--- /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 000000000..8c01ca048
--- /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 000000000..97289962f
--- /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 000000000..b066eae82
--- /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 000000000..14e5daade
--- /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 000000000..049250230
--- /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 000000000..93b60f0c7
--- /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 000000000..f98920a44
--- /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 000000000..50a3d136b
--- /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 000000000..e73bd1162
--- /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 000000000..ff75a435a
--- /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 000000000..5f50bf7e4
--- /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 000000000..c75fa0cfa
--- /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 000000000..9d58a9873
--- /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 000000000..17dd889c7
--- /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 000000000..d64e43540
--- /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 000000000..efa7d9b4d
--- /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 000000000..0b39c1575
--- /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 000000000..68f9b3574
--- /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 000000000..d1830787e
--- /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 000000000..7ce6728b7
--- /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 000000000..5bd38da5f
--- /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 000000000..6d34697be
--- /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 000000000..1f0d87ba9
--- /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 000000000..e5b32e7b1
--- /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]