author | Philipp von Weitershausen <philipp@weitershausen.de> |
Tue, 04 Oct 2011 12:49:21 -0700 | |
changeset 78117 | 70e4de45a0d0f7b54e4dbc22c177e56a9c717a42 |
parent 78089 | 3304b551a1b5f7def8fa237c509b7db31c03fc91 (current diff) |
parent 78116 | 3af678a82e64f70e9bbef258db965df67c57ea3d (diff) |
child 78151 | 38a487da2def461a01e6e5691227a4c6b1fe5997 |
child 78160 | 2cf7db7d5211c1f801dc88f756dabd4f8726e071 |
child 81239 | 8cb900050922ed20dbdfa23d5afa68812ac9c456 |
push id | 21268 |
push user | pweitershausen@mozilla.com |
push date | Tue, 04 Oct 2011 19:50:07 +0000 |
treeherder | mozilla-central@70e4de45a0d0 [default view] [failures only] |
perfherder | [talos] [build metrics] [platform microbench] (compared to previous push) |
milestone | 10.0a1 |
first release with | nightly linux32
70e4de45a0d0
/
10.0a1
/
20111005030932
/
files
nightly linux64
70e4de45a0d0
/
10.0a1
/
20111005030932
/
files
nightly mac
70e4de45a0d0
/
10.0a1
/
20111005030932
/
files
nightly win32
70e4de45a0d0
/
10.0a1
/
20111005030932
/
files
nightly win64
70e4de45a0d0
/
10.0a1
/
20111005030932
/
files
|
last release without | nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
|
releases | nightly linux32
10.0a1
/
20111005030932
/
pushlog to previous
nightly linux64
10.0a1
/
20111005030932
/
pushlog to previous
nightly mac
10.0a1
/
20111005030932
/
pushlog to previous
nightly win32
10.0a1
/
20111005030932
/
pushlog to previous
nightly win64
10.0a1
/
20111005030932
/
pushlog to previous
|
mobile/chrome/content/aboutHome.xhtml | file | annotate | diff | comparison | revisions | |
mobile/chrome/content/browser.xul | file | annotate | diff | comparison | revisions |
--- a/browser/base/content/syncAddDevice.js +++ b/browser/base/content/syncAddDevice.js @@ -54,17 +54,17 @@ let gSyncAddDevice = { this.pin1.setAttribute("maxlength", PIN_PART_LENGTH); this.pin2.setAttribute("maxlength", PIN_PART_LENGTH); this.pin3.setAttribute("maxlength", PIN_PART_LENGTH); this.nextFocusEl = {pin1: this.pin2, pin2: this.pin3, pin3: this.wizard.getButton("next")}; - this.throbber = document.getElementById("add-device-throbber"); + this.throbber = document.getElementById("pairDeviceThrobber"); this.errorRow = document.getElementById("errorRow"); // Kick off a sync. That way the server will have the most recent data from // this computer and it will show up immediately on the new device. Weave.Utils.nextTick(Weave.Service.sync, Weave.Service); }, onPageShow: function onPageShow() { @@ -102,46 +102,58 @@ let gSyncAddDevice = { window.close(); return false; } return true; }, startTransfer: function startTransfer() { this.errorRow.hidden = true; + // When onAbort is called, Weave may already be gone. + const JPAKE_ERROR_USERABORT = Weave.JPAKE_ERROR_USERABORT; + let self = this; - this._jpakeclient = new Weave.JPAKEClient({ + let jpakeclient = this._jpakeclient = new Weave.JPAKEClient({ + onPaired: function onPaired() { + let credentials = {account: Weave.Service.account, + password: Weave.Service.password, + synckey: Weave.Service.passphrase, + serverURL: Weave.Service.serverURL}; + jpakeclient.sendAndComplete(credentials); + }, onComplete: function onComplete() { delete self._jpakeclient; self.wizard.pageIndex = DEVICE_CONNECTED_PAGE; + + // Schedule a Sync for soonish to fetch the data uploaded by the + // device with which we just paired. + Weave.SyncScheduler.scheduleNextSync(Weave.SyncScheduler.activeInterval); }, onAbort: function onAbort(error) { delete self._jpakeclient; // Aborted by user, ignore. - if (!error) + if (error == JPAKE_ERROR_USERABORT) { return; + } self.errorRow.hidden = false; self.throbber.hidden = true; self.pin1.value = self.pin2.value = self.pin3.value = ""; self.pin1.disabled = self.pin2.disabled = self.pin3.disabled = false; self.pin1.focus(); } }); this.throbber.hidden = false; this.pin1.disabled = this.pin2.disabled = this.pin3.disabled = true; this.wizard.canAdvance = false; let pin = this.pin1.value + this.pin2.value + this.pin3.value; - let credentials = {account: Weave.Service.account, - password: Weave.Service.password, - synckey: Weave.Service.passphrase, - serverURL: Weave.Service.serverURL}; - this._jpakeclient.sendWithPIN(pin, credentials); + let expectDelay = false; + jpakeclient.pairWithPIN(pin, expectDelay); }, onWizardBack: function onWizardBack() { if (this.wizard.pageIndex != SYNC_KEY_PAGE) return true; this.wizard.pageIndex = ADD_DEVICE_PAGE; return false;
--- a/browser/base/content/syncAddDevice.xul +++ b/browser/base/content/syncAddDevice.xul @@ -47,17 +47,17 @@ <!ENTITY % syncSetupDTD SYSTEM "chrome://browser/locale/syncSetup.dtd"> %brandDTD; %syncBrandDTD; %syncSetupDTD; ]> <wizard xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" xmlns:html="http://www.w3.org/1999/xhtml" id="wizard" - title="&addDevice.title.label;" + title="&pairDevice.title.label;" windowtype="Sync:AddDevice" persist="screenX screenY" onwizardnext="return gSyncAddDevice.onWizardAdvance();" onwizardback="return gSyncAddDevice.onWizardBack();" onwizardcancel="gSyncAddDevice.onWizardCancel();" onload="gSyncAddDevice.init();"> <script type="application/javascript" @@ -65,20 +65,20 @@ <script type="application/javascript" src="chrome://browser/content/syncUtils.js"/> <script type="application/javascript" src="chrome://browser/content/utilityOverlay.js"/> <script type="application/javascript" src="chrome://global/content/printUtils.js"/> <wizardpage id="addDevicePage" - label="&addDevice.title.label;" + label="&pairDevice.title.label;" onpageshow="gSyncAddDevice.onPageShow();"> <description> - &addDevice.dialog.description.label; + &pairDevice.dialog.description.label; <label class="text-link" value="&addDevice.showMeHow.label;" href="https://services.mozilla.com/sync/help/add-device"/> </description> <separator class="groove-thin"/> <description> &addDevice.dialog.enterCode.label; </description> @@ -96,17 +96,17 @@ /> <textbox id="pin3" class="pin" oninput="gSyncAddDevice.onTextBoxInput(this);" onfocus="this.select();" /> </vbox> <separator class="groove-thin"/> - <vbox id="add-device-throbber" align="center" hidden="true"> + <vbox id="pairDeviceThrobber" align="center" hidden="true"> <image/> </vbox> <hbox id="errorRow" pack="center" hidden="true"> <image class="statusIcon" status="error"/> <label class="status" value="&addDevice.dialog.tryAgain.label;"/> </hbox> <spacer flex="3"/>
--- a/browser/base/content/syncSetup.js +++ b/browser/base/content/syncSetup.js @@ -41,43 +41,48 @@ const Ci = Components.interfaces; const Cc = Components.classes; const Cr = Components.results; const Cu = Components.utils; // page consts -const INTRO_PAGE = 0; -const NEW_ACCOUNT_START_PAGE = 1; -const NEW_ACCOUNT_PP_PAGE = 2; -const NEW_ACCOUNT_CAPTCHA_PAGE = 3; -const EXISTING_ACCOUNT_CONNECT_PAGE = 4; -const EXISTING_ACCOUNT_LOGIN_PAGE = 5; -const OPTIONS_PAGE = 6; -const OPTIONS_CONFIRM_PAGE = 7; -const SETUP_SUCCESS_PAGE = 8; +const PAIR_PAGE = 0; +const INTRO_PAGE = 1; +const NEW_ACCOUNT_START_PAGE = 2; +const EXISTING_ACCOUNT_CONNECT_PAGE = 3; +const EXISTING_ACCOUNT_LOGIN_PAGE = 4; +const OPTIONS_PAGE = 5; +const OPTIONS_CONFIRM_PAGE = 6; +const SETUP_SUCCESS_PAGE = 7; // Broader than we'd like, but after this changed from api-secure.recaptcha.net // we had no choice. At least we only do this for the duration of setup. // See discussion in Bugs 508112 and 653307. const RECAPTCHA_DOMAIN = "https://www.google.com"; +const PIN_PART_LENGTH = 4; + Cu.import("resource://services-sync/main.js"); Cu.import("resource://gre/modules/XPCOMUtils.jsm"); Cu.import("resource://gre/modules/Services.jsm"); Cu.import("resource://gre/modules/PlacesUtils.jsm"); Cu.import("resource://gre/modules/PluralForm.jsm"); + +function setVisibility(element, visible) { + element.style.visibility = visible ? "visible" : "hidden"; +} + var gSyncSetup = { QueryInterface: XPCOMUtils.generateQI([Ci.nsISupports, Ci.nsIWebProgressListener, Ci.nsISupportsWeakReference]), - haveCaptcha: true, captchaBrowser: null, wizard: null, _disabledSites: [], status: { password: false, email: false, server: false @@ -115,27 +120,33 @@ var gSyncSetup = { window.setTimeout(function () { // Force Service to be loaded so that engines are registered. // See Bug 670082. Weave.Service; }, 0); this.captchaBrowser = document.getElementById("captcha"); - this.wizard = document.getElementById("accountSetup"); - if (window.arguments && window.arguments[0] == true) { - // we're resetting sync - this._resettingSync = true; - this.wizard.pageIndex = OPTIONS_PAGE; + this.wizardType = null; + if (window.arguments && window.arguments[0]) { + this.wizardType = window.arguments[0]; } - else { - this.wizard.canAdvance = false; - this.captchaBrowser.addProgressListener(this); - Weave.Svc.Prefs.set("firstSync", "notReady"); + switch (this.wizardType) { + case null: + this.wizard.pageIndex = INTRO_PAGE; + // Fall through! + case "pair": + this.captchaBrowser.addProgressListener(this); + Weave.Svc.Prefs.set("firstSync", "notReady"); + break; + case "reset": + this._resettingSync = true; + this.wizard.pageIndex = OPTIONS_PAGE; + break; } this.wizard.getButton("extra1").label = this._stringBundle.GetStringFromName("button.syncOptions.label"); // Remember these values because the options pages change them temporarily. this._nextButtonLabel = this.wizard.getButton("next").label; this._nextButtonAccesskey = this.wizard.getButton("next") @@ -145,24 +156,29 @@ var gSyncSetup = { .getAttribute("accesskey"); }, startNewAccountSetup: function () { if (!Weave.Utils.ensureMPUnlocked()) return false; this._settingUpNew = true; this.wizard.pageIndex = NEW_ACCOUNT_START_PAGE; - this.loadCaptcha(); }, useExistingAccount: function () { if (!Weave.Utils.ensureMPUnlocked()) return false; this._settingUpNew = false; - this.wizard.pageIndex = EXISTING_ACCOUNT_CONNECT_PAGE; + if (this.wizardType == "pair") { + // We're already pairing, so there's no point in pairing again. + // Go straight to the manual login page. + this.wizard.pageIndex = EXISTING_ACCOUNT_LOGIN_PAGE; + } else { + this.wizard.pageIndex = EXISTING_ACCOUNT_CONNECT_PAGE; + } }, resetPassphrase: function resetPassphrase() { // Apply the existing form fields so that // Weave.Service.changePassphrase() has the necessary credentials. Weave.Service.account = document.getElementById("existingAccountName").value; Weave.Service.password = document.getElementById("existingPassword").value; @@ -202,16 +218,30 @@ var gSyncSetup = { onLoginStart: function () { this.toggleLoginFeedback(false); }, onLoginEnd: function () { this.toggleLoginFeedback(true); }, + sendCredentialsAfterSync: function () { + let send = function() { + Services.obs.removeObserver("weave:service:sync:finish", send); + Services.obs.removeObserver("weave:service:sync:error", send); + let credentials = {account: Weave.Service.account, + password: Weave.Service.password, + synckey: Weave.Service.passphrase, + serverURL: Weave.Service.serverURL}; + this._jpakeclient.sendAndComplete(credentials); + }.bind(this); + Services.obs.addObserver("weave:service:sync:finish", send, false); + Services.obs.addObserver("weave:service:sync:error", send, false); + }, + toggleLoginFeedback: function (stop) { document.getElementById("login-throbber").hidden = stop; let password = document.getElementById("existingPasswordFeedbackRow"); let server = document.getElementById("existingServerFeedbackRow"); let passphrase = document.getElementById("existingPassphraseFeedbackRow"); if (!stop || (Weave.Status.login == Weave.LOGIN_SUCCEEDED)) { password.hidden = server.hidden = passphrase.hidden = true; @@ -284,16 +314,25 @@ var gSyncSetup = { } } return false; } // Default, e.g. wizard's special page -1 etc. return true; }, + onPINInput: function onPINInput(textbox) { + if (textbox && textbox.value.length == PIN_PART_LENGTH) { + this.nextFocusEl[textbox.id].focus(); + } + this.wizard.canAdvance = (this.pin1.value.length == PIN_PART_LENGTH && + this.pin2.value.length == PIN_PART_LENGTH && + this.pin3.value.length == PIN_PART_LENGTH); + }, + onEmailInput: function () { // Check account validity when the user stops typing for 1 second. if (this._checkAccountTimer) window.clearTimeout(this._checkAccountTimer); this._checkAccountTimer = window.setTimeout(function () { gSyncSetup.checkAccount(); }, 1000); }, @@ -330,60 +369,44 @@ var gSyncSetup = { this.status.email = valid; if (valid) Weave.Service.account = value; this.checkFields(); }, onPasswordChange: function () { let password = document.getElementById("weavePassword"); - let valid, str; - if (password.value == document.getElementById("weavePassphrase").value) { - // xxxmpc - hack, sigh - valid = false; - errorString = Weave.Utils.getErrorString("change.password.pwSameAsRecoveryKey"); - } - else { - let pwconfirm = document.getElementById("weavePasswordConfirm"); - [valid, errorString] = gSyncUtils.validatePassword(password, pwconfirm); - } + let pwconfirm = document.getElementById("weavePasswordConfirm"); + let [valid, errorString] = gSyncUtils.validatePassword(password, pwconfirm); let feedback = document.getElementById("passwordFeedbackRow"); this._setFeedback(feedback, valid, errorString); this.status.password = valid; this.checkFields(); }, - onPassphraseGenerate: function () { - let passphrase = Weave.Utils.generatePassphrase(); - Weave.Service.passphrase = passphrase; - let el = document.getElementById("weavePassphrase"); - el.value = Weave.Utils.hyphenatePassphrase(passphrase); - }, - onPageShow: function() { switch (this.wizard.pageIndex) { + case PAIR_PAGE: + this.wizard.getButton("back").hidden = true; + this.wizard.getButton("extra1").hidden = true; + this.onPINInput(); + this.pin1.focus(); + break; case INTRO_PAGE: + // We may not need the captcha in the Existing Account branch of the + // wizard. However, we want to preload it to avoid any flickering while + // the Create Account page is shown. + this.loadCaptcha(); this.wizard.getButton("next").hidden = true; this.wizard.getButton("back").hidden = true; this.wizard.getButton("extra1").hidden = true; - break; - case NEW_ACCOUNT_PP_PAGE: - document.getElementById("saveSyncKeyButton").focus(); - let el = document.getElementById("weavePassphrase"); - if (!el.value) - this.onPassphraseGenerate(); this.checkFields(); break; - case NEW_ACCOUNT_CAPTCHA_PAGE: - if (!this.haveCaptcha) { - gSyncSetup.wizard.advance(); - } - break; case NEW_ACCOUNT_START_PAGE: this.wizard.getButton("extra1").hidden = false; this.wizard.getButton("next").hidden = false; this.wizard.getButton("back").hidden = false; this.onServerCommand(); this.wizard.canRewind = true; this.checkFields(); break; @@ -391,27 +414,33 @@ var gSyncSetup = { this.wizard.getButton("next").hidden = false; this.wizard.getButton("back").hidden = false; this.wizard.getButton("extra1").hidden = false; this.wizard.canAdvance = false; this.wizard.canRewind = true; this.startEasySetup(); break; case EXISTING_ACCOUNT_LOGIN_PAGE: + this.wizard.getButton("next").hidden = false; + this.wizard.getButton("back").hidden = false; + this.wizard.getButton("extra1").hidden = false; this.wizard.canRewind = true; this.checkFields(); break; case SETUP_SUCCESS_PAGE: this.wizard.canRewind = false; this.wizard.canAdvance = true; this.wizard.getButton("back").hidden = true; this.wizard.getButton("next").hidden = true; this.wizard.getButton("cancel").hidden = true; this.wizard.getButton("finish").hidden = false; this._handleSuccess(); + if (this.wizardType == "pair") { + this.completePairing(); + } break; case OPTIONS_PAGE: this.wizard.canRewind = false; this.wizard.canAdvance = true; if (!this._resettingSync) { this.wizard.getButton("next").label = this._stringBundle.GetStringFromName("button.syncOptionsDone.label"); this.wizard.getButton("next").removeAttribute("accesskey"); @@ -440,55 +469,60 @@ var gSyncSetup = { onWizardAdvance: function () { // Check pageIndex so we don't prompt before the Sync setup wizard appears. // This is a fallback in case the Master Password gets locked mid-wizard. if ((this.wizard.pageIndex >= 0) && !Weave.Utils.ensureMPUnlocked()) { return false; } - if (!this.wizard.pageIndex) - return true; - switch (this.wizard.pageIndex) { + case PAIR_PAGE: + this.startPairing(); + return false; case NEW_ACCOUNT_START_PAGE: // If the user selects Next (e.g. by hitting enter) when we haven't // executed the delayed checks yet, execute them immediately. - if (this._checkAccountTimer) + if (this._checkAccountTimer) { this.checkAccount(); - if (this._checkServerTimer) + } + if (this._checkServerTimer) { this.checkServer(); - return this.wizard.canAdvance; - case NEW_ACCOUNT_CAPTCHA_PAGE: + } + if (!this.wizard.canAdvance) { + return false; + } + let doc = this.captchaBrowser.contentDocument; let getField = function getField(field) { let node = doc.getElementById("recaptcha_" + field + "_field"); return node && node.value; }; // Display throbber let feedback = document.getElementById("captchaFeedback"); let image = feedback.firstChild; let label = image.nextSibling; image.setAttribute("status", "active"); label.value = this._stringBundle.GetStringFromName("verifying.label"); - feedback.hidden = false; + setVisibility(feedback, true); let password = document.getElementById("weavePassword").value; let email = Weave.Utils.normalizeAccount( document.getElementById("weaveEmail").value); let challenge = getField("challenge"); let response = getField("response"); let error = Weave.Service.createAccount(email, password, challenge, response); if (error == null) { Weave.Service.account = email; Weave.Service.password = password; + Weave.Service.passphrase = Weave.Utils.generatePassphrase(); this._handleNoScript(false); this.wizard.pageIndex = SETUP_SUCCESS_PAGE; return false; } image.setAttribute("status", "error"); label.value = Weave.Utils.getErrorString(error); return false; @@ -524,16 +558,25 @@ var gSyncSetup = { case NEW_ACCOUNT_START_PAGE: case EXISTING_ACCOUNT_LOGIN_PAGE: this.wizard.pageIndex = INTRO_PAGE; return false; case EXISTING_ACCOUNT_CONNECT_PAGE: this.abortEasySetup(); this.wizard.pageIndex = INTRO_PAGE; return false; + case EXISTING_ACCOUNT_LOGIN_PAGE: + // If we were already pairing on entry, we went straight to the manual + // login page. If subsequently we go back, return to the page that lets + // us choose whether we already have an account. + if (this.wizardType == "pair") { + this.wizard.pageIndex = INTRO_PAGE; + return false; + } + return true; case OPTIONS_CONFIRM_PAGE: // Backing up from the confirmation page = resetting first sync to merge. document.getElementById("mergeChoiceRadio").selectedIndex = 0; return this.returnFromOptions(); } return true; }, @@ -589,16 +632,69 @@ var gSyncSetup = { this.wizard.getButton("back").setAttribute("accesskey", this._backButtonAccesskey); this.wizard.getButton("cancel").hidden = false; this.wizard.getButton("extra1").hidden = false; this.wizard.pageIndex = this._beforeOptionsPage; return false; }, + startPairing: function startPairing() { + this.pairDeviceErrorRow.hidden = true; + // When onAbort is called, Weave may already be gone. + const JPAKE_ERROR_USERABORT = Weave.JPAKE_ERROR_USERABORT; + + let self = this; + let jpakeclient = this._jpakeclient = new Weave.JPAKEClient({ + onPaired: function onPaired() { + self.wizard.pageIndex = INTRO_PAGE; + }, + onComplete: function onComplete() { + // This method will never be called since SendCredentialsController + // will take over after the wizard completes. + }, + onAbort: function onAbort(error) { + delete self._jpakeclient; + + // Aborted by user, ignore. The window is almost certainly going to close + // or is already closed. + if (error == JPAKE_ERROR_USERABORT) { + return; + } + + self.pairDeviceErrorRow.hidden = false; + self.pairDeviceThrobber.hidden = true; + self.pin1.value = self.pin2.value = self.pin3.value = ""; + self.pin1.disabled = self.pin2.disabled = self.pin3.disabled = false; + if (self.wizard.pageIndex == PAIR_PAGE) { + self.pin1.focus(); + } + } + }); + this.pairDeviceThrobber.hidden = false; + this.pin1.disabled = this.pin2.disabled = this.pin3.disabled = true; + this.wizard.canAdvance = false; + + let pin = this.pin1.value + this.pin2.value + this.pin3.value; + let expectDelay = true; + jpakeclient.pairWithPIN(pin, expectDelay); + }, + + completePairing: function completePairing() { + if (!this._jpakeclient) { + // The channel was aborted while we were setting up the account + // locally. XXX TODO should we do anything here, e.g. tell + // the user on the last wizard page that it's ok, they just + // have to pair again? + return; + } + let controller = new Weave.SendCredentialsController(this._jpakeclient); + this._jpakeclient.controller = controller; + }, + startEasySetup: function () { // Don't do anything if we have a client already (e.g. we went to // Sync Options and just came back). if (this._jpakeclient) return; // When onAbort is called, Weave may already be gone const JPAKE_ERROR_USERABORT = Weave.JPAKE_ERROR_USERABORT; @@ -606,16 +702,18 @@ var gSyncSetup = { let self = this; this._jpakeclient = new Weave.JPAKEClient({ displayPIN: function displayPIN(pin) { document.getElementById("easySetupPIN1").value = pin.slice(0, 4); document.getElementById("easySetupPIN2").value = pin.slice(4, 8); document.getElementById("easySetupPIN3").value = pin.slice(8); }, + onPairingStart: function onPairingStart() {}, + onComplete: function onComplete(credentials) { Weave.Service.account = credentials.account; Weave.Service.password = credentials.password; Weave.Service.passphrase = credentials.synckey; Weave.Service.serverURL = credentials.serverURL; self.wizard.pageIndex = SETUP_SUCCESS_PAGE; }, @@ -703,17 +801,17 @@ var gSyncSetup = { if (this._existingServerTimer) window.clearTimeout(this._existingServerTimer); this._existingServerTimer = window.setTimeout(function () { gSyncSetup.checkFields(); }, 1000); }, onServerCommand: function () { - document.getElementById("TOSRow").hidden = !this._usingMainServers; + setVisibility(document.getElementById("TOSRow"), this._usingMainServers); let control = document.getElementById("server"); if (!this._usingMainServers) { control.setAttribute("editable", "true"); // Force a style flush to ensure that the binding is attached. control.clientTop; control.value = ""; control.inputField.focus(); // checkServer() will call checkAccount() and checkFields(). @@ -957,50 +1055,59 @@ var gSyncSetup = { if (!str) str = Weave.Utils.getErrorString(string); } this._setFeedback(element, success, str); }, loadCaptcha: function loadCaptcha() { + let captchaURI = Weave.Service.miscAPI + "captcha_html"; // First check for NoScript and whitelist the right sites. this._handleNoScript(true); - this.captchaBrowser.loadURI(Weave.Service.miscAPI + "captcha_html"); + if (this.captchaBrowser.currentURI.spec != captchaURI) { + this.captchaBrowser.loadURI(captchaURI); + } }, onStateChange: function(webProgress, request, stateFlags, status) { // We're only looking for the end of the frame load if ((stateFlags & Ci.nsIWebProgressListener.STATE_STOP) == 0) return; if ((stateFlags & Ci.nsIWebProgressListener.STATE_IS_NETWORK) == 0) return; if ((stateFlags & Ci.nsIWebProgressListener.STATE_IS_WINDOW) == 0) return; - // If we didn't find the captcha, assume it's not needed and move on - if (request.QueryInterface(Ci.nsIHttpChannel).responseStatus == 404) { - this.haveCaptcha = false; - // Hide the browser just in case we end up displaying the captcha page - // due to a sign up error. - this.captchaBrowser.hidden = true; - if (this.wizard.pageIndex == NEW_ACCOUNT_CAPTCHA_PAGE) { - this.onWizardAdvance(); - } - } else { - this.haveCaptcha = true; - this.captchaBrowser.hidden = false; - } + // If we didn't find a captcha, assume it's not needed and don't show it. + let responseStatus = request.QueryInterface(Ci.nsIHttpChannel).responseStatus; + setVisibility(this.captchaBrowser, responseStatus != 404); + //XXX TODO we should really log any responseStatus other than 200 }, onProgressChange: function() {}, onStatusChange: function() {}, onSecurityChange: function() {}, onLocationChange: function () {} -} +}; -// onWizardAdvance() and onPageShow() are run before init(), so we'll set -// wizard & _stringBundle up as lazy getters. -XPCOMUtils.defineLazyGetter(gSyncSetup, "wizard", function() { - return document.getElementById("accountSetup"); +// Define lazy getters for various XUL elements. +// +// onWizardAdvance() and onPageShow() are run before init(), so we'll even +// define things that will almost certainly be used (like 'wizard') as a lazy +// getter here. +["wizard", + "pin1", + "pin2", + "pin3", + "pairDeviceErrorRow", + "pairDeviceThrobber"].forEach(function (id) { + XPCOMUtils.defineLazyGetter(gSyncSetup, id, function() { + return document.getElementById(id); + }); +}); +XPCOMUtils.defineLazyGetter(gSyncSetup, "nextFocusEl", function () { + return {pin1: this.pin2, + pin2: this.pin3, + pin3: this.wizard.getButton("next")}; }); XPCOMUtils.defineLazyGetter(gSyncSetup, "_stringBundle", function() { return Services.strings.createBundle("chrome://browser/locale/syncSetup.properties"); });
--- a/browser/base/content/syncSetup.xul +++ b/browser/base/content/syncSetup.xul @@ -47,17 +47,18 @@ <!DOCTYPE window [ <!ENTITY % brandDTD SYSTEM "chrome://branding/locale/brand.dtd"> <!ENTITY % syncBrandDTD SYSTEM "chrome://browser/locale/syncBrand.dtd"> <!ENTITY % syncSetupDTD SYSTEM "chrome://browser/locale/syncSetup.dtd"> %brandDTD; %syncBrandDTD; %syncSetupDTD; ]> -<wizard id="accountSetup" title="&accountSetupTitle.label;" +<wizard id="wizard" + title="&accountSetupTitle.label;" windowtype="Weave:AccountSetup" persist="screenX screenY" xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" xmlns:html="http://www.w3.org/1999/xhtml" onwizardnext="return gSyncSetup.onWizardAdvance()" onwizardback="return gSyncSetup.onWizardBack()" onwizardfinish="gSyncSetup.onWizardFinish()" onwizardcancel="gSyncSetup.onWizardCancel()" @@ -67,39 +68,79 @@ src="chrome://browser/content/syncSetup.js"/> <script type="application/javascript" src="chrome://browser/content/syncUtils.js"/> <script type="application/javascript" src="chrome://browser/content/utilityOverlay.js"/> <script type="application/javascript" src="chrome://global/content/printUtils.js"/> + <wizardpage id="addDevicePage" + label="&pairDevice.title.label;" + onpageshow="gSyncSetup.onPageShow()"> + <description> + &pairDevice.dialog.description.label; + <label class="text-link" + value="&addDevice.showMeHow.label;" + href="https://services.mozilla.com/sync/help/add-device"/> + </description> + <separator class="groove-thin"/> + <description> + &addDevice.dialog.enterCode.label; + </description> + <separator class="groove-thin"/> + <vbox align="center"> + <textbox id="pin1" + class="pin" + oninput="gSyncSetup.onPINInput(this);" + onfocus="this.select();" + /> + <textbox id="pin2" + class="pin" + oninput="gSyncSetup.onPINInput(this);" + onfocus="this.select();" + /> + <textbox id="pin3" + class="pin" + oninput="gSyncSetup.onPINInput(this);" + onfocus="this.select();" + /> + </vbox> + <separator class="groove-thin"/> + <vbox id="pairDeviceThrobber" align="center" hidden="true"> + <image/> + </vbox> + <hbox id="pairDeviceErrorRow" pack="center" hidden="true"> + <image class="statusIcon" status="error"/> + <label class="status" + value="&addDevice.dialog.tryAgain.label;"/> + </hbox> + </wizardpage> + <wizardpage id="pickSetupType" label="&syncBrand.fullName.label;" onpageshow="gSyncSetup.onPageShow()"> <vbox align="center" flex="1"> <description style="padding: 0 7em;"> &setup.pickSetupType.description; </description> - <spacer flex="1"/> + <spacer flex="3"/> <button id="newAccount" class="accountChoiceButton" label="&button.createNewAccount.label;" oncommand="gSyncSetup.startNewAccountSetup()" align="center"/> - <spacer flex="3"/> + <spacer flex="1"/> </vbox> <separator class="groove"/> <vbox align="center" flex="1"> - <spacer flex="3"/> - <label value="&setup.haveAccount.label;" /> <spacer flex="1"/> <button id="existingAccount" class="accountChoiceButton" - label="&button.connect.label;" + label="&button.haveAccount.label;" oncommand="gSyncSetup.useExistingAccount()"/> <spacer flex="3"/> </vbox> </wizardpage> <wizardpage label="&setup.newAccountDetailsPage.title.label;" id="newAccountStart" onextra1="gSyncSetup.onSyncOptions()" @@ -189,74 +230,37 @@ &setup.ppLink.label; </label> &setup.tosAgree3.label; </description> </hbox> </row> </rows> </grid> - </wizardpage> - - <wizardpage label="&setup.newRecoveryKeyPage.title.label;" - onextra1="gSyncSetup.onSyncOptions()" - onpageshow="gSyncSetup.onPageShow();"> - <description> - &setup.newRecoveryKeyPage.description.label; - </description> - <spacer/> - - <groupbox> - <label value="&recoveryKeyEntry.label;" - accesskey="&recoveryKeyEntry.accesskey;" - control="weavePassphrase"/> - <textbox id="weavePassphrase" - readonly="true" - onfocus="this.select();"/> - </groupbox> - - <groupbox align="center"> - <description>&recoveryKeyBackup.description;</description> - <hbox> - <button id="printSyncKeyButton" - label="&button.syncKeyBackup.print.label;" - accesskey="&button.syncKeyBackup.print.accesskey;" - oncommand="gSyncUtils.passphrasePrint('weavePassphrase');"/> - <button id="saveSyncKeyButton" - label="&button.syncKeyBackup.save.label;" - accesskey="&button.syncKeyBackup.save.accesskey;" - oncommand="gSyncUtils.passphraseSave('weavePassphrase');"/> - </hbox> - </groupbox> - </wizardpage> - - <wizardpage label="&setup.captchaPage2.title.label;" - onextra1="gSyncSetup.onSyncOptions()" - onpageshow="gSyncSetup.onPageShow();"> + <spacer flex="1"/> <vbox flex="1" align="center"> <browser height="150" - width="450" + width="500" id="captcha" type="content" disablehistory="true"/> <spacer flex="1"/> - <hbox id="captchaFeedback" hidden="true"> + <hbox id="captchaFeedback"> <image class="statusIcon"/> <label class="status" value=" "/> </hbox> - <spacer flex="3"/> </vbox> </wizardpage> <wizardpage id="addDevice" - label="&addDevice.title.label;" + label="&pairDevice.title.label;" onextra1="gSyncSetup.onSyncOptions()" onpageshow="gSyncSetup.onPageShow()"> <description> - &addDevice.setup.description.label; + &pairDevice.setup.description.label; <label class="text-link" value="&addDevice.showMeHow.label;" href="https://services.mozilla.com/sync/help/easy-setup"/> </description> <description>&addDevice.setup.enterCode.label;</description> <spacer flex="1"/> <vbox align="center" flex="1"> <textbox id="easySetupPIN1"
--- a/browser/components/preferences/sync.js +++ b/browser/components/preferences/sync.js @@ -138,23 +138,33 @@ let gSyncPane = { resetPass: function () { if (Weave.Status.login == Weave.LOGIN_FAILED_LOGIN_REJECTED) gSyncUtils.resetPassword(); else gSyncUtils.resetPassphrase(); }, - openSetup: function (resetSync) { + /** + * Invoke the Sync setup wizard. + * + * @param wizardType + * Indicates type of wizard to launch: + * null -- regular set up wizard + * "pair" -- pair a device first + * "reset" -- reset sync + */ + openSetup: function (wizardType) { var win = Services.wm.getMostRecentWindow("Weave:AccountSetup"); if (win) win.focus(); else { window.openDialog("chrome://browser/content/syncSetup.xul", - "weaveSetup", "centerscreen,chrome,resizable=no", resetSync); + "weaveSetup", "centerscreen,chrome,resizable=no", + wizardType); } }, openQuotaDialog: function () { let win = Services.wm.getMostRecentWindow("Sync:ViewQuota"); if (win) win.focus(); else @@ -170,12 +180,12 @@ let gSyncPane = { if (win) win.focus(); else window.openDialog("chrome://browser/content/syncAddDevice.xul", "syncAddDevice", "centerscreen,chrome,resizable=no"); }, resetSync: function () { - this.openSetup(true); + this.openSetup("reset"); } }
--- a/browser/components/preferences/sync.xul +++ b/browser/components/preferences/sync.xul @@ -69,24 +69,27 @@ src="chrome://browser/content/preferences/sync.js"/> <script type="application/javascript" src="chrome://browser/content/syncUtils.js"/> <deck id="weavePrefsDeck"> <vbox id="noAccount" align="center"> <spacer flex="1"/> - <button id="setupButton" - label="&setupButton.label;" - accesskey="&setupButton.accesskey;" - oncommand="gSyncPane.openSetup();"/> - <separator/> - <description id="syncDesc" flex="1"> + <description id="syncDesc"> &weaveDesc.label; </description> + <separator/> + <label class="text-link" + onclick="event.stopPropagation(); gSyncPane.openSetup(null);" + value="&setupButton.label;"/> + <separator/> + <label class="text-link" + onclick="event.stopPropagation(); gSyncPane.openSetup('pair');" + value="&pairDevice.label;"/> <spacer flex="3"/> </vbox> <vbox id="hasAccount"> <groupbox class="syncGroupBox"> <!-- label is set to account name --> <caption id="accountCaption" align="center"> <image id="accountCaptionImage"/> @@ -111,17 +114,17 @@ </menupopup> </button> </hbox> <hbox> <label id="syncAddDeviceLabel" class="text-link" onclick="gSyncPane.openAddDevice(); return false;" - value="&addDevice.label;"/> + value="&pairDevice.label;"/> </hbox> <vbox> <label value="&syncMy.label;" /> <richlistbox id="syncEnginesList" orient="vertical" onselect="if (this.selectedCount) this.clearSelection();"> <richlistitem>
--- a/browser/locales/en-US/chrome/browser/preferences/sync.dtd +++ b/browser/locales/en-US/chrome/browser/preferences/sync.dtd @@ -12,17 +12,17 @@ <!-- Manage Account --> <!ENTITY manageAccount.label "Manage Account"> <!ENTITY manageAccount.accesskey "A"> <!ENTITY viewQuota.label "View Quota"> <!ENTITY changePassword2.label "Change Password…"> <!ENTITY myRecoveryKey.label "My Recovery Key"> <!ENTITY resetSync2.label "Reset Sync…"> -<!ENTITY addDevice.label "Add a Device"> +<!ENTITY pairDevice.label "Pair a Device"> <!ENTITY syncMy.label "Sync My"> <!ENTITY engine.bookmarks.label "Bookmarks"> <!ENTITY engine.bookmarks.accesskey "m"> <!ENTITY engine.tabs.label "Tabs"> <!ENTITY engine.tabs.accesskey "T"> <!ENTITY engine.history.label "History"> <!ENTITY engine.history.accesskey "r">
--- a/browser/locales/en-US/chrome/browser/syncSetup.dtd +++ b/browser/locales/en-US/chrome/browser/syncSetup.dtd @@ -1,16 +1,15 @@ <!ENTITY accountSetupTitle.label "&syncBrand.fullName.label; Setup"> <!-- First page of the wizard --> <!ENTITY setup.pickSetupType.description "Welcome, if you've never used &syncBrand.fullName.label; before, you will need to create a new account."> <!ENTITY button.createNewAccount.label "Create a New Account"> -<!ENTITY setup.haveAccount.label "I already have a &syncBrand.fullName.label; account."> -<!ENTITY button.connect.label "Connect"> +<!ENTITY button.haveAccount.label "I Have an Account"> <!ENTITY setup.choicePage.title.label "Have you used &syncBrand.fullName.label; before?"> <!ENTITY setup.choicePage.new.label "I've never used &syncBrand.shortName.label; before"> <!ENTITY setup.choicePage.existing.label "I'm already using &syncBrand.shortName.label; on another computer"> <!-- New Account AND Existing Account --> <!ENTITY server.label "Server"> <!ENTITY serverType.main.label "&syncBrand.fullName.label; Server"> @@ -35,38 +34,36 @@ <!ENTITY setup.tosAgree1.label "I agree to the"> <!ENTITY setup.tosAgree1.accesskey "a"> <!ENTITY setup.tosLink.label "Terms of Service"> <!ENTITY setup.tosAgree2.label "and the"> <!ENTITY setup.ppLink.label "Privacy Policy"> <!ENTITY setup.tosAgree3.label ""> <!ENTITY setup.tosAgree2.accesskey ""> -<!-- New Account Page 2: Sync Key --> +<!-- My Recovery Key dialog --> <!ENTITY setup.newRecoveryKeyPage.title.label "&brandShortName; Cares About Your Privacy"> <!ENTITY setup.newRecoveryKeyPage.description.label "To ensure your total privacy, all of your data is encrypted prior to being uploaded. The Recovery Key which is necessary to decrypt your data is not uploaded."> <!ENTITY recoveryKeyEntry.label "Your Recovery Key"> <!ENTITY recoveryKeyEntry.accesskey "K"> <!ENTITY syncGenerateNewKey.label "Generate a new key"> <!ENTITY recoveryKeyBackup.description "Your Recovery Key is required to access &syncBrand.fullName.label; on other machines. Please create a backup copy. We cannot help you recover your Recovery Key."> <!ENTITY button.syncKeyBackup.print.label "Print…"> <!ENTITY button.syncKeyBackup.print.accesskey "P"> <!ENTITY button.syncKeyBackup.save.label "Save…"> <!ENTITY button.syncKeyBackup.save.accesskey "S"> -<!-- New Account Page 3: Captcha --> -<!ENTITY setup.captchaPage2.title.label "Please Confirm You're Not a Robot"> -<!-- Existing Account Page 1: Add Device (incl. Add a Device dialog strings) --> -<!ENTITY addDevice.title.label "Add a Device"> +<!-- Existing Account Page 1: Pair a Device (incl. Pair a Device dialog strings) --> +<!ENTITY pairDevice.title.label "Pair a Device"> <!ENTITY addDevice.showMeHow.label "Show me how."> <!ENTITY addDevice.dontHaveDevice.label "I don't have the device with me"> -<!ENTITY addDevice.setup.description.label "To activate, go to &syncBrand.shortName.label; Options on your other device and select "Add a Device"."> +<!ENTITY pairDevice.setup.description.label "To activate, select "Pair a Device" on your other device."> <!ENTITY addDevice.setup.enterCode.label "Then, enter this code:"> -<!ENTITY addDevice.dialog.description.label "To activate your new device, go to &syncBrand.shortName.label; Options on the device and select "Connect.""> +<!ENTITY pairDevice.dialog.description.label "To activate your new device, select "Set Up Sync" on the device."> <!ENTITY addDevice.dialog.enterCode.label "Enter the code that the device provides:"> <!ENTITY addDevice.dialog.tryAgain.label "Please try again."> <!ENTITY addDevice.dialog.successful.label "The device has been successfully added. The initial synchronization can take several minutes and will finish in the background."> <!ENTITY addDevice.dialog.recoveryKey.label "To activate your device you will need to enter your Recovery Key. Please print or save this key and take it with you."> <!ENTITY addDevice.dialog.connected.label "Device Connected"> <!-- Existing Account Page 2: Manual Login --> <!ENTITY setup.signInPage.title.label "Sign In">
--- a/browser/themes/gnomestripe/browser/preferences/preferences.css +++ b/browser/themes/gnomestripe/browser/preferences/preferences.css @@ -164,17 +164,17 @@ radio[pane=paneSync] { #SanitizeDialogPane > groupbox { margin-top: 0; } %ifdef MOZ_SERVICES_SYNC /* Sync Pane */ #syncDesc { - padding: 0 12em; + padding: 0 8em; } #accountCaptionImage { list-style-image: url("chrome://mozapps/skin/profile/profileicon.png"); } #syncAddDeviceLabel { margin-top: 1em;
--- a/browser/themes/gnomestripe/browser/syncSetup.css +++ b/browser/themes/gnomestripe/browser/syncSetup.css @@ -1,28 +1,28 @@ wizard { -moz-appearance: none; width: 55em; - height: 42em; + height: 45em; padding: 0; background-color: Window; } .wizard-page-box { -moz-appearance: none; padding-left: 0; padding-right: 0; margin: 0; } wizardpage { -moz-box-pack: center; -moz-box-align: center; margin: 0; - padding: 0 8em; + padding: 0 6em; background-color: Window; } .wizard-header { -moz-appearance: none; border: none; padding: 2em 0 1em 0; text-align: center; @@ -104,17 +104,21 @@ description > .text-link:focus { width: 4em; text-align: center; } #passphraseHelpSpacer { width: 0.5em; } -#add-device-throbber > image, +#pairDeviceThrobber > image, #login-throbber > image { list-style-image: url("chrome://global/skin/icons/loading_16.png"); } +#captchaFeedback { + visibility: hidden; +} + #successPageIcon { /* TODO replace this with a 128px version (bug 591122) */ list-style-image: url("chrome://browser/skin/sync-32.png"); }
--- a/browser/themes/pinstripe/browser/preferences/preferences.css +++ b/browser/themes/pinstripe/browser/preferences/preferences.css @@ -222,17 +222,17 @@ caption { margin-top: 0; } %ifdef MOZ_SERVICES_SYNC /* ----- SYNC PANE ----- */ #syncDesc { - padding: 0 12em; + padding: 0 8em; } #accountCaptionImage { list-style-image: url("chrome://mozapps/skin/profile/profileicon.png"); } #syncAddDeviceLabel { margin-top: 1em;
--- a/browser/themes/pinstripe/browser/syncSetup.css +++ b/browser/themes/pinstripe/browser/syncSetup.css @@ -1,28 +1,28 @@ wizard { -moz-appearance: none; width: 55em; - height: 42em; + height: 45em; padding: 0; background-color: Window; } .wizard-page-box { -moz-appearance: none; padding-left: 0; padding-right: 0; margin: 0; } wizardpage { -moz-box-pack: center; -moz-box-align: center; margin: 0; - padding: 0 8em; + padding: 0 6em; background-color: Window; } .wizard-header { -moz-appearance: none; border: none; padding: 2em 0 1em 0; text-align: center; @@ -103,17 +103,21 @@ description > .text-link:focus { width: 4em; text-align: center; } #passphraseHelpSpacer { width: 0.5em; } -#add-device-throbber > image, +#pairDeviceThrobber > image, #login-throbber > image { list-style-image: url("chrome://global/skin/icons/loading_16.png"); } +#captchaFeedback { + visibility: hidden; +} + #successPageIcon { /* TODO replace this with a 128px version (bug 591122) */ list-style-image: url("chrome://browser/skin/sync-32.png"); }
--- a/browser/themes/winstripe/browser/preferences/preferences.css +++ b/browser/themes/winstripe/browser/preferences/preferences.css @@ -150,17 +150,17 @@ radio[pane=paneSync] { .bottomBox { padding-bottom: 4px; } %ifdef MOZ_SERVICES_SYNC /* Sync Pane */ #syncDesc { - padding: 0 12em; + padding: 0 8em; } .syncGroupBox { padding: 10px; } #accountCaptionImage { list-style-image: url("chrome://mozapps/skin/profile/profileicon.png");
--- a/browser/themes/winstripe/browser/syncSetup.css +++ b/browser/themes/winstripe/browser/syncSetup.css @@ -1,28 +1,28 @@ wizard { -moz-appearance: none; width: 55em; - height: 42em; + height: 45em; padding: 0; background-color: Window; } .wizard-page-box { -moz-appearance: none; padding-left: 0; padding-right: 0; margin: 0; } wizardpage { -moz-box-pack: center; -moz-box-align: center; margin: 0; - padding: 0 8em; + padding: 0 6em; background-color: Window; } .wizard-header { -moz-appearance: none; border: none; padding: 2em 0 1em 0; text-align: center; @@ -104,17 +104,21 @@ description > .text-link:focus { width: 4em; text-align: center; } #passphraseHelpSpacer { width: 0.5em; } -#add-device-throbber > image, +#pairDeviceThrobber > image, #login-throbber > image { list-style-image: url("chrome://global/skin/icons/loading_16.png"); } +#captchaFeedback { + visibility: hidden; +} + #successPageIcon { /* TODO replace this with a 128px version (bug 591122) */ list-style-image: url("chrome://browser/skin/sync-32.png"); }
--- a/mobile/chrome/content/aboutHome.xhtml +++ b/mobile/chrome/content/aboutHome.xhtml @@ -87,17 +87,19 @@ <div id="locale" class="section-row" style="display: none;" onclick="openLocalePicker();" role="button"> <div>&aboutHome.getLocale;</div> </div> <div id="footer-wrapper"> <span id="feedback" style="width: &aboutHome.footerWidth;" class="section-row" pref="app.feedbackURL" onclick="openLink(this);" role="button">&aboutHome.giveFeedback;</span ><span id="support" style="width: &aboutHome.footerWidth;" class="section-row" pref="app.support.baseURL" onclick="openLink(this);" role="button">&aboutHome.getHelp;</span> </div> - + <div id="sync-setup"> + <a id="syncSetupSync" href="#" onclick="openSetupSyncWizard();">&aboutHome.setupSync;</a> + </div> </div> </div> <!-- l10n hack --> <div style="display: none"> <span id="text-openalltabs">&aboutHome.openAllTabs;</span> <span id="text-notabs">&aboutHome.noTabs;</span> <span id="text-noaddons">&aboutHome.noAddons;</span> @@ -157,31 +159,33 @@ let win = getChromeWin(); win.BrowserUI.showPanel("prefs-container"); win.document.getElementById("prefs-languages").click(); } function init() { initTabs(); initAddons(); + initSetupSync(); let prefs = Cc["@mozilla.org/preferences-service;1"].getService(Ci.nsIPrefService).QueryInterface(Ci.nsIPrefBranch2); if (prefs.getBoolPref("browser.firstrun.show.uidiscovery")) { startDiscovery(); prefs.setBoolPref("browser.firstrun.show.uidiscovery", false); document.getElementById("locale").style.display = "block"; } else { endDiscovery(); } initLightbox(); } function uninit() { uninitAddons(); + uninitSetupSync(); } function _readFile(aFile, aCallback) { let channel = NetUtil.newChannel(aFile); channel.contentType = "application/json"; NetUtil.asyncFetch(channel, function(aStream, aResult) { if (!Components.isSuccessCode(aResult)) { Cu.reportError("AboutHome: Could not read from " + aFile.leafName); @@ -434,17 +438,45 @@ function endDiscovery() { let doc = getChromeWin().document; let broadcaster = doc.getElementById("bcast_uidiscovery"); broadcaster.removeAttribute("mode"); doc.removeEventListener("animationend", endDiscovery, false); doc.removeEventListener("PanBegin", endDiscovery, false); } - + + function initSetupSync() { + let services = getChromeWin().Services; + if (services.prefs.prefHasUserValue("services.sync.username")) { + document.getElementById("syncSetupSync").style.display = "none"; + } + services.obs.addObserver(syncConnected, "weave:service:setup-complete", false); + services.obs.addObserver(syncDisconnected, "weave:service:start-over", false); + } + + function uninitSetupSync() { + let services = getChromeWin().Services; + services.obs.removeObserver(syncConnected, "weave:service:setup-complete"); + services.obs.removeObserver(syncDisconnected, "weave:service:start-over"); + } + + function syncConnected() { + document.getElementById("syncSetupSync").style.display = "none"; + } + + function syncDisconnected() { + document.getElementById("syncSetupSync").style.display = "inline"; + } + + function openSetupSyncWizard() { + let chromeWin = getChromeWin(); + chromeWin.WeaveGlue.open(); + } + function initLightbox() { let prefs = getChromeWin().Services.prefs; let channel = prefs.getCharPref("app.update.channel"); let startupCount = 0; try { startupCount = prefs.getIntPref("app.promo.aurora"); if(startupCount != 6) prefs.setIntPref("app.promo.aurora", ++startupCount);
--- a/mobile/chrome/content/browser.xul +++ b/mobile/chrome/content/browser.xul @@ -563,32 +563,35 @@ <box id="syncsetup-container" class="perm-modal-block" hidden="true"> <dialog id="syncsetup-dialog" class="content-dialog" flex="1"> <hbox class="prompt-title"> <description>&sync.setup.title;</description> </hbox> <separator class="prompt-line"/> <vbox id="syncsetup-simple" class="syncsetup-page" flex="1"> <scrollbox id="sync-message" class="prompt-message" orient="vertical" flex="1"> - <description class="syncsetup-desc syncsetup-center" flex="1">&sync.setup.jpake;</description> + <description class="syncsetup-desc syncsetup-center" flex="1">&sync.setup.pair;</description> <description class="syncsetup-center link" flex="1" onclick="WeaveGlue.openTutorial();">&sync.setup.tutorial;</description> <separator/> <vbox align="center" flex="1"> <description id="syncsetup-code1" class="syncsetup-code">....</description> <description id="syncsetup-code2" class="syncsetup-code">....</description> <description id="syncsetup-code3" class="syncsetup-code">....</description> </vbox> <separator/> <description class="syncsetup-center link" flex="1" onclick="WeaveGlue.openManual();">&sync.fallback;</description> <separator flex="1"/> </scrollbox> <hbox class="prompt-buttons" pack="center"> <button class="prompt-button" oncommand="WeaveGlue.close();">&sync.setup.cancel;</button> </hbox> </vbox> + <vbox id="syncsetup-waiting" class="syncsetup-page" flex="1" hidden="true"> + <description class="syncsetup-desc syncsetup-center" flex="1">&sync.setup.waiting;</description> + </vbox> <vbox id="syncsetup-fallback" class="syncsetup-page" flex="1" hidden="true"> <scrollbox class="prompt-message" orient="vertical" flex="1"> <description class="syncsetup-desc syncsetup-center" flex="1">&sync.setup.manual;</description> <separator/> <textbox id="syncsetup-account" class="prompt-edit" placeholder="&sync.account;" oninput="WeaveGlue.canConnect();"/> <textbox id="syncsetup-password" class="prompt-edit" placeholder="&sync.password;" type="password" oninput="WeaveGlue.canConnect();"/> <textbox id="syncsetup-synckey" class="prompt-edit" placeholder="&sync.recoveryKey;" oninput="WeaveGlue.canConnect();"/> <separator class="thin"/>
--- a/mobile/chrome/content/sync.js +++ b/mobile/chrome/content/sync.js @@ -103,28 +103,34 @@ let WeaveGlue = { } // Clear up any previous JPAKE codes this.abortEasySetup(); // Show the connect UI container.hidden = false; document.getElementById("syncsetup-simple").hidden = false; + document.getElementById("syncsetup-waiting").hidden = true; document.getElementById("syncsetup-fallback").hidden = true; BrowserUI.pushDialog(this); let self = this; this.jpake = new Weave.JPAKEClient({ displayPIN: function displayPIN(aPin) { document.getElementById("syncsetup-code1").value = aPin.slice(0, 4); document.getElementById("syncsetup-code2").value = aPin.slice(4, 8); document.getElementById("syncsetup-code3").value = aPin.slice(8); }, + onPairingStart: function onPairingStart() { + document.getElementById("syncsetup-simple").hidden = true; + document.getElementById("syncsetup-waiting").hidden = false; + }, + onComplete: function onComplete(aCredentials) { self.jpake = null; self.close(); self.setupData = aCredentials; self.connect(); }, onAbort: function onAbort(aError) {
--- a/mobile/locales/en-US/chrome/aboutHome.dtd +++ b/mobile/locales/en-US/chrome/aboutHome.dtd @@ -14,8 +14,9 @@ <!-- LOCALIZATION NOTE: (aboutHome.downloadAurora): First line of a multi-line button. Treat as a title. --> <!ENTITY aboutHome.downloadAurora "Download Aurora"> <!-- LOCALIZATION NOTE: (aboutHome.forAndroid): Second line of a multi-line button. Treat as a subtitle. --> <!ENTITY aboutHome.forAndroid "for Android"> +<!ENTITY aboutHome.setupSync "Set Up Sync">
--- a/mobile/locales/en-US/chrome/sync.dtd +++ b/mobile/locales/en-US/chrome/sync.dtd @@ -3,19 +3,20 @@ <!ENTITY sync.connect "Connect"> <!ENTITY sync.connected "Connected"> <!ENTITY sync.details "Details"> <!ENTITY sync.deviceName "This device"> <!ENTITY sync.disconnect "Disconnect"> <!ENTITY sync.syncNow "Sync Now"> <!ENTITY sync.setup.title "Connect to Sync"> -<!ENTITY sync.setup.jpake "From a Firefox Sync-connected computer, go to Sync options and select "Add a device""> +<!ENTITY sync.setup.pair "To activate, select "Pair a Device" on your other device."> <!ENTITY sync.fallback "I'm not near my computer…"> <!ENTITY sync.setup.manual "Enter your Sync account information"> <!ENTITY sync.account "Account Name"> <!ENTITY sync.password "Password"> <!ENTITY sync.recoveryKey "Recovery Key"> <!ENTITY sync.customServer "Use custom server"> <!ENTITY sync.serverURL "Server URL"> <!ENTITY sync.setup.connect "Connect"> <!ENTITY sync.setup.cancel "Cancel"> <!ENTITY sync.setup.tutorial "Show me how"> +<!ENTITY sync.setup.waiting "Pairing in progress…">
--- a/mobile/themes/core/aboutHome.css +++ b/mobile/themes/core/aboutHome.css @@ -239,16 +239,27 @@ body[dir="rtl"] { margin: 0; } #support { display: inline-block; margin: 0; } +#sync-setup { + font-size: 18px; + margin-top: 24px; + text-align: center; +} + +#syncSetupSync { + text-decoration: underline; + color: blue; +} + /* Lightbox for Aurora */ #lightbox { position: fixed; width: 100%; height: 100%; top: 0px; left: 0px; display: none;
--- a/mobile/themes/core/browser.css +++ b/mobile/themes/core/browser.css @@ -1528,16 +1528,20 @@ setting { #syncsetup-customserver { -moz-margin-start: @margin_xnormal@; } #sync-message { padding-bottom: 2em; } +#syncsetup-waiting { + padding: 10em 0; +} + /* content scrollbars */ .scroller { opacity: 0; background-color: rgba(0, 0, 0, 0.4) !important; -moz-border-top-colors: none !important; -moz-border-bottom-colors: none !important; -moz-border-right-colors: none !important; -moz-border-left-colors: none !important;
--- a/services/sync/locales/en-US/errors.properties +++ b/services/sync/locales/en-US/errors.properties @@ -10,14 +10,13 @@ error.sync.failed_partial = O error.sync.reason.server_maintenance = Firefox Sync server maintenance is underway, syncing will resume automatically. invalid-captcha = Incorrect words, try again weak-password = Use a stronger password # this is the fallback, if we hit an error we didn't bother to localize error.reason.unknown = Unknown error -change.password.pwSameAsRecoveryKey = Password can't match your Recovery Key change.password.pwSameAsPassword = Password can't match current password change.password.pwSameAsUsername = Password can't match your user name change.password.pwSameAsEmail = Password can't match your email address change.password.mismatch = The passwords entered do not match change.password.tooShort = The password entered is too short
--- a/services/sync/modules/constants.js +++ b/services/sync/modules/constants.js @@ -180,16 +180,17 @@ JPAKE_ERROR_NETWORK: " JPAKE_ERROR_SERVER: "jpake.error.server", JPAKE_ERROR_TIMEOUT: "jpake.error.timeout", JPAKE_ERROR_INTERNAL: "jpake.error.internal", JPAKE_ERROR_INVALID: "jpake.error.invalid", JPAKE_ERROR_NODATA: "jpake.error.nodata", JPAKE_ERROR_KEYMISMATCH: "jpake.error.keymismatch", JPAKE_ERROR_WRONGMESSAGE: "jpake.error.wrongmessage", JPAKE_ERROR_USERABORT: "jpake.error.userabort", +JPAKE_ERROR_DELAYUNSUPPORTED: "jpake.error.delayunsupported", // info types for Service.getStorageInfo INFO_COLLECTIONS: "collections", INFO_COLLECTION_USAGE: "collection_usage", INFO_COLLECTION_COUNTS: "collection_counts", INFO_QUOTA: "quota", // Ways that a sync can be disabled (messages only to be printed in debug log)
--- a/services/sync/modules/jpakeclient.js +++ b/services/sync/modules/jpakeclient.js @@ -43,183 +43,274 @@ const Cu = Components.utils; Cu.import("resource://services-sync/log4moz.js"); Cu.import("resource://services-sync/rest.js"); Cu.import("resource://services-sync/constants.js"); Cu.import("resource://services-sync/util.js"); const EXPORTED_SYMBOLS = ["JPAKEClient"]; const REQUEST_TIMEOUT = 60; // 1 minute +const KEYEXCHANGE_VERSION = 3; + const JPAKE_SIGNERID_SENDER = "sender"; const JPAKE_SIGNERID_RECEIVER = "receiver"; const JPAKE_LENGTH_SECRET = 8; const JPAKE_LENGTH_CLIENTID = 256; const JPAKE_VERIFY_VALUE = "0123456789ABCDEF"; -/* +/** * Client to exchange encrypted data using the J-PAKE algorithm. * The exchange between two clients of this type looks like this: * * - * Client A Server Client B - * ================================================================== - * | - * retrieve channel <---------------| - * generate random secret | - * show PIN = secret + channel | ask user for PIN - * upload A's message 1 ----------->| - * |--------> retrieve A's message 1 - * |<---------- upload B's message 1 - * retrieve B's message 1 <---------| - * upload A's message 2 ----------->| - * |--------> retrieve A's message 2 - * | compute key - * |<---------- upload B's message 2 - * retrieve B's message 2 <---------| - * compute key | - * upload sha256d(key) ------------>| - * |---------> retrieve sha256d(key) - * | verify against own key - * | encrypt data - * |<------------------- upload data - * retrieve data <------------------| - * verify HMAC | - * decrypt data | + * Mobile Server Desktop + * =================================================================== + * | + * retrieve channel <---------------| + * generate random secret | + * show PIN = secret + channel | ask user for PIN + * upload Mobile's message 1 ------>| + * |----> retrieve Mobile's message 1 + * |<----- upload Desktop's message 1 + * retrieve Desktop's message 1 <---| + * upload Mobile's message 2 ------>| + * |----> retrieve Mobile's message 2 + * | compute key + * |<----- upload Desktop's message 2 + * retrieve Desktop's message 2 <---| + * compute key | + * encrypt known value ------------>| + * |-------> retrieve encrypted value + * | verify against local known value + * + * At this point Desktop knows whether the PIN was entered correctly. + * If it wasn't, Desktop deletes the session. If it was, the account + * setup can proceed. If Desktop doesn't yet have an account set up, + * it will keep the channel open and let the user connect to or + * create an account. + * + * | encrypt credentials + * |<------------- upload credentials + * retrieve credentials <-----------| + * verify HMAC | + * decrypt credentials | + * delete session ----------------->| + * start syncing | * * * Create a client object like so: * - * let client = new JPAKEClient(observer); + * let client = new JPAKEClient(controller); + * + * The 'controller' object must implement the following methods: + * + * displayPIN(pin) -- Called when a PIN has been generated and is ready to + * be displayed to the user. Only called on the client where the pairing + * was initiated with 'receiveNoPIN()'. * - * The 'observer' object must implement the following methods: + * onPairingStart() -- Called when the pairing has started and messages are + * being sent back and forth over the channel. Only called on the client + * where the pairing was initiated with 'receiveNoPIN()'. * - * displayPIN(pin) -- Display the PIN to the user, only called on the client - * that didn't provide the PIN. + * onPaired() -- Called when the device pairing has been established and + * we're ready to send the credentials over. To do that, the controller + * must call 'sendAndComplete()' while the channel is active. * * onComplete(data) -- Called after transfer has been completed. On * the sending side this is called with no parameter and as soon as the - * data has been uploaded, which this doesn't mean the receiving side - * has actually retrieved them yet. + * data has been uploaded. This does not mean the receiving side has + * actually retrieved them yet. * * onAbort(error) -- Called whenever an error is encountered. All errors lead * to an abort and the process has to be started again on both sides. * * To start the data transfer on the receiving side, call * * client.receiveNoPIN(); * * This will allocate a new channel on the server, generate a PIN, have it * displayed and then do the transfer once the protocol has been completed * with the sending side. * * To initiate the transfer from the sending side, call * - * client.sendWithPIN(pin, data) + * client.pairWithPIN(pin, true); + * + * Once the pairing has been established, the controller's 'onPaired()' method + * will be called. To then transmit the data, call + * + * client.sendAndComplete(data); * * To abort the process, call * * client.abort(); * * Note that after completion or abort, the 'client' instance may not be reused. * You will have to create a new one in case you'd like to restart the process. */ -function JPAKEClient(observer) { - this.observer = observer; +function JPAKEClient(controller) { + this.controller = controller; this._log = Log4Moz.repository.getLogger("Sync.JPAKEClient"); this._log.level = Log4Moz.Level[Svc.Prefs.get( "log.logger.service.jpakeclient", "Debug")]; - this._serverUrl = Svc.Prefs.get("jpake.serverURL"); + this._serverURL = Svc.Prefs.get("jpake.serverURL"); this._pollInterval = Svc.Prefs.get("jpake.pollInterval"); this._maxTries = Svc.Prefs.get("jpake.maxTries"); - if (this._serverUrl.slice(-1) != "/") - this._serverUrl += "/"; + if (this._serverURL.slice(-1) != "/") { + this._serverURL += "/"; + } this._jpake = Cc["@mozilla.org/services-crypto/sync-jpake;1"] .createInstance(Ci.nsISyncJPAKE); this._setClientID(); } JPAKEClient.prototype = { _chain: Async.chain, /* * Public API */ + /** + * Initiate pairing and receive data without providing a PIN. The PIN will + * be generated and passed on to the controller to be displayed to the user. + * + * This is typically called on mobile devices where typing is tedious. + */ receiveNoPIN: function receiveNoPIN() { this._my_signerid = JPAKE_SIGNERID_RECEIVER; this._their_signerid = JPAKE_SIGNERID_SENDER; this._secret = this._createSecret(); // Allow a large number of tries first while we wait for the PIN // to be entered on the other device. this._maxTries = Svc.Prefs.get("jpake.firstMsgMaxTries"); this._chain(this._getChannel, this._computeStepOne, this._putStep, this._getStep, function(callback) { + // We fetched the first response from the other client. + // Notify controller of the pairing starting. + Utils.nextTick(this.controller.onPairingStart, + this.controller); + // Now we can switch back to the smaller timeout. this._maxTries = Svc.Prefs.get("jpake.maxTries"); callback(); }, this._computeStepTwo, this._putStep, this._getStep, this._computeFinal, this._computeKeyVerification, this._putStep, + function(callback) { + // Allow longer time-out for the last message. + this._maxTries = Svc.Prefs.get("jpake.lastMsgMaxTries"); + callback(); + }, this._getStep, this._decryptData, this._complete)(); }, - sendWithPIN: function sendWithPIN(pin, obj) { + /** + * Initiate pairing based on the PIN entered by the user. + * + * This is typically called on desktop devices where typing is easier than + * on mobile. + * + * @param pin + * 12 character string (in human-friendly base32) containing the PIN + * entered by the user. + * @param expectDelay + * Flag that indicates that a significant delay between the pairing + * and the sending should be expected. v2 and earlier of the protocol + * did not allow for this and the pairing to a v2 or earlier client + * will be aborted if this flag is 'true'. + */ + pairWithPIN: function pairWithPIN(pin, expectDelay) { this._my_signerid = JPAKE_SIGNERID_SENDER; this._their_signerid = JPAKE_SIGNERID_RECEIVER; this._channel = pin.slice(JPAKE_LENGTH_SECRET); - this._channelUrl = this._serverUrl + this._channel; + this._channelURL = this._serverURL + this._channel; this._secret = pin.slice(0, JPAKE_LENGTH_SECRET); - this._data = JSON.stringify(obj); this._chain(this._computeStepOne, this._getStep, + function (callback) { + // Ensure that the other client can deal with a delay for + // the last message if that's requested by the caller. + if (!expectDelay) { + return callback(); + } + if (!this._incoming.version || this._incoming.version < 3) { + return this.abort(JPAKE_ERROR_DELAYUNSUPPORTED); + } + return callback(); + }, this._putStep, this._computeStepTwo, this._getStep, this._putStep, this._computeFinal, this._getStep, - this._encryptData, + this._verifyPairing)(); + }, + + /** + * Send data after a successful pairing. + * + * @param obj + * Object containing the data to send. It will be serialized as JSON. + */ + sendAndComplete: function sendAndComplete(obj) { + if (!this._paired || this._finished) { + this._log.error("Can't send data, no active pairing!"); + throw "No active pairing!"; + } + this._data = JSON.stringify(obj); + this._chain(this._encryptData, this._putStep, this._complete)(); }, + /** + * Abort the current pairing. The channel on the server will be deleted + * if the abort wasn't due to a network or server error. The controller's + * 'onAbort()' method is notified in all cases. + * + * @param error [optional] + * Error constant indicating the reason for the abort. Defaults to + * user abort. + */ abort: function abort(error) { this._log.debug("Aborting..."); this._finished = true; let self = this; // Default to "user aborted". - if (!error) + if (!error) { error = JPAKE_ERROR_USERABORT; + } - if (error == JPAKE_ERROR_CHANNEL - || error == JPAKE_ERROR_NETWORK - || error == JPAKE_ERROR_NODATA) { - Utils.namedTimer(function() { this.observer.onAbort(error); }, 0, - this, "_timer_onAbort"); + if (error == JPAKE_ERROR_CHANNEL || + error == JPAKE_ERROR_NETWORK || + error == JPAKE_ERROR_NODATA) { + Utils.nextTick(function() { this.controller.onAbort(error); }, this); } else { - this._reportFailure(error, function() { self.observer.onAbort(error); }); + this._reportFailure(error, function() { self.controller.onAbort(error); }); } }, /* * Utilities */ _setClientID: function _setClientID() { @@ -248,20 +339,21 @@ JPAKEClient.prototype = { }, /* * Steps of J-PAKE procedure */ _getChannel: function _getChannel(callback) { this._log.trace("Requesting channel."); - let request = this._newRequest(this._serverUrl + "new_channel"); + let request = this._newRequest(this._serverURL + "new_channel"); request.get(Utils.bind2(this, function handleChannel(error) { - if (this._finished) + if (this._finished) { return; + } if (error) { this._log.error("Error acquiring channel ID. " + error); this.abort(JPAKE_ERROR_CHANNEL); return; } if (request.response.status != 200) { this._log.error("Error acquiring channel ID. Server responded with HTTP " @@ -273,65 +365,71 @@ JPAKEClient.prototype = { try { this._channel = JSON.parse(request.response.body); } catch (ex) { this._log.error("Server responded with invalid JSON."); this.abort(JPAKE_ERROR_CHANNEL); return; } this._log.debug("Using channel " + this._channel); - this._channelUrl = this._serverUrl + this._channel; + this._channelURL = this._serverURL + this._channel; // Don't block on UI code. let pin = this._secret + this._channel; - Utils.namedTimer(function() { this.observer.displayPIN(pin); }, 0, - this, "_timer_displayPIN"); + Utils.nextTick(function() { this.controller.displayPIN(pin); }, this); callback(); })); }, // Generic handler for uploading data. _putStep: function _putStep(callback) { this._log.trace("Uploading message " + this._outgoing.type); - let request = this._newRequest(this._channelUrl); + let request = this._newRequest(this._channelURL); + if (this._their_etag) { + request.setHeader("If-Match", this._their_etag); + } else { + request.setHeader("If-None-Match", "*"); + } request.put(this._outgoing, Utils.bind2(this, function (error) { - if (this._finished) + if (this._finished) { return; + } if (error) { this._log.error("Error uploading data. " + error); this.abort(JPAKE_ERROR_NETWORK); return; } if (request.response.status != 200) { this._log.error("Could not upload data. Server responded with HTTP " + request.response.status); this.abort(JPAKE_ERROR_SERVER); return; } // There's no point in returning early here since the next step will // always be a GET so let's pause for twice the poll interval. - this._etag = request.response.headers["etag"]; + this._my_etag = request.response.headers["etag"]; Utils.namedTimer(function () { callback(); }, this._pollInterval * 2, this, "_pollTimer"); })); }, // Generic handler for polling for and retrieving data. _pollTries: 0, _getStep: function _getStep(callback) { this._log.trace("Retrieving next message."); - let request = this._newRequest(this._channelUrl); - if (this._etag) { - request.setHeader("If-None-Match", this._etag); + let request = this._newRequest(this._channelURL); + if (this._my_etag) { + request.setHeader("If-None-Match", this._my_etag); } request.get(Utils.bind2(this, function (error) { - if (this._finished) + if (this._finished) { return; + } if (error) { this._log.error("Error fetching data. " + error); this.abort(JPAKE_ERROR_NETWORK); return; } if (request.response.status == 304) { @@ -355,31 +453,39 @@ JPAKEClient.prototype = { } if (request.response.status != 200) { this._log.error("Could not retrieve data. Server responded with HTTP " + request.response.status); this.abort(JPAKE_ERROR_SERVER); return; } + this._their_etag = request.response.headers["etag"]; + if (!this._their_etag) { + this._log.error("Server did not supply ETag for message: " + + request.response.body); + this.abort(JPAKE_ERROR_SERVER); + return; + } + try { this._incoming = JSON.parse(request.response.body); } catch (ex) { this._log.error("Server responded with invalid JSON."); this.abort(JPAKE_ERROR_INVALID); return; } this._log.trace("Fetched message " + this._incoming.type); callback(); })); }, _reportFailure: function _reportFailure(reason, callback) { this._log.debug("Reporting failure to server."); - let request = this._newRequest(this._serverUrl + "report"); + let request = this._newRequest(this._serverURL + "report"); request.setHeader("X-KeyExchange-Cid", this._channel); request.setHeader("X-KeyExchange-Log", reason); request.post("", Utils.bind2(this, function (error) { if (error) { this._log.warn("Report failed: " + error); } else if (request.response.status != 200) { this._log.warn("Report failed. Server responded with HTTP " + request.response.status); @@ -404,17 +510,19 @@ JPAKEClient.prototype = { this._log.error("JPAKE round 1 threw: " + ex); this.abort(JPAKE_ERROR_INTERNAL); return; } let one = {gx1: gx1.value, gx2: gx2.value, zkp_x1: {gr: gv1.value, b: r1.value, id: this._my_signerid}, zkp_x2: {gr: gv2.value, b: r2.value, id: this._my_signerid}}; - this._outgoing = {type: this._my_signerid + "1", payload: one}; + this._outgoing = {type: this._my_signerid + "1", + version: KEYEXCHANGE_VERSION, + payload: one}; this._log.trace("Generated message " + this._outgoing.type); callback(); }, _computeStepTwo: function _computeStepTwo(callback) { this._log.trace("Computing round 2."); if (this._incoming.type != this._their_signerid + "1") { this._log.error("Invalid round 1 message: " @@ -442,17 +550,19 @@ JPAKEClient.prototype = { A, gvA, rA); } catch (ex) { this._log.error("JPAKE round 2 threw: " + ex); this.abort(JPAKE_ERROR_INTERNAL); return; } let two = {A: A.value, zkp_A: {gr: gvA.value, b: rA.value, id: this._my_signerid}}; - this._outgoing = {type: this._my_signerid + "2", payload: two}; + this._outgoing = {type: this._my_signerid + "2", + version: KEYEXCHANGE_VERSION, + payload: two}; this._log.trace("Generated message " + this._outgoing.type); callback(); }, _computeFinal: function _computeFinal(callback) { if (this._incoming.type != this._their_signerid + "2") { this._log.error("Invalid round 2 message: " + JSON.stringify(this._incoming)); @@ -494,53 +604,64 @@ JPAKEClient.prototype = { ciphertext = Svc.Crypto.encrypt(JPAKE_VERIFY_VALUE, this._crypto_key, iv); } catch (ex) { this._log.error("Failed to encrypt key verification value."); this.abort(JPAKE_ERROR_INTERNAL); return; } this._outgoing = {type: this._my_signerid + "3", + version: KEYEXCHANGE_VERSION, payload: {ciphertext: ciphertext, IV: iv}}; this._log.trace("Generated message " + this._outgoing.type); callback(); }, - _encryptData: function _encryptData(callback) { + _verifyPairing: function _verifyPairing(callback) { this._log.trace("Verifying their key."); if (this._incoming.type != this._their_signerid + "3") { this._log.error("Invalid round 3 data: " + JSON.stringify(this._incoming)); this.abort(JPAKE_ERROR_WRONGMESSAGE); return; } let step3 = this._incoming.payload; + let ciphertext; try { ciphertext = Svc.Crypto.encrypt(JPAKE_VERIFY_VALUE, this._crypto_key, step3.IV); - if (ciphertext != step3.ciphertext) + if (ciphertext != step3.ciphertext) { throw "Key mismatch!"; + } } catch (ex) { this._log.error("Keys don't match!"); this.abort(JPAKE_ERROR_KEYMISMATCH); return; } + this._log.debug("Verified pairing!"); + this._paired = true; + Utils.nextTick(function () { this.controller.onPaired(); }, this); + callback(); + }, + + _encryptData: function _encryptData(callback) { this._log.trace("Encrypting data."); let iv, ciphertext, hmac; try { iv = Svc.Crypto.generateRandomIV(); ciphertext = Svc.Crypto.encrypt(this._data, this._crypto_key, iv); hmac = Utils.bytesAsHex(Utils.digestUTF8(ciphertext, this._hmac_hasher)); } catch (ex) { this._log.error("Failed to encrypt data."); this.abort(JPAKE_ERROR_INTERNAL); return; } this._outgoing = {type: this._my_signerid + "3", + version: KEYEXCHANGE_VERSION, payload: {ciphertext: ciphertext, IV: iv, hmac: hmac}}; this._log.trace("Generated message " + this._outgoing.type); callback(); }, _decryptData: function _decryptData(callback) { this._log.trace("Verifying their key."); if (this._incoming.type != this._their_signerid + "3") { @@ -548,18 +669,19 @@ JPAKEClient.prototype = { + JSON.stringify(this._incoming)); this.abort(JPAKE_ERROR_WRONGMESSAGE); return; } let step3 = this._incoming.payload; try { let hmac = Utils.bytesAsHex( Utils.digestUTF8(step3.ciphertext, this._hmac_hasher)); - if (hmac != step3.hmac) + if (hmac != step3.hmac) { throw "HMAC validation failed!"; + } } catch (ex) { this._log.error("HMAC validation failed."); this.abort(JPAKE_ERROR_KEYMISMATCH); return; } this._log.trace("Decrypting data."); let cleartext; @@ -582,13 +704,13 @@ JPAKEClient.prototype = { this._log.trace("Decrypted data."); callback(); }, _complete: function _complete() { this._log.debug("Exchange completed."); this._finished = true; - Utils.namedTimer(function () { this.observer.onComplete(this._newData); }, - 0, this, "_timer_onComplete"); + Utils.nextTick(function () { this.controller.onComplete(this._newData); }, + this); } };
--- a/services/sync/modules/main.js +++ b/services/sync/modules/main.js @@ -47,17 +47,18 @@ let lazies = { "engines/forms.js": ["FormEngine"], "engines/history.js": ["HistoryEngine"], "engines/prefs.js": ["PrefsEngine"], "engines/passwords.js": ["PasswordEngine"], "engines/tabs.js": ["TabEngine"], "identity.js": ["Identity", "ID"], "jpakeclient.js": ["JPAKEClient"], "notifications.js": ["Notifications", "Notification", "NotificationButton"], - "policies.js": ["SyncScheduler", "ErrorHandler"], + "policies.js": ["SyncScheduler", "ErrorHandler", + "SendCredentialsController"], "resource.js": ["Resource", "AsyncResource", "Auth", "BasicAuthenticator", "NoOpAuthenticator"], "service.js": ["Service"], "status.js": ["Status"], "util.js": ['Utils', 'Svc', 'Str'] }; function lazyImport(module, dest, props) {
--- a/services/sync/modules/policies.js +++ b/services/sync/modules/policies.js @@ -32,18 +32,19 @@ * use your version of this file under the terms of the MPL, indicate your * decision by deleting the provisions above and replace them with the notice * and other provisions required by the GPL or the LGPL. If you do not delete * the provisions above, a recipient may use your version of this file under * the terms of any one of the MPL, the GPL or the LGPL. * * ***** END LICENSE BLOCK ***** */ - -const EXPORTED_SYMBOLS = ["SyncScheduler", "ErrorHandler"]; +const EXPORTED_SYMBOLS = ["SyncScheduler", + "ErrorHandler", + "SendCredentialsController"]; const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components; Cu.import("resource://services-sync/constants.js"); Cu.import("resource://services-sync/log4moz.js"); Cu.import("resource://services-sync/util.js"); Cu.import("resource://services-sync/engines.js"); Cu.import("resource://services-sync/engines/clients.js"); @@ -179,19 +180,21 @@ let SyncScheduler = { case "weave:service:sync:error": // There may be multiple clients but if the sync fails, client mode // should still be updated so that the next sync has a correct interval. this.updateClientMode(); this.adjustSyncInterval(); this.handleSyncError(); break; case "weave:service:backoff:interval": - let interval = (data + Math.random() * data * 0.25) * 1000; // required backoff + up to 25% + let requested_interval = subject * 1000; + // Leave up to 25% more time for the back off. + let interval = requested_interval * (1 + Math.random() * 0.25); Status.backoffInterval = interval; - Status.minimumNextSync = Date.now() + data; + Status.minimumNextSync = Date.now() + requested_interval; break; case "weave:service:ready": // Applications can specify this preference if they want autoconnect // to happen after a fixed delay. let delay = Svc.Prefs.get("autoconnectDelay"); if (delay) { this.delayedAutoConnect(delay); } @@ -731,8 +734,100 @@ let ErrorHandler = { Status.sync = LOGIN_FAILED_NETWORK_ERROR; } else { Status.login = LOGIN_FAILED_NETWORK_ERROR; } break; } }, }; + + +/** + * Send credentials over an active J-PAKE channel. + * + * This object is designed to take over as the JPAKEClient controller, + * presumably replacing one that is UI-based which would either cause + * DOM objects to leak or the JPAKEClient to be GC'ed when the DOM + * context disappears. This object stays alive for the duration of the + * transfer by being strong-ref'ed as an nsIObserver. + * + * Credentials are sent after the first sync has been completed + * (successfully or not.) + * + * Usage: + * + * jpakeclient.controller = new SendCredentialsController(jpakeclient); + * + */ +function SendCredentialsController(jpakeclient) { + this._log = Log4Moz.repository.getLogger("Sync.SendCredentialsController"); + this._log.level = Log4Moz.Level[Svc.Prefs.get("log.logger.service.main")]; + + this._log.trace("Loading."); + this.jpakeclient = jpakeclient; + + // Register ourselves as observers the first Sync finishing (either + // successfully or unsuccessfully, we don't care) or for removing + // this device's sync configuration, in case that happens while we + // haven't finished the first sync yet. + Services.obs.addObserver(this, "weave:service:sync:finish", false); + Services.obs.addObserver(this, "weave:service:sync:error", false); + Services.obs.addObserver(this, "weave:service:start-over", false); +} +SendCredentialsController.prototype = { + + unload: function unload() { + this._log.trace("Unloading."); + try { + Services.obs.removeObserver(this, "weave:service:sync:finish"); + Services.obs.removeObserver(this, "weave:service:sync:error"); + Services.obs.removeObserver(this, "weave:service:start-over"); + } catch (ex) { + // Ignore. + } + }, + + observe: function observe(subject, topic, data) { + switch (topic) { + case "weave:service:sync:finish": + case "weave:service:sync:error": + Utils.nextTick(this.sendCredentials, this); + break; + case "weave:service:start-over": + // This will call onAbort which will call unload(). + this.jpakeclient.abort(); + break; + } + }, + + sendCredentials: function sendCredentials() { + this._log.trace("Sending credentials."); + let credentials = {account: Weave.Service.account, + password: Weave.Service.password, + synckey: Weave.Service.passphrase, + serverURL: Weave.Service.serverURL}; + this.jpakeclient.sendAndComplete(credentials); + }, + + // JPAKEClient controller API + + onComplete: function onComplete() { + this._log.debug("Exchange was completed successfully!"); + this.unload(); + + // Schedule a Sync for soonish to fetch the data uploaded by the + // device with which we just paired. + SyncScheduler.scheduleNextSync(SyncScheduler.activeInterval); + }, + + onAbort: function onAbort(error) { + // It doesn't really matter why we aborted, but the channel is closed + // for sure, so we won't be able to do anything with it. + this._log.debug("Exchange was aborted with error: " + error); + this.unload(); + }, + + // Irrelevant methods for this controller: + displayPIN: function displayPIN() {}, + onPairingStart: function onPairingStart() {}, + onPaired: function onPaired() {} +};
--- a/services/sync/services-sync.js +++ b/services/sync/services-sync.js @@ -21,17 +21,18 @@ pref("services.sync.engine.bookmarks", t pref("services.sync.engine.history", true); pref("services.sync.engine.passwords", true); pref("services.sync.engine.prefs", true); pref("services.sync.engine.tabs", true); pref("services.sync.engine.tabs.filteredUrls", "^(about:.*|chrome://weave/.*|wyciwyg:.*|file:.*)$"); pref("services.sync.jpake.serverURL", "https://setup.services.mozilla.com/"); pref("services.sync.jpake.pollInterval", 1000); -pref("services.sync.jpake.firstMsgMaxTries", 300); +pref("services.sync.jpake.firstMsgMaxTries", 300); // 5 minutes +pref("services.sync.jpake.lastMsgMaxTries", 300); // 5 minutes pref("services.sync.jpake.maxTries", 10); pref("services.sync.log.appender.console", "Warn"); pref("services.sync.log.appender.dump", "Error"); pref("services.sync.log.appender.file.level", "Trace"); pref("services.sync.log.appender.file.logOnError", true); pref("services.sync.log.appender.file.logOnSuccess", false); pref("services.sync.log.appender.file.maxErrorAge", 864000); // 10 days
--- a/services/sync/tests/unit/head_http_server.js +++ b/services/sync/tests/unit/head_http_server.js @@ -1,8 +1,13 @@ +// Shared logging for all HTTP server functions. +Cu.import("resource://services-sync/log4moz.js"); +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 httpd_setup (handlers) { @@ -72,30 +77,30 @@ function readBytesFromInputStream(inputS count = inputStream.available(); } return new BinaryInputStream(inputStream).readBytes(count); } /* * Represent a WBO on the server */ -function ServerWBO(id, initialPayload) { +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 = new_timestamp(); + this.modified = modified || new_timestamp(); } ServerWBO.prototype = { get data() { return JSON.parse(this.payload); }, get: function() { @@ -133,125 +138,234 @@ ServerWBO.prototype = { 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 'wbo' attribute is a +/** + * 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! - * - * Passing `true` for acceptNew will allow POSTs of new WBOs to this - * collection. New WBOs will be created and wired in on the fly. + * 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) { - this.wbos = wbos || {}; +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 = Log4Moz.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) { + return [id for ([id, wbo] in Iterator(this._wbos)) + if (wbo.payload && + (!filter || filter(id, wbo)))]; + }, + + /** + * 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 = [wbo for ([id, wbo] in Iterator(this._wbos)) + if (wbo.payload)]; + 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)); + }, + _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] in Iterator(this.wbos)) { + for (let [id, wbo] in Iterator(this._wbos)) { if (wbo.modified && this._inResultSet(wbo, options)) { c++; } } return c; }, get: function(options) { let result; if (options.full) { - let data = [wbo.get() for ([id, wbo] in Iterator(this.wbos)) + let data = [wbo.get() for ([id, wbo] in Iterator(this._wbos)) // Drop deleted. if (wbo.modified && this._inResultSet(wbo, options))]; if (options.limit) { data = data.slice(0, options.limit); } - // Our implementation of application/newlines + // 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 = [id for ([id, wbo] in Iterator(this.wbos)) + let data = [id for ([id, wbo] in Iterator(this._wbos)) if (this._inResultSet(wbo, options))]; if (options.limit) { data = data.slice(0, options.limit); } 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 each (let record in input) { - let wbo = this.wbos[record.id]; + let wbo = this.wbo(record.id); if (!wbo && this.acceptNew) { - _("Creating WBO " + JSON.stringify(record.id) + " on the fly."); + this._log.debug("Creating WBO " + JSON.stringify(record.id) + + " on the fly."); wbo = new ServerWBO(record.id); - this.wbos[record.id] = wbo; + 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) { - for (let [id, wbo] in Iterator(this.wbos)) { + let deleted = []; + for (let [id, wbo] in Iterator(this._wbos)) { if (this._inResultSet(wbo, options)) { - _("Deleting " + JSON.stringify(wbo)); + 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) { @@ -280,36 +394,54 @@ ServerCollection.prototype = { } if (options.limit) { options.limit = parseInt(options.limit, 10); } switch(request.method) { case "GET": body = self.get(options); + // "If supported by the db, this header will return the number of + // records total in the request body of any multiple-record GET + // request." + let records = options.recordCount; + self._log.info("Records: " + records); + if (records != null) { + response.setHeader("X-Weave-Records", "" + records); + } break; case "POST": let res = self.post(readBytesFromInputStream(request.bodyInputStream)); body = JSON.stringify(res); response.newModified = res.modified; break; case "DELETE": - self.delete(options); + self._log.debug("Invoking ServerCollection.DELETE."); + let deleted = self.delete(options); 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. */ @@ -374,8 +506,434 @@ function track_collections_helper() { 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. + */ +let SyncServerCallback = { + onCollectionDeleted: function onCollectionDeleted(user, collection) {}, + onItemDeleted: function onItemDeleted(user, collection, wboID) {} +}; + +/** + * 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 nsHttpServer(); + this.started = false; + this.users = {}; + this._log = Log4Moz.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 = { + port: 8080, + server: null, // nsHttpServer. + users: null, // Map of username => {collections, password}. + + /** + * Start the SyncServer's underlying HTTP server. + * + * @param port + * The numeric port on which to start. A falsy value implies the + * default (8080). + * @param cb + * A callback function (of no arguments) which is invoked after + * startup. + */ + start: function start(port, cb) { + if (this.started) { + this._log.warn("Warning: server already started on " + this.port); + return; + } + if (port) { + this.port = port; + } + try { + this.server.start(this.port); + this.started = true; + if (cb) { + cb(); + } + } catch (ex) { + _("=========================================="); + _("Got exception starting Sync HTTP server on port " + this.port); + _("Error: " + Utils.exceptionStr(ex)); + _("Is there a process already listening on port " + this.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 Math.round(Date.now() / 10) / 100; + }, + + /** + * 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] in Iterator(collections)) { + let coll = userCollections[id] || + this._insertCollection(userCollections, id); + for (let [wboID, payload] in Iterator(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; + }, + + /** + * 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); + return { + collection: collection, + createCollection: createCollection + }; + }, + + /* + * 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); + for each (let [header, value] in Iterator(headers || this.defaultHeaders)) { + resp.setHeader(header, value); + } + resp.setHeader("X-Weave-Timestamp", "" + this.timestamp(), false); + resp.bodyOutputStream.write(body, body.length); + }, + + /** + * This is invoked by the nsHttpServer. `this` is bound to the SyncServer; + * `handler` is the nsHttpServer'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) { + this._log.debug("SyncServer: Handling request: " + req.method + " " + req.path); + 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; + if (version != 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 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); + let respond = this.respond.bind(this, req, resp); + switch (req.method) { + case "GET": + if (!coll) { + // *cries inside*: Bug 687299. + respond(200, "OK", "[]"); + return; + } + if (!wboID) { + return coll.collectionHandler(req, resp); + } + let wbo = coll.wbo(wboID); + if (!wbo) { + respond(404, "Not found", "Not found"); + return; + } + return wbo.handler()(req, resp); + + // TODO: implement handling of X-If-Unmodified-Since for write verbs. + case "DELETE": + if (!coll) { + respond(200, "OK", "{}"); + return; + } + if (wboID) { + let wbo = coll.wbo(wboID); + if (wbo) { + wbo.delete(); + } + respond(200, "OK", "{}"); + this.callback.onItemDeleted(username, collectin, wboID); + return; + } + 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; + 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] in Iterator(users)) { + server.registerUser(user, pass); + server.createContents(user, contents); + } + server.start(); + return server; +}
--- a/services/sync/tests/unit/test_bookmark_engine.js +++ b/services/sync/tests/unit/test_bookmark_engine.js @@ -77,32 +77,35 @@ add_test(function test_ID_caching() { // And it's repeatable, even with creation enabled. do_check_eq(newMobileID, store.idForGUID("mobile", false)); do_check_eq(store.GUIDForId(mobileID), "abcdefghijkl"); run_next_test(); }); +function serverForFoo(engine) { + return serverForUsers({"foo": "password"}, { + meta: {global: {engines: {bookmarks: {version: engine.version, + syncID: engine.syncID}}}}, + bookmarks: {} + }); +} + add_test(function test_processIncoming_error_orderChildren() { _("Ensure that _orderChildren() is called even when _processIncoming() throws an error."); let syncTesting = new SyncTestingInfrastructure(); Svc.Prefs.set("clusterURL", "http://localhost:8080/"); Svc.Prefs.set("username", "foo"); - let collection = new ServerCollection(); let engine = new BookmarksEngine(); - let store = engine._store; - let global = new ServerWBO('global', - {engines: {bookmarks: {version: engine.version, - syncID: engine.syncID}}}); - let server = httpd_setup({ - "/1.1/foo/storage/meta/global": global.handler(), - "/1.1/foo/storage/bookmarks": collection.handler() - }); + let store = engine._store; + let server = serverForFoo(engine); + + 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/"); @@ -114,24 +117,22 @@ add_test(function test_processIncoming_e 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.wbos[folder1_guid] = new ServerWBO( - folder1_guid, encryptPayload(folder1_payload)); + 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.wbos[BOGUS_GUID] - = new ServerWBO(BOGUS_GUID, "I'm a bogus record!"); + 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]; @@ -163,27 +164,21 @@ add_test(function test_processIncoming_e add_test(function test_restorePromptsReupload() { _("Ensure that restoring from a backup will reupload all records."); let syncTesting = new SyncTestingInfrastructure(); Svc.Prefs.set("username", "foo"); Service.serverURL = "http://localhost:8080/"; Service.clusterURL = "http://localhost:8080/"; - let collection = new ServerCollection({}, true); + let engine = new BookmarksEngine(); + let store = engine._store; + let server = serverForFoo(engine); - let engine = new BookmarksEngine(); - let store = engine._store; - let global = new ServerWBO('global', - {engines: {bookmarks: {version: engine.version, - syncID: engine.syncID}}}); - let server = httpd_setup({ - "/1.1/foo/storage/meta/global": global.handler(), - "/1.1/foo/storage/bookmarks": collection.handler() - }); + 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); @@ -225,18 +220,19 @@ add_test(function test_restorePromptsReu } catch(ex) { error = ex; _("Got error: " + Utils.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 = [id for ([id, wbo] in Iterator(collection.wbos)) - if (["menu", "toolbar", "mobile", folder1_guid].indexOf(id) == -1)]; + let wbos = collection.keys(function (id) { + return ["menu", "toolbar", "mobile", folder1_guid].indexOf(id) == -1; + }); do_check_eq(wbos.length, 1); do_check_eq(wbos[0], bmk2_guid); _("Now restore from a backup."); PlacesUtils.restoreBookmarksFromJSONFile(backupFile); _("Ensure we have the bookmarks we expect locally."); let guids = store.getAllIDs(); @@ -268,34 +264,34 @@ add_test(function test_restorePromptsReu } catch(ex) { error = ex; _("Got error: " + Utils.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... - wbos = [JSON.parse(JSON.parse(wbo.payload).ciphertext) - for ([id, wbo] in Iterator(collection.wbos)) - if (wbo.payload)]; + 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")); + }); - _("WBOs: " + JSON.stringify(wbos)); - let bookmarks = [wbo for each (wbo in wbos) if (wbo.type == "bookmark")]; - do_check_eq(bookmarks.length, 1); - do_check_eq(bookmarks[0].id, newFX); - do_check_eq(bookmarks[0].bmkUri, fxuri.spec); - do_check_eq(bookmarks[0].title, "Get Firefox!"); + 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."); - let folders = [wbo for each (wbo in wbos) - if ((wbo.type == "folder") && - (wbo.id != "menu") && - (wbo.id != "toolbar"))]; - do_check_eq(folders.length, 1); - do_check_eq(folders[0].title, "Folder 1"); + do_check_eq(folderWBOs.length, 1); + do_check_eq(folderWBOs[0].title, "Folder 1"); } finally { store.wipe(); Svc.Prefs.resetBranch(""); Records.clearCache(); server.stop(run_next_test); } }); @@ -336,28 +332,21 @@ add_test(function test_mismatched_types( "parentid": "toolbar" }; let syncTesting = new SyncTestingInfrastructure(); Svc.Prefs.set("username", "foo"); Service.serverURL = "http://localhost:8080/"; Service.clusterURL = "http://localhost:8080/"; - let collection = new ServerCollection({}, true); + let engine = new BookmarksEngine(); + let store = engine._store; + let server = serverForFoo(engine); - let engine = new BookmarksEngine(); - let store = engine._store; - let global = new ServerWBO('global', - {engines: {bookmarks: {version: engine.version, - syncID: engine.syncID}}}); _("GUID: " + store.GUIDForId(6, true)); - let server = httpd_setup({ - "/1.1/foo/storage/meta/global": global.handler(), - "/1.1/foo/storage/bookmarks": collection.handler() - }); try { let bms = PlacesUtils.bookmarks; let oldR = new FakeRecord(BookmarkFolder, oldRecord); let newR = new FakeRecord(Livemark, newRecord); oldR._parent = PlacesUtils.bookmarks.toolbarFolder; newR._parent = PlacesUtils.bookmarks.toolbarFolder; @@ -385,34 +374,29 @@ add_test(function test_mismatched_types( }); add_test(function test_bookmark_guidMap_fail() { _("Ensure that failures building the GUID map cause early death."); let syncTesting = new SyncTestingInfrastructure(); Svc.Prefs.set("clusterURL", "http://localhost:8080/"); Svc.Prefs.set("username", "foo"); - let collection = new ServerCollection(); let engine = new BookmarksEngine(); let store = engine._store; - let global = new ServerWBO('global', - {engines: {bookmarks: {version: engine.version, - syncID: engine.syncID}}}); - let server = httpd_setup({ - "/1.1/foo/storage/meta/global": global.handler(), - "/1.1/foo/storage/bookmarks": collection.handler() - }); + + let store = engine._store; + let server = serverForFoo(engine); + let coll = server.user("foo").collection("bookmarks"); // Add one item to the server. let itemID = PlacesUtils.bookmarks.createFolder( PlacesUtils.bookmarks.toolbarFolder, "Folder 1", 0); - let itemGUID = store.GUIDForId(itemID); + let itemGUID = store.GUIDForId(itemID); let itemPayload = store.createRecord(itemGUID).cleartext; - let encPayload = encryptPayload(itemPayload); - collection.wbos[itemGUID] = new ServerWBO(itemGUID, encPayload); + coll.insert(itemGUID, encryptPayload(itemPayload)); engine.lastSync = 1; // So we don't back up. // Make building the GUID map fail. store.getAllIDs = function () { throw "Nooo"; }; // Ensure that we throw when accessing _guidMap. engine._syncStartup();
--- a/services/sync/tests/unit/test_bookmark_smart_bookmarks.js +++ b/services/sync/tests/unit/test_bookmark_smart_bookmarks.js @@ -41,17 +41,25 @@ function smartBookmarkCount() { 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_test(function test_annotation_uploaded() { let startCount = smartBookmarkCount(); _("Start count is " + startCount); if (startCount > 0) { @@ -97,35 +105,30 @@ add_test(function test_annotation_upload _("Our count has increased since we started."); do_check_eq(smartBookmarkCount(), startCount + 1); _("Sync record to the server."); Svc.Prefs.set("username", "foo"); Service.serverURL = "http://localhost:8080/"; Service.clusterURL = "http://localhost:8080/"; - let collection = new ServerCollection({}, true); - let global = new ServerWBO('global', - {engines: {bookmarks: {version: engine.version, - syncID: engine.syncID}}}); - let server = httpd_setup({ - "/1.1/foo/storage/meta/global": global.handler(), - "/1.1/foo/storage/bookmarks": collection.handler() - }); + let server = serverForFoo(engine); + let collection = server.user("foo").collection("bookmarks"); try { engine.sync(); - let wbos = [id for ([id, wbo] in Iterator(collection.wbos)) - if (["menu", "toolbar", "mobile"].indexOf(id) == -1)]; + let wbos = collection.keys(function (id) { + return ["menu", "toolbar", "mobile"].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.wbos[serverGUID]; + 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."); @@ -188,25 +191,19 @@ add_test(function test_smart_bookmarks_d let record = store.createRecord(mostVisitedGUID); _("Prepare sync."); Svc.Prefs.set("username", "foo"); Service.serverURL = "http://localhost:8080/"; Service.clusterURL = "http://localhost:8080/"; - let collection = new ServerCollection({}, true); - let global = new ServerWBO('global', - {engines: {bookmarks: {version: engine.version, - syncID: engine.syncID}}}); - let server = httpd_setup({ - "/1.1/foo/storage/meta/global": global.handler(), - "/1.1/foo/storage/bookmarks": collection.handler() - }); - + let server = serverForFoo(engine); + 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));
--- a/services/sync/tests/unit/test_clients_engine.js +++ b/services/sync/tests/unit/test_clients_engine.js @@ -1,537 +1,544 @@ -Cu.import("resource://services-sync/constants.js"); -Cu.import("resource://services-sync/record.js"); -Cu.import("resource://services-sync/identity.js"); -Cu.import("resource://services-sync/util.js"); -Cu.import("resource://services-sync/engines.js"); -Cu.import("resource://services-sync/engines/clients.js"); -Cu.import("resource://services-sync/service.js"); - -const MORE_THAN_CLIENTS_TTL_REFRESH = 691200; // 8 days -const LESS_THAN_CLIENTS_TTL_REFRESH = 86400; // 1 day - -add_test(function test_bad_hmac() { - _("Ensure that Clients engine deletes corrupt records."); - let global = new ServerWBO('global', - {engines: {clients: {version: Clients.version, - syncID: Clients.syncID}}}); - let clientsColl = new ServerCollection({}, true); - let keysWBO = new ServerWBO("keys"); - - let collectionsHelper = track_collections_helper(); - let upd = collectionsHelper.with_updated_collection; - let collections = collectionsHelper.collections; - - // Watch for deletions in the given collection. - let deleted = false; - function trackDeletedHandler(coll, handler) { - let u = upd(coll, handler); - return function(request, response) { - if (request.method == "DELETE") - deleted = true; - - return u(request, response); - }; - } - - 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", keysWBO.handler()), - "/1.1/foo/storage/clients": trackDeletedHandler("clients", clientsColl.handler()) - }; - - let server = httpd_setup(handlers); - - try { - let passphrase = "abcdeabcdeabcdeabcdeabcdea"; - Service.serverURL = "http://localhost:8080/"; - Service.clusterURL = "http://localhost:8080/"; - Service.login("foo", "ilovejane", passphrase); - - generateNewKeys(); - - _("First sync, client record is uploaded"); - do_check_eq(0, clientsColl.count()); - do_check_eq(Clients.lastRecordUpload, 0); - Clients.sync(); - do_check_eq(1, clientsColl.count()); - do_check_true(Clients.lastRecordUpload > 0); - deleted = false; // Initial setup can wipe the server, so clean up. - - _("Records now: " + clientsColl.get({})); - _("Change our keys and our client ID, reupload keys."); - Clients.localID = Utils.makeGUID(); - Clients.resetClient(); - generateNewKeys(); - let serverKeys = CollectionKeys.asWBO("crypto", "keys"); - serverKeys.encrypt(Weave.Service.syncKeyBundle); - do_check_true(serverKeys.upload(Weave.Service.cryptoKeysURL).success); - - _("Sync."); - do_check_true(!deleted); - Clients.sync(); - - _("Old record was deleted, new one uploaded."); - do_check_true(deleted); - do_check_eq(1, clientsColl.count()); - _("Records now: " + clientsColl.get({})); - - _("Now change our keys but don't upload them. " + - "That means we get an HMAC error but redownload keys."); - Service.lastHMACEvent = 0; - Clients.localID = Utils.makeGUID(); - Clients.resetClient(); - generateNewKeys(); - deleted = false; - do_check_eq(1, clientsColl.count()); - Clients.sync(); - - _("Old record was not deleted, new one uploaded."); - do_check_false(deleted); - do_check_eq(2, clientsColl.count()); - _("Records now: " + clientsColl.get({})); - - _("Now try the scenario where our keys are wrong *and* there's a bad record."); - // Clean up and start fresh. - clientsColl.wbos = {}; - Service.lastHMACEvent = 0; - Clients.localID = Utils.makeGUID(); - Clients.resetClient(); - deleted = false; - do_check_eq(0, clientsColl.count()); - - // Create and upload keys. - generateNewKeys(); - serverKeys = CollectionKeys.asWBO("crypto", "keys"); - serverKeys.encrypt(Weave.Service.syncKeyBundle); - do_check_true(serverKeys.upload(Weave.Service.cryptoKeysURL).success); - - // Sync once to upload a record. - Clients.sync(); - do_check_eq(1, clientsColl.count()); - - // Generate and upload new keys, so the old client record is wrong. - generateNewKeys(); - serverKeys = CollectionKeys.asWBO("crypto", "keys"); - serverKeys.encrypt(Weave.Service.syncKeyBundle); - do_check_true(serverKeys.upload(Weave.Service.cryptoKeysURL).success); - - // 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. - Clients.localID = Utils.makeGUID(); - Clients.resetClient(); - generateNewKeys(); - let oldKey = CollectionKeys.keyForCollection(); - - do_check_false(deleted); - Clients.sync(); - do_check_true(deleted); - do_check_eq(1, clientsColl.count()); - let newKey = CollectionKeys.keyForCollection(); - do_check_false(oldKey.equals(newKey)); - - } finally { - Svc.Prefs.resetBranch(""); - Records.clearCache(); - server.stop(run_next_test); - } -}); - -add_test(function test_properties() { - _("Test lastRecordUpload property"); - try { - do_check_eq(Svc.Prefs.get("clients.lastRecordUpload"), undefined); - do_check_eq(Clients.lastRecordUpload, 0); - - let now = Date.now(); - Clients.lastRecordUpload = now / 1000; - do_check_eq(Clients.lastRecordUpload, Math.floor(now / 1000)); - } finally { - Svc.Prefs.resetBranch(""); - run_next_test(); - } -}); - -add_test(function test_sync() { - _("Ensure that Clients engine uploads a new client record once a week."); - Svc.Prefs.set("clusterURL", "http://localhost:8080/"); - Svc.Prefs.set("username", "foo"); - - generateNewKeys(); - - let global = new ServerWBO('global', - {engines: {clients: {version: Clients.version, - syncID: Clients.syncID}}}); - let coll = new ServerCollection(); - let clientwbo = coll.wbos[Clients.localID] = new ServerWBO(Clients.localID); - let server = httpd_setup({ - "/1.1/foo/storage/meta/global": global.handler(), - "/1.1/foo/storage/clients": coll.handler() - }); - server.registerPathHandler( - "/1.1/foo/storage/clients/" + Clients.localID, clientwbo.handler()); - - try { - - _("First sync, client record is uploaded"); - do_check_eq(clientwbo.payload, undefined); - do_check_eq(Clients.lastRecordUpload, 0); - Clients.sync(); - do_check_true(!!clientwbo.payload); - do_check_true(Clients.lastRecordUpload > 0); - - _("Let's time travel more than a week back, new record should've been uploaded."); - Clients.lastRecordUpload -= MORE_THAN_CLIENTS_TTL_REFRESH; - let lastweek = Clients.lastRecordUpload; - clientwbo.payload = undefined; - Clients.sync(); - do_check_true(!!clientwbo.payload); - do_check_true(Clients.lastRecordUpload > lastweek); - - _("Remove client record."); - Clients.removeClientData(); - do_check_eq(clientwbo.payload, undefined); - - _("Time travel one day back, no record uploaded."); - Clients.lastRecordUpload -= LESS_THAN_CLIENTS_TTL_REFRESH; - let yesterday = Clients.lastRecordUpload; - Clients.sync(); - do_check_eq(clientwbo.payload, undefined); - do_check_eq(Clients.lastRecordUpload, yesterday); - - } finally { - Svc.Prefs.resetBranch(""); - Records.clearCache(); - server.stop(run_next_test); - } -}); - -add_test(function test_client_name_change() { - _("Ensure client name change incurs a client record update."); - - let tracker = Clients._tracker; - - let localID = Clients.localID; - let initialName = Clients.localName; - - Svc.Obs.notify("weave:engine:start-tracking"); - _("initial name: " + initialName); - - // Tracker already has data, so clear it. - tracker.clearChangedIDs(); - - let initialScore = tracker.score; - - do_check_eq(Object.keys(tracker.changedIDs).length, 0); - - Svc.Prefs.set("client.name", "new name"); - - _("new name: " + Clients.localName); - do_check_neq(initialName, Clients.localName); - do_check_eq(Object.keys(tracker.changedIDs).length, 1); - do_check_true(Clients.localID in tracker.changedIDs); - do_check_true(tracker.score > initialScore); - do_check_true(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 = Clients._store; - let tracker = Clients._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"]; - - Clients._sendCommandToClient(action, args, remoteId); - - let newRecord = store._remoteClients[remoteId]; - do_check_neq(newRecord, undefined); - do_check_eq(newRecord.commands.length, 1); - - let command = newRecord.commands[0]; - do_check_eq(command.command, action); - do_check_eq(command.args.length, 2); - do_check_eq(command.args, args); - - do_check_neq(tracker.changedIDs[remoteId], undefined); - - run_next_test(); -}); - -add_test(function test_command_validation() { - _("Verifies that command validation works properly."); - - let store = Clients._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 each (let [action, args, expectedResult] in testCommands) { - let remoteId = Utils.makeGUID(); - let rec = new ClientsRec("clients", remoteId); - - store.create(rec); - store.createRecord(remoteId, "clients"); - - Clients.sendCommand(action, args, remoteId); - - let newRecord = store._remoteClients[remoteId]; - do_check_neq(newRecord, undefined); - - if (expectedResult) { - _("Ensuring command is sent: " + action); - do_check_eq(newRecord.commands.length, 1); - - let command = newRecord.commands[0]; - do_check_eq(command.command, action); - do_check_eq(command.args, args); - - do_check_neq(Clients._tracker, undefined); - do_check_neq(Clients._tracker.changedIDs[remoteId], undefined); - } else { - _("Ensuring command is scrubbed: " + action); - do_check_eq(newRecord.commands, undefined); - - if (store._tracker) { - do_check_eq(Clients._tracker[remoteId], undefined); - } - } - - } - run_next_test(); -}); - -add_test(function test_command_duplication() { - _("Ensures duplicate commands are detected and not added"); - - let store = Clients._store; - let remoteId = Utils.makeGUID(); - let rec = new ClientsRec("clients", remoteId); - store.create(rec); - store.createRecord(remoteId, "clients"); - - let action = "resetAll"; - let args = []; - - Clients.sendCommand(action, args, remoteId); - Clients.sendCommand(action, args, remoteId); - - let newRecord = store._remoteClients[remoteId]; - do_check_eq(newRecord.commands.length, 1); - - _("Check variant args length"); - newRecord.commands = []; - - action = "resetEngine"; - Clients.sendCommand(action, [{ x: "foo" }], remoteId); - Clients.sendCommand(action, [{ x: "bar" }], remoteId); - - _("Make sure we spot a real dupe argument."); - Clients.sendCommand(action, [{ x: "bar" }], remoteId); - - do_check_eq(newRecord.commands.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 { - Clients.sendCommand("wipeAll", [], id); - } catch (ex) { - error = ex; - } - - do_check_eq(error.message.indexOf("Unknown remote client ID: "), 0); - - run_next_test(); -}); - -add_test(function test_process_incoming_commands() { - _("Ensures local commands are executed"); - - Clients.localCommands = [{ command: "logout", args: [] }]; - - let ev = "weave:service:logout:finish"; - - var handler = function() { - Svc.Obs.remove(ev, handler); - run_next_test(); - }; - - Svc.Obs.add(ev, handler); - - // logout command causes processIncomingCommands to return explicit false. - do_check_false(Clients.processIncomingCommands()); -}); - -add_test(function test_command_sync() { - _("Ensure that commands are synced across clients."); - Svc.Prefs.set("clusterURL", "http://localhost:8080/"); - Svc.Prefs.set("username", "foo"); - - generateNewKeys(); - - let global = new ServerWBO('global', - {engines: {clients: {version: Clients.version, - syncID: Clients.syncID}}}); - let coll = new ServerCollection(); - let clientwbo = coll.wbos[Clients.localID] = new ServerWBO(Clients.localID); - let server = httpd_setup({ - "/1.1/foo/storage/meta/global": global.handler(), - "/1.1/foo/storage/clients": coll.handler() - }); - let remoteId = Utils.makeGUID(); - let remotewbo = coll.wbos[remoteId] = new ServerWBO(remoteId); - server.registerPathHandler( - "/1.1/foo/storage/clients/" + Clients.localID, clientwbo.handler()); - server.registerPathHandler( - "/1.1/foo/storage/clients/" + remoteId, remotewbo.handler()); - - _("Create remote client record"); - let rec = new ClientsRec("clients", remoteId); - Clients._store.create(rec); - let remoteRecord = Clients._store.createRecord(remoteId, "clients"); - Clients.sendCommand("wipeAll", []); - - let clientRecord = Clients._store._remoteClients[remoteId]; - do_check_neq(clientRecord, undefined); - do_check_eq(clientRecord.commands.length, 1); - - try { - Clients.sync(); - do_check_neq(clientwbo.payload, undefined); - do_check_true(Clients.lastRecordUpload > 0); - - do_check_neq(remotewbo.payload, undefined); - - Svc.Prefs.set("client.GUID", remoteId); - Clients._resetClient(); - do_check_eq(Clients.localID, remoteId); - Clients.sync(); - do_check_neq(Clients.localCommands, undefined); - do_check_eq(Clients.localCommands.length, 1); - - let command = Clients.localCommands[0]; - do_check_eq(command.command, "wipeAll"); - do_check_eq(command.args.length, 0); - - } finally { - Svc.Prefs.resetBranch(""); - Records.clearCache(); - server.stop(run_next_test); - } -}); - -add_test(function test_send_uri_to_client_for_display() { - _("Ensure sendURIToClientForDisplay() sends command properly."); - - let tracker = Clients._tracker; - let store = Clients._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/"; - Clients.sendURIToClientForDisplay(uri, remoteId); - - let newRecord = store._remoteClients[remoteId]; - - do_check_neq(newRecord, undefined); - do_check_eq(newRecord.commands.length, 1); - - let command = newRecord.commands[0]; - do_check_eq(command.command, "displayURI"); - do_check_eq(command.args.length, 2); - do_check_eq(command.args[0], uri); - - do_check_true(tracker.score > initialScore); - do_check_true(tracker.score - initialScore >= SCORE_INCREMENT_XLARGE); - - _("Ensure unknown client IDs result in exception."); - let unknownId = Utils.makeGUID(); - let error; - - try { - Clients.sendURIToClientForDisplay(uri, unknownId); - } catch (ex) { - error = ex; - } - - do_check_eq(error.message.indexOf("Unknown remote client ID: "), 0); - - 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 command = { - command: "displayURI", - args: [uri, remoteId], - }; - - Clients.localCommands = [command]; - - // Received 'displayURI' command should result in the topic defined below - // being called. - let ev = "weave:engine:clients:display-uri"; - - let handler = function(subject, data) { - Svc.Obs.remove(ev, handler); - - do_check_eq(subject.uri, uri); - do_check_eq(subject.client, remoteId); - do_check_eq(data, null); - - run_next_test(); - }; - - Svc.Obs.add(ev, handler); - - do_check_true(Clients.processIncomingCommands()); -}); - -function run_test() { - initTestLogging("Trace"); - Log4Moz.repository.getLogger("Sync.Engine.Clients").level = Log4Moz.Level.Trace; - run_next_test(); -} +Cu.import("resource://services-sync/constants.js"); +Cu.import("resource://services-sync/record.js"); +Cu.import("resource://services-sync/identity.js"); +Cu.import("resource://services-sync/util.js"); +Cu.import("resource://services-sync/engines.js"); +Cu.import("resource://services-sync/engines/clients.js"); +Cu.import("resource://services-sync/service.js"); + +const MORE_THAN_CLIENTS_TTL_REFRESH = 691200; // 8 days +const LESS_THAN_CLIENTS_TTL_REFRESH = 86400; // 1 day + +add_test(function test_bad_hmac() { + _("Ensure that Clients engine deletes corrupt records."); + let contents = { + meta: {global: {engines: {clients: {version: Clients.version, + syncID: Clients.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. + do_check_eq(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(); + let serverKeys = CollectionKeys.asWBO("crypto", "keys"); + serverKeys.encrypt(Weave.Service.syncKeyBundle); + do_check_true(serverKeys.upload(Weave.Service.cryptoKeysURL).success); + } + + try { + let passphrase = "abcdeabcdeabcdeabcdeabcdea"; + Service.serverURL = "http://localhost:8080/"; + Service.clusterURL = "http://localhost:8080/"; + Service.login("foo", "ilovejane", passphrase); + + generateNewKeys(); + + _("First sync, client record is uploaded"); + do_check_eq(Clients.lastRecordUpload, 0); + check_clients_count(0); + Clients.sync(); + check_clients_count(1); + do_check_true(Clients.lastRecordUpload > 0); + + // Initial setup can wipe the server, so clean up. + deletedCollections = []; + deletedItems = []; + + _("Change our keys and our client ID, reupload keys."); + let oldLocalID = Clients.localID; // Preserve to test for deletion! + Clients.localID = Utils.makeGUID(); + Clients.resetClient(); + generateNewKeys(); + let serverKeys = CollectionKeys.asWBO("crypto", "keys"); + serverKeys.encrypt(Weave.Service.syncKeyBundle); + do_check_true(serverKeys.upload(Weave.Service.cryptoKeysURL).success); + + _("Sync."); + Clients.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; + Clients.localID = Utils.makeGUID(); + Clients.resetClient(); + generateNewKeys(); + deletedCollections = []; + deletedItems = []; + check_clients_count(1); + Clients.sync(); + + _("Old record was not deleted, new one uploaded."); + do_check_eq(deletedCollections.length, 0); + do_check_eq(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; + Clients.localID = Utils.makeGUID(); + Clients.resetClient(); + deletedCollections = []; + deletedItems = []; + check_clients_count(0); + + uploadNewKeys(); + + // Sync once to upload a record. + Clients.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 = Clients.localID; // Preserve to test for deletion! + Clients.localID = Utils.makeGUID(); + Clients.resetClient(); + generateNewKeys(); + let oldKey = CollectionKeys.keyForCollection(); + + do_check_eq(deletedCollections.length, 0); + do_check_eq(deletedItems.length, 0); + Clients.sync(); + do_check_eq(deletedItems.length, 1); + check_client_deleted(oldLocalID); + check_clients_count(1); + let newKey = CollectionKeys.keyForCollection(); + do_check_false(oldKey.equals(newKey)); + + } finally { + Svc.Prefs.resetBranch(""); + Records.clearCache(); + server.stop(run_next_test); + } +}); + +add_test(function test_properties() { + _("Test lastRecordUpload property"); + try { + do_check_eq(Svc.Prefs.get("clients.lastRecordUpload"), undefined); + do_check_eq(Clients.lastRecordUpload, 0); + + let now = Date.now(); + Clients.lastRecordUpload = now / 1000; + do_check_eq(Clients.lastRecordUpload, Math.floor(now / 1000)); + } finally { + Svc.Prefs.resetBranch(""); + run_next_test(); + } +}); + +add_test(function test_sync() { + _("Ensure that Clients engine uploads a new client record once a week."); + Svc.Prefs.set("clusterURL", "http://localhost:8080/"); + Svc.Prefs.set("username", "foo"); + generateNewKeys(); + + let contents = { + meta: {global: {engines: {clients: {version: Clients.version, + syncID: Clients.syncID}}}}, + clients: {}, + crypto: {} + }; + let server = serverForUsers({"foo": "password"}, contents); + let user = server.user("foo"); + + function clientWBO() { + return user.collection("clients").wbo(Clients.localID); + } + + try { + + _("First sync. Client record is uploaded."); + do_check_eq(clientWBO(), undefined); + do_check_eq(Clients.lastRecordUpload, 0); + Clients.sync(); + do_check_true(!!clientWBO().payload); + do_check_true(Clients.lastRecordUpload > 0); + + _("Let's time travel more than a week back, new record should've been uploaded."); + Clients.lastRecordUpload -= MORE_THAN_CLIENTS_TTL_REFRESH; + let lastweek = Clients.lastRecordUpload; + clientWBO().payload = undefined; + Clients.sync(); + do_check_true(!!clientWBO().payload); + do_check_true(Clients.lastRecordUpload > lastweek); + + _("Remove client record."); + Clients.removeClientData(); + do_check_eq(clientWBO().payload, undefined); + + _("Time travel one day back, no record uploaded."); + Clients.lastRecordUpload -= LESS_THAN_CLIENTS_TTL_REFRESH; + let yesterday = Clients.lastRecordUpload; + Clients.sync(); + do_check_eq(clientWBO().payload, undefined); + do_check_eq(Clients.lastRecordUpload, yesterday); + + } finally { + Svc.Prefs.resetBranch(""); + Records.clearCache(); + server.stop(run_next_test); + } +}); + +add_test(function test_client_name_change() { + _("Ensure client name change incurs a client record update."); + + let tracker = Clients._tracker; + + let localID = Clients.localID; + let initialName = Clients.localName; + + Svc.Obs.notify("weave:engine:start-tracking"); + _("initial name: " + initialName); + + // Tracker already has data, so clear it. + tracker.clearChangedIDs(); + + let initialScore = tracker.score; + + do_check_eq(Object.keys(tracker.changedIDs).length, 0); + + Svc.Prefs.set("client.name", "new name"); + + _("new name: " + Clients.localName); + do_check_neq(initialName, Clients.localName); + do_check_eq(Object.keys(tracker.changedIDs).length, 1); + do_check_true(Clients.localID in tracker.changedIDs); + do_check_true(tracker.score > initialScore); + do_check_true(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 = Clients._store; + let tracker = Clients._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"]; + + Clients._sendCommandToClient(action, args, remoteId); + + let newRecord = store._remoteClients[remoteId]; + do_check_neq(newRecord, undefined); + do_check_eq(newRecord.commands.length, 1); + + let command = newRecord.commands[0]; + do_check_eq(command.command, action); + do_check_eq(command.args.length, 2); + do_check_eq(command.args, args); + + do_check_neq(tracker.changedIDs[remoteId], undefined); + + run_next_test(); +}); + +add_test(function test_command_validation() { + _("Verifies that command validation works properly."); + + let store = Clients._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 each (let [action, args, expectedResult] in testCommands) { + let remoteId = Utils.makeGUID(); + let rec = new ClientsRec("clients", remoteId); + + store.create(rec); + store.createRecord(remoteId, "clients"); + + Clients.sendCommand(action, args, remoteId); + + let newRecord = store._remoteClients[remoteId]; + do_check_neq(newRecord, undefined); + + if (expectedResult) { + _("Ensuring command is sent: " + action); + do_check_eq(newRecord.commands.length, 1); + + let command = newRecord.commands[0]; + do_check_eq(command.command, action); + do_check_eq(command.args, args); + + do_check_neq(Clients._tracker, undefined); + do_check_neq(Clients._tracker.changedIDs[remoteId], undefined); + } else { + _("Ensuring command is scrubbed: " + action); + do_check_eq(newRecord.commands, undefined); + + if (store._tracker) { + do_check_eq(Clients._tracker[remoteId], undefined); + } + } + + } + run_next_test(); +}); + +add_test(function test_command_duplication() { + _("Ensures duplicate commands are detected and not added"); + + let store = Clients._store; + let remoteId = Utils.makeGUID(); + let rec = new ClientsRec("clients", remoteId); + store.create(rec); + store.createRecord(remoteId, "clients"); + + let action = "resetAll"; + let args = []; + + Clients.sendCommand(action, args, remoteId); + Clients.sendCommand(action, args, remoteId); + + let newRecord = store._remoteClients[remoteId]; + do_check_eq(newRecord.commands.length, 1); + + _("Check variant args length"); + newRecord.commands = []; + + action = "resetEngine"; + Clients.sendCommand(action, [{ x: "foo" }], remoteId); + Clients.sendCommand(action, [{ x: "bar" }], remoteId); + + _("Make sure we spot a real dupe argument."); + Clients.sendCommand(action, [{ x: "bar" }], remoteId); + + do_check_eq(newRecord.commands.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 { + Clients.sendCommand("wipeAll", [], id); + } catch (ex) { + error = ex; + } + + do_check_eq(error.message.indexOf("Unknown remote client ID: "), 0); + + run_next_test(); +}); + +add_test(function test_process_incoming_commands() { + _("Ensures local commands are executed"); + + Clients.localCommands = [{ command: "logout", args: [] }]; + + let ev = "weave:service:logout:finish"; + + var handler = function() { + Svc.Obs.remove(ev, handler); + run_next_test(); + }; + + Svc.Obs.add(ev, handler); + + // logout command causes processIncomingCommands to return explicit false. + do_check_false(Clients.processIncomingCommands()); +}); + +add_test(function test_command_sync() { + _("Ensure that commands are synced across clients."); + Svc.Prefs.set("clusterURL", "http://localhost:8080/"); + Svc.Prefs.set("username", "foo"); + generateNewKeys(); + let contents = { + meta: {global: {engines: {clients: {version: Clients.version, + syncID: Clients.syncID}}}}, + clients: {}, + crypto: {} + }; + let server = serverForUsers({"foo": "password"}, contents); + let user = server.user("foo"); + let remoteId = Utils.makeGUID(); + + function clientWBO(id) { + return user.collection("clients").wbo(id); + } + + _("Create remote client record"); + let rec = new ClientsRec("clients", remoteId); + Clients._store.create(rec); + let remoteRecord = Clients._store.createRecord(remoteId, "clients"); + Clients.sendCommand("wipeAll", []); + + let clientRecord = Clients._store._remoteClients[remoteId]; + do_check_neq(clientRecord, undefined); + do_check_eq(clientRecord.commands.length, 1); + + try { + _("Syncing."); + Clients.sync(); + _("Checking record was uploaded."); + do_check_neq(clientWBO(Clients.localID).payload, undefined); + do_check_true(Clients.lastRecordUpload > 0); + + do_check_neq(clientWBO(remoteId).payload, undefined); + + Svc.Prefs.set("client.GUID", remoteId); + Clients._resetClient(); + do_check_eq(Clients.localID, remoteId); + Clients.sync(); + do_check_neq(Clients.localCommands, undefined); + do_check_eq(Clients.localCommands.length, 1); + + let command = Clients.localCommands[0]; + do_check_eq(command.command, "wipeAll"); + do_check_eq(command.args.length, 0); + + } finally { + Svc.Prefs.resetBranch(""); + Records.clearCache(); + server.stop(run_next_test); + } +}); + +add_test(function test_send_uri_to_client_for_display() { + _("Ensure sendURIToClientForDisplay() sends command properly."); + + let tracker = Clients._tracker; + let store = Clients._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/"; + Clients.sendURIToClientForDisplay(uri, remoteId); + + let newRecord = store._remoteClients[remoteId]; + + do_check_neq(newRecord, undefined); + do_check_eq(newRecord.commands.length, 1); + + let command = newRecord.commands[0]; + do_check_eq(command.command, "displayURI"); + do_check_eq(command.args.length, 2); + do_check_eq(command.args[0], uri); + + do_check_true(tracker.score > initialScore); + do_check_true(tracker.score - initialScore >= SCORE_INCREMENT_XLARGE); + + _("Ensure unknown client IDs result in exception."); + let unknownId = Utils.makeGUID(); + let error; + + try { + Clients.sendURIToClientForDisplay(uri, unknownId); + } catch (ex) { + error = ex; + } + + do_check_eq(error.message.indexOf("Unknown remote client ID: "), 0); + + 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 command = { + command: "displayURI", + args: [uri, remoteId], + }; + + Clients.localCommands = [command]; + + // Received 'displayURI' command should result in the topic defined below + // being called. + let ev = "weave:engine:clients:display-uri"; + + let handler = function(subject, data) { + Svc.Obs.remove(ev, handler); + + do_check_eq(subject.uri, uri); + do_check_eq(subject.client, remoteId); + do_check_eq(data, null); + + run_next_test(); + }; + + Svc.Obs.add(ev, handler); + + do_check_true(Clients.processIncomingCommands()); +}); + +function run_test() { + initTestLogging("Trace"); + Log4Moz.repository.getLogger("Sync.Engine.Clients").level = Log4Moz.Level.Trace; + run_next_test(); +}
--- a/services/sync/tests/unit/test_corrupt_keys.js +++ b/services/sync/tests/unit/test_corrupt_keys.js @@ -136,17 +136,17 @@ add_test(function test_locally_changed_k visits: [{date: (modified - 5) * 1000000, type: visitType}], deleted: false}; w.encrypt(); let wbo = new ServerWBO(id, {ciphertext: w.ciphertext, IV: w.IV, hmac: w.hmac}); wbo.modified = modified; - history.wbos[id] = wbo; + history.insertWBO(wbo); server.registerPathHandler( "/1.1/johndoe/storage/history/record-no--" + i, upd("history", wbo.handler())); } collections.history = Date.now()/1000; let old_key_time = collections.crypto; _("Old key time: " + old_key_time); @@ -196,17 +196,17 @@ add_test(function test_locally_changed_k deleted: false}; w.encrypt(); w.hmac = w.hmac.toUpperCase(); let wbo = new ServerWBO(id, {ciphertext: w.ciphertext, IV: w.IV, hmac: w.hmac}); wbo.modified = modified; - history.wbos[id] = wbo; + history.insertWBO(wbo); server.registerPathHandler( "/1.1/johndoe/storage/history/record-no--" + i, upd("history", wbo.handler())); } collections.history = Date.now()/1000; _("Server key time hasn't changed."); do_check_eq(collections.crypto, old_key_time);
--- a/services/sync/tests/unit/test_engine_abort.js +++ b/services/sync/tests/unit/test_engine_abort.js @@ -13,17 +13,17 @@ add_test(function test_processIncoming_a _("Create some server data."); let meta_global = Records.set(engine.metaURL, new WBORecord(engine.metaURL)); meta_global.payload.engines = {rotary: {version: engine.version, syncID: engine.syncID}}; let collection = new ServerCollection(); let id = Utils.makeGUID(); let payload = encryptPayload({id: id, denomination: "Record No. " + id}); - collection.wbos[id] = new ServerWBO(id, payload); + collection.insert(id, payload); let server = sync_httpd_setup({ "/1.1/foo/storage/rotary": collection.handler() }); _("Fake applyIncoming to abort."); engine._store.applyIncoming = function (record) { let ex = {code: Engine.prototype.eEngineAbortApplyIncoming,
--- a/services/sync/tests/unit/test_history_engine.js +++ b/services/sync/tests/unit/test_history_engine.js @@ -38,17 +38,17 @@ add_test(function test_processIncoming_m 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.wbos[id] = wbo; + collection.insertWBO(wbo); } let server = sync_httpd_setup({ "/1.1/foo/storage/history": collection.handler() }); let engine = new HistoryEngine("history"); let meta_global = Records.set(engine.metaURL, new WBORecord(engine.metaURL));
--- a/services/sync/tests/unit/test_hmac_error.js +++ b/services/sync/tests/unit/test_hmac_error.js @@ -183,34 +183,34 @@ add_test(function hmac_error_during_node 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.wbos["flying"] || - rotaryColl.wbos["scotsman"]; + 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.wbos["flying"] || - rotaryColl.wbos["scotsman"]; + 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.");
new file mode 100644 --- /dev/null +++ b/services/sync/tests/unit/test_httpd_sync_server.js @@ -0,0 +1,178 @@ +function run_test() { + run_next_test(); +} + +add_test(function test_creation() { + // Explicit callback for this one. + let s = new SyncServer({ + __proto__: SyncServerCallback, + }); + do_check_true(!!s); // Just so we have a check. + s.start(null, function () { + _("Started on " + s.port); + s.stop(run_next_test); + }); +}); + +add_test(function test_url_parsing() { + let s = new SyncServer(); + let parts = s.pathRE.exec("/1.1/johnsmith/storage/crypto/keys"); + let [all, version, username, first, rest] = parts; + 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, s.pathRE.exec("/nothing/else")); + run_next_test(); +}); + +Cu.import("resource://services-sync/rest.js"); +function localRequest(path) { + _("localRequest: " + path); + let url = "http://127.0.0.1:8080" + path; + _("url: " + url); + return new RESTRequest(url); +} + +add_test(function test_basic_http() { + let s = new SyncServer(); + s.registerUser("john", "password"); + do_check_true(s.userExists("john")); + s.start(8080, function () { + _("Started on " + s.port); + do_check_eq(s.port, 8080); + Utils.nextTick(function () { + let req = localRequest("/1.1/john/storage/crypto/keys"); + _("req is " + req); + req.get(function (err) { + do_check_eq(null, err); + Utils.nextTick(function () { + s.stop(run_next_test); + }); + }); + }); + }); +}); + +add_test(function test_info_collections() { + let s = 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); + } + + s.registerUser("john", "password"); + s.start(8080, function () { + do_check_eq(s.port, 8080); + Utils.nextTick(function () { + let req = localRequest("/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("/1.1/john/info/collections"); + req.get(function (err) { + do_check_eq(null, err); + responseHasCorrectHeaders(this.response); + let expectedColl = s.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 () { + s.stop(run_next_test); + }); + }); + } + let payload = JSON.stringify({foo: "bar"}); + localRequest("/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 s = new SyncServer(); + let creation = s.timestamp(); + s.registerUser("john", "password"); + + s.createContents("john", { + crypto: {foos: {foo: "bar"}} + }); + let coll = s.user("john").collection("crypto"); + do_check_true(!!coll); + + _("We're tracking timestamps."); + do_check_true(coll.timestamp >= creation); + + function retrieveWBONotExists(next) { + let req = localRequest(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(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); + }); + } + s.start(8080, function () { + retrieveWBONotExists( + retrieveWBOExists.bind(this, function () { + s.stop(run_next_test); + }) + ); + }); +}); + +add_test(function test_x_weave_records() { + let s = new SyncServer(); + s.registerUser("john", "password"); + + s.createContents("john", { + crypto: {foos: {foo: "bar"}, + bars: {foo: "baz"}} + }); + s.start(8080, function () { + let wbo = localRequest("/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("/1.1/john/storage/crypto"); + col.get(function (err) { + // Collection fetches do. + do_check_eq(this.response.headers["x-weave-records"], "2"); + s.stop(run_next_test); + }); + }); + }); +});
--- a/services/sync/tests/unit/test_jpakeclient.js +++ b/services/sync/tests/unit/test_jpakeclient.js @@ -1,36 +1,42 @@ Cu.import("resource://services-sync/log4moz.js"); 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"); 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")); + 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")); + do_check_true(request.hasHeader("X-KeyExchange-Id"), stack); do_check_eq(request.getHeader("X-KeyExchange-Id").length, - JPAKE_LENGTH_CLIENTID); + 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]) + while (channels[cid]) { cid = Math.floor(Math.random() * 10000); + } let channel = channels[cid] = new ServerChannel(); server.registerPathHandler("/" + cid, channel.handler()); return cid; } let server; let channels = {}; // Map channel -> ServerChannel object function server_new_channel(request, response) { @@ -40,60 +46,79 @@ function server_new_channel(request, res response.setStatusLine(request.httpVersion, 200, "OK"); response.bodyOutputStream.write(body, body.length); } let error_report; function server_report(request, response) { check_headers(request); - if (request.hasHeader("X-KeyExchange-Log")) + 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) + if (channel) { channel.clear(); + } } response.setStatusLine(request.httpVersion, 200, "OK"); } function ServerChannel() { - this.data = "{}"; + 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) { + if (etag == this.etag) { response.setStatusLine(request.httpVersion, 304, "Not Modified"); 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 == 6) + if (this.getCount == SERVER_MAX_GETS) { this.clear(); + } }, 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); + 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() { @@ -103,24 +128,47 @@ ServerChannel.prototype = { let method = self[request.method]; return method.apply(self, arguments); }; } }; +/** + * Controller that throws for everything. + */ +let 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() { Svc.Prefs.set("jpake.serverURL", "http://localhost:8080/"); Svc.Prefs.set("jpake.pollInterval", POLLINTERVAL); - Svc.Prefs.set("jpake.maxTries", 5); + 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); @@ -129,125 +177,200 @@ function run_test() { let id = new Identity(PWDMGR_PASSWORD_REALM, "johndoe"); id.password = "ilovejane"; ID.set("WeaveID", id); server = httpd_setup({"/new_channel": server_new_channel, "/report": server_report}); initTestLogging("Trace"); + Log4Moz.repository.getLogger("Sync.JPAKEClient").level = Log4Moz.Level.Trace; + Log4Moz.repository.getLogger("Sync.RESTRequest").level = Log4Moz.Level.Trace; run_next_test(); } add_test(function test_success_receiveNoPIN() { _("Test a successful exchange started by receiveNoPIN()."); let snd = new JPAKEClient({ - displayPIN: function displayPIN() { - do_throw("displayPIN shouldn't have been called!"); - }, - onAbort: function onAbort(error) { - do_throw("Shouldn't have aborted!" + error); + __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.sendWithPIN(pin, DATA); }); + Utils.nextTick(function() { snd.pairWithPIN(pin, false); }); }, - onAbort: function onAbort(error) { - do_throw("Shouldn't have aborted! " + error); + onPairingStart: function onPairingStart() { + pairingStartCalledOnReceiver = true; }, - onComplete: function onComplete(a) { + 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() { +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() { - do_throw("Shouldn't have completed! "); + 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. + _("Received PIN " + pin + ". Waiting 150ms before entering it into sender..."); + this.cid = pin.slice(JPAKE_LENGTH_SECRET); + Utils.namedTimer(function() { snd.pairWithPIN(pin, false); }, + 150, this, "_sendTimer"); + }, + 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); + 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. + _("Pairing successful, waiting 150ms to send final payload."); + Utils.namedTimer(function() { snd.sendAndComplete(DATA); }, + 150, this, "_sendTimer"); + }, + 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); + run_next_test(); + } + }); + + rec.receiveNoPIN(); +}); + + add_test(function test_wrongPIN() { _("Test abort when PINs don't match."); let snd = new JPAKEClient({ - displayPIN: function displayPIN() { - do_throw("displayPIN shouldn't have been called!"); - }, + __proto__: BaseController, onAbort: function onAbort(error) { do_check_eq(error, JPAKE_ERROR_KEYMISMATCH); do_check_eq(error_report, JPAKE_ERROR_KEYMISMATCH); error_report = undefined; - }, - onComplete: function onComplete() { - do_throw("Shouldn't have completed!"); } }); + 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 = [char for each (char in secret)].reverse().join(""); let new_pin = secret + this.cid; _("Received PIN " + pin + ", but I'm entering " + new_pin); - Utils.nextTick(function() { snd.sendWithPIN(new_pin, DATA); }); + 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(); - }, - onComplete: function onComplete() { - do_throw("Shouldn't have completed! "); } }); rec.receiveNoPIN(); }); add_test(function test_abort_receiver() { _("Test user abort on receiving side."); let rec = new JPAKEClient({ - onComplete: function onComplete(data) { - do_throw("onComplete shouldn't be called."); - }, + __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(); @@ -260,99 +383,143 @@ add_test(function test_abort_receiver() rec.receiveNoPIN(); }); add_test(function test_abort_sender() { _("Test user abort on sending side."); let snd = new JPAKEClient({ - displayPIN: function displayPIN() { - do_throw("displayPIN shouldn't have been called!"); - }, + __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; - }, - onComplete: function onComplete() { - do_throw("Shouldn't have completed!"); } }); let rec = new JPAKEClient({ - onComplete: function onComplete(data) { - do_throw("onComplete shouldn't be called."); - }, + __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); 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.sendWithPIN(pin, DATA); }); + Utils.nextTick(function() { snd.pairWithPIN(pin, false); }); Utils.namedTimer(function() { snd.abort(); }, POLLINTERVAL, this, "_abortTimer"); - } + }, + onPairingStart: function onPairingStart(pin) {} }); rec.receiveNoPIN(); }); add_test(function test_wrongmessage() { let cid = new_channel(); - channels[cid].data = JSON.stringify({type: "receiver2", payload: {}}); + 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.sendWithPIN("01234567" + cid, DATA); + 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({ - onComplete: function onComplete(data) { - do_throw("onComplete shouldn't be called."); - }, + __proto__: BaseController, onAbort: function onAbort(error) { do_check_eq(error, JPAKE_ERROR_CHANNEL); - Svc.Prefs.reset("jpake.serverURL"); + 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({ - onComplete: function onComplete(data) { - do_throw("onComplete shouldn't be called."); - }, + __proto__: BaseController, onAbort: function onAbort(error) { do_check_eq(error, JPAKE_ERROR_NETWORK); - Svc.Prefs.reset("jpake.serverURL"); + 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.sendWithPIN("0123456789ab", DATA); + 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); });
new file mode 100644 --- /dev/null +++ b/services/sync/tests/unit/test_sendcredentials_controller.js @@ -0,0 +1,96 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +Cu.import("resource://services-sync/policies.js"); +Cu.import("resource://services-sync/constants.js"); +Cu.import("resource://services-sync/service.js"); +Cu.import("resource://services-sync/util.js"); + +function run_test() { + Service.account = "johndoe"; + Service.password = "ilovejane"; + Service.passphrase = Utils.generatePassphrase(); + Service.serverURL = "http://weave.server/"; + + 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; + this.controller.onComplete(); + Svc.Obs.notify(topic); + + // Verify it sends the correct data. + do_check_eq(data.account, Service.account); + do_check_eq(data.password, Service.password); + do_check_eq(data.synckey, Service.passphrase); + do_check_eq(data.serverURL, Service.serverURL); + + // Verify it schedules a sync for the expected interval. + let expectedInterval = SyncScheduler.activeInterval; + do_check_true(SyncScheduler.nextSync - Date.now() <= expectedInterval); + SyncScheduler.setDefaults(); + + Utils.nextTick(run_next_test); + } + }; + jpakeclient.controller = new SendCredentialsController(jpakeclient); + 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); + + // 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); + + 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); +});
--- a/services/sync/tests/unit/test_syncengine_sync.js +++ b/services/sync/tests/unit/test_syncengine_sync.js @@ -40,54 +40,54 @@ add_test(function test_syncStartup_empty _("SyncEngine._syncStartup resets sync and wipes server data if there's no or an outdated global record"); let syncTesting = new SyncTestingInfrastructure(); Svc.Prefs.set("clusterURL", "http://localhost:8080/"); Svc.Prefs.set("username", "foo"); // Some server side data that's going to be wiped 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.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 engine = makeRotaryEngine(); engine._store.items = {rekolok: "Rekonstruktionslokomotive"}; try { // Confirm initial environment do_check_eq(engine._tracker.changedIDs["rekolok"], undefined); let metaGlobal = Records.get(engine.metaURL); do_check_eq(metaGlobal.payload.engines, undefined); - do_check_true(!!collection.wbos.flying.payload); - do_check_true(!!collection.wbos.scotsman.payload); + 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.wbos.flying.payload, undefined); - do_check_eq(collection.wbos.scotsman.payload, undefined); + 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 "); @@ -187,32 +187,32 @@ add_test(function test_processIncoming_c let syncTesting = new SyncTestingInfrastructure(); Svc.Prefs.set("clusterURL", "http://localhost:8080/"); Svc.Prefs.set("username", "foo"); generateNewKeys(); // Some server records that will be downloaded 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.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. - collection.wbos['../pathological'] = new ServerWBO( - '../pathological', encryptPayload({id: '../pathological', - denomination: "Pathological Case"})); + 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.wbos.flying.handler(), - "/1.1/foo/storage/rotary/scotsman": collection.wbos.scotsman.handler() + "/1.1/foo/storage/rotary/flying": collection.wbo("flying").handler(), + "/1.1/foo/storage/rotary/scotsman": collection.wbo("scotsman").handler() }); let engine = makeRotaryEngine(); let meta_global = Records.set(engine.metaURL, new WBORecord(engine.metaURL)); meta_global.payload.engines = {rotary: {version: engine.version, syncID: engine.syncID}}; try { @@ -247,58 +247,58 @@ add_test(function test_processIncoming_r let syncTesting = new SyncTestingInfrastructure(); Svc.Prefs.set("clusterURL", "http://localhost:8080/"); Svc.Prefs.set("username", "foo"); let collection = new ServerCollection(); // This server record is newer than the corresponding client one, // so it'll update its data. - collection.wbos.newrecord = new ServerWBO( - 'newrecord', encryptPayload({id: 'newrecord', - denomination: "New stuff..."})); + 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.wbos.newerserver = new ServerWBO( - 'newerserver', encryptPayload({id: 'newerserver', - denomination: "New 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.wbos.olderidentical = new ServerWBO( - 'olderidentical', encryptPayload({id: 'olderidentical', - denomination: "Older but identical"})); - collection.wbos.olderidentical.modified -= 120; + 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.wbos.updateclient = new ServerWBO( - 'updateclient', encryptPayload({id: 'updateclient', - denomination: "Get this!"})); + collection.insert('updateclient', + encryptPayload({id: 'updateclient', + denomination: "Get this!"})); // This is a dupe of 'original' but with a longer GUID, so we're // expecting it to be marked for deletion from the server - collection.wbos.duplication = new ServerWBO( - 'duplication', encryptPayload({id: 'duplication', - denomination: "Original Entry"})); + collection.insert('duplication', + encryptPayload({id: 'duplication', + denomination: "Original Entry"})); // This is a dupe of 'long_original' but with a shorter GUID, so we're // expecting it to replace 'long_original'. - collection.wbos.dupe = new ServerWBO( - 'dupe', encryptPayload({id: 'dupe', - denomination: "Long Original Entry"})); + collection.insert('dupe', + encryptPayload({id: 'dupe', + denomination: "Long Original Entry"})); // This record is marked as deleted, so we're expecting the client // record to be removed. - collection.wbos.nukeme = new ServerWBO( - 'nukeme', encryptPayload({id: 'nukeme', - denomination: "Nuke me!", - deleted: true})); + collection.insert('nukeme', + encryptPayload({id: 'nukeme', + denomination: "Nuke me!", + deleted: true})); let server = sync_httpd_setup({ "/1.1/foo/storage/rotary": collection.handler() }); let engine = makeRotaryEngine(); engine._store.items = {newerserver: "New data, but not as new as server!", olderidentical: "Older but identical", @@ -375,22 +375,22 @@ add_test(function test_processIncoming_m 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 (var i = 0; i < 234; i++) { + 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.wbos[id] = wbo; + collection.insertWBO(wbo); } let server = sync_httpd_setup({ "/1.1/foo/storage/rotary": collection.handler() }); let engine = makeRotaryEngine(); let meta_global = Records.set(engine.metaURL, new WBORecord(engine.metaURL)); @@ -451,17 +451,17 @@ add_test(function test_processIncoming_s }; // 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.wbos[id] = wbo; + collection.insertWBO(wbo); } let engine = makeRotaryEngine(); engine.enabled = true; let meta_global = Records.set(engine.metaURL, new WBORecord(engine.metaURL)); meta_global.payload.engines = {rotary: {version: engine.version, syncID: engine.syncID}}; @@ -485,17 +485,17 @@ add_test(function test_processIncoming_s // 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.wbos["record-no-99"].modified); + do_check_eq(engine.lastSync, collection.wbo("record-no-99").modified); } finally { cleanAndGo(server); } }); add_test(function test_processIncoming_resume_toFetch() { @@ -503,36 +503,36 @@ add_test(function test_processIncoming_r let syncTesting = new SyncTestingInfrastructure(); Svc.Prefs.set("clusterURL", "http://localhost:8080/"); Svc.Prefs.set("username", "foo"); const LASTSYNC = Date.now() / 1000; // Server records that will be downloaded 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"})); - for (var i = 0; i < 3; i++) { + 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.wbos[id] = wbo; + collection.insertWBO(wbo); } - collection.wbos.flying.modified = collection.wbos.scotsman.modified - = LASTSYNC - 10; - collection.wbos.rekolok.modified = LASTSYNC + 10; + 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 meta_global = Records.set(engine.metaURL, new WBORecord(engine.metaURL)); @@ -584,17 +584,17 @@ add_test(function test_processIncoming_a 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.wbos[id] = new ServerWBO(id, payload); + collection.insert(id, payload); } let meta_global = Records.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() }); @@ -639,17 +639,17 @@ add_test(function test_processIncoming_a 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.wbos[id] = new ServerWBO(id, payload); + collection.insert(id, payload); } let meta_global = Records.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() }); @@ -690,17 +690,17 @@ add_test(function test_processIncoming_n 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.wbos[id] = new ServerWBO(id, payload); + collection.insert(id, payload); } let meta_global = Records.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() }); @@ -777,17 +777,17 @@ add_test(function test_processIncoming_p 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.wbos[id] = new ServerWBO(id, payload); + collection.insert(id, payload); } let meta_global = Records.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() }); @@ -852,17 +852,17 @@ add_test(function test_processIncoming_f // 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.wbos[id] = wbo; + 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), @@ -981,26 +981,26 @@ add_test(function test_processIncoming_d let syncTesting = new SyncTestingInfrastructure(); Svc.Prefs.set("clusterURL", "http://localhost:8080/"); Svc.Prefs.set("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( + 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( + 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!"); + 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); @@ -1028,17 +1028,17 @@ add_test(function test_processIncoming_d 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.wbos.nojson.modified - 1; + engine.lastSync = collection.wbo("nojson").modified - 1; engine.sync(); 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"); @@ -1055,23 +1055,23 @@ add_test(function test_processIncoming_d add_test(function test_uploadOutgoing_toEmptyServer() { _("SyncEngine._uploadOutgoing uploads new records to server"); let syncTesting = new SyncTestingInfrastructure(); Svc.Prefs.set("clusterURL", "http://localhost:8080/"); Svc.Prefs.set("username", "foo"); let collection = new ServerCollection(); - collection.wbos.flying = new ServerWBO('flying'); - collection.wbos.scotsman = new ServerWBO('scotsman'); + 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.wbos.flying.handler(), - "/1.1/foo/storage/rotary/scotsman": collection.wbos.scotsman.handler() + "/1.1/foo/storage/rotary/flying": collection.wbo("flying").handler(), + "/1.1/foo/storage/rotary/scotsman": collection.wbo("scotsman").handler() }); generateNewKeys(); 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 @@ -1080,52 +1080,52 @@ add_test(function test_uploadOutgoing_to let meta_global = Records.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.wbos.flying.payload, undefined); - do_check_eq(collection.wbos.scotsman.payload, undefined); + 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.wbos.flying.payload, undefined); - do_check_true(!!collection.wbos.scotsman.payload); - do_check_eq(JSON.parse(collection.wbos.scotsman.data.ciphertext).id, - 'scotsman'); - do_check_eq(engine._tracker.changedIDs['scotsman'], undefined); + 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.wbos.flying.payload, undefined); + do_check_eq(collection.payload("flying"), undefined); } finally { cleanAndGo(server); } }); add_test(function test_uploadOutgoing_failed() { _("SyncEngine._uploadOutgoing doesn't clear the tracker of objects that failed to upload."); let syncTesting = new SyncTestingInfrastructure(); Svc.Prefs.set("clusterURL", "http://localhost:8080/"); Svc.Prefs.set("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'); + collection._wbos.flying = new ServerWBO('flying'); let server = sync_httpd_setup({ "/1.1/foo/storage/rotary": collection.handler() }); let engine = makeRotaryEngine(); engine.lastSync = 123; // needs to be non-zero so that tracker is queried engine._store.items = {flying: "LNER Class A3 4472", @@ -1142,29 +1142,29 @@ add_test(function test_uploadOutgoing_fa let meta_global = Records.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.wbos.flying.payload, undefined); + 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; engine.sync(); // 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.wbos.flying.payload); + 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 { @@ -1191,41 +1191,41 @@ add_test(function test_uploadOutgoing_MA }(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.wbos[id] = new ServerWBO(id); + collection.insert(id); } let meta_global = Records.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() }); try { - // Confirm initial environment + // Confirm initial environment. do_check_eq(noOfUploads, 0); engine._syncStartup(); engine._uploadOutgoing(); - // Ensure all records have been uploaded + // Ensure all records have been uploaded. for (i = 0; i < 234; i++) { - do_check_true(!!collection.wbos['record-no-'+i].payload); + do_check_true(!!collection.payload('record-no-' + i)); } - // Ensure that the uploads were performed in batches of MAX_UPLOAD_RECORDS + // 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); } }); @@ -1246,40 +1246,40 @@ add_test(function test_syncFinish_noDele add_test(function test_syncFinish_deleteByIds() { _("SyncEngine._syncFinish deletes server records slated for deletion (list of record IDs)."); let syncTesting = new SyncTestingInfrastructure(); Svc.Prefs.set("clusterURL", "http://localhost:8080/"); Svc.Prefs.set("username", "foo"); let collection = new ServerCollection(); - collection.wbos.flying = new ServerWBO( + collection._wbos.flying = new ServerWBO( 'flying', encryptPayload({id: 'flying', denomination: "LNER Class A3 4472"})); - collection.wbos.scotsman = new ServerWBO( + collection._wbos.scotsman = new ServerWBO( 'scotsman', encryptPayload({id: 'scotsman', denomination: "Flying Scotsman"})); - collection.wbos.rekolok = new ServerWBO( + collection._wbos.rekolok = new ServerWBO( 'rekolok', encryptPayload({id: 'rekolok', denomination: "Rekonstruktionslokomotive"})); let server = httpd_setup({ "/1.1/foo/storage/rotary": collection.handler() }); 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.wbos.flying.payload, undefined); - do_check_true(!!collection.wbos.scotsman.payload); - do_check_eq(collection.wbos.rekolok.payload, undefined); + 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); } }); @@ -1304,17 +1304,17 @@ add_test(function test_syncFinish_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.wbos[id] = wbo; + collection.insertWBO(wbo); } let server = httpd_setup({ "/1.1/foo/storage/rotary": collection.handler() }); let engine = makeRotaryEngine(); try { @@ -1333,19 +1333,19 @@ add_test(function test_syncFinish_delete 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.wbos[id].payload, undefined); + do_check_eq(collection.payload(id), undefined); } else { - do_check_true(!!collection.wbos[id].payload); + 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); @@ -1385,18 +1385,19 @@ add_test(function test_sync_partialUploa }(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.wbos[id] = new ServerWBO(id); + if ((i != 23) && (i != 42)) { + collection.insert(id); + } } let meta_global = Records.set(engine.metaURL, new WBORecord(engine.metaURL)); meta_global.payload.engines = {rotary: {version: engine.version, syncID: engine.syncID}}; try { @@ -1434,17 +1435,17 @@ add_test(function test_canDecrypt_noCryp let syncTesting = new SyncTestingInfrastructure(); Svc.Prefs.set("clusterURL", "http://localhost:8080/"); Svc.Prefs.set("username", "foo"); // Wipe CollectionKeys so we can test the desired scenario. CollectionKeys.clear(); let collection = new ServerCollection(); - collection.wbos.flying = new ServerWBO( + 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 engine = makeRotaryEngine(); @@ -1462,17 +1463,17 @@ add_test(function test_canDecrypt_true() let syncTesting = new SyncTestingInfrastructure(); Svc.Prefs.set("clusterURL", "http://localhost:8080/"); Svc.Prefs.set("username", "foo"); // Set up CollectionKeys, as service.js does. generateNewKeys(); let collection = new ServerCollection(); - collection.wbos.flying = new ServerWBO( + 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 engine = makeRotaryEngine(); @@ -1495,17 +1496,17 @@ add_test(function test_syncapplied_obser 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.wbos[id] = new ServerWBO(id, payload); + collection.insert(id, payload); } let meta_global = Records.set(engine.metaURL, new WBORecord(engine.metaURL)); meta_global.payload.engines = {rotary: {version: engine.version, syncID: engine.syncID}}; let server = httpd_setup({ "/1.1/foo/storage/rotary": collection.handler() });
--- a/services/sync/tests/unit/test_syncscheduler.js +++ b/services/sync/tests/unit/test_syncscheduler.js @@ -573,8 +573,124 @@ add_test(function test_sync_failed_parti do_check_eq(SyncScheduler._syncErrors, 0); do_check_true(SyncScheduler.nextSync <= (Date.now() + SyncScheduler.activeInterval)); do_check_true(SyncScheduler.syncTimer.delay <= SyncScheduler.activeInterval); Status.resetSync(); Service.startOver(); server.stop(run_next_test); }); + +add_test(function test_sync_X_Weave_Backoff() { + let server = sync_httpd_setup(); + setUp(); + + // 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. + Clients._store.create({id: "foo", cleartext: "bar"}); + let rec = Clients._store.createRecord("foo", "clients"); + rec.encrypt(); + rec.upload(Clients.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(SyncScheduler.syncInterval, SyncScheduler.activeInterval); + do_check_true(SyncScheduler.nextSync <= + Date.now() + SyncScheduler.syncInterval); + // Sanity check that we picked the right value for BACKOFF: + do_check_true(SyncScheduler.syncInterval < BACKOFF * 1000); + + // Turn on server maintenance and sync again. + serverBackoff = true; + Service.sync(); + + do_check_true(Status.backoffInterval >= BACKOFF * 1000); + // Allowing 1 second worth of of leeway between when Status.minimumNextSync + // was set and when this line gets executed. + let minimumExpectedDelay = (BACKOFF - 1) * 1000; + do_check_true(Status.minimumNextSync >= Date.now() + minimumExpectedDelay); + + // Verify that the next sync is actually going to wait that long. + do_check_true(SyncScheduler.nextSync >= Date.now() + minimumExpectedDelay); + do_check_true(SyncScheduler.syncTimer.delay >= minimumExpectedDelay); + + Service.startOver(); + server.stop(run_next_test); +}); + +add_test(function test_sync_503_Retry_After() { + let server = sync_httpd_setup(); + setUp(); + + // 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. + Clients._store.create({id: "foo", cleartext: "bar"}); + let rec = Clients._store.createRecord("foo", "clients"); + rec.encrypt(); + rec.upload(Clients.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(SyncScheduler.syncInterval, SyncScheduler.activeInterval); + do_check_true(SyncScheduler.nextSync <= + Date.now() + SyncScheduler.syncInterval); + // Sanity check that we picked the right value for BACKOFF: + do_check_true(SyncScheduler.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 1 second worth of of leeway between when Status.minimumNextSync + // was set and when this line gets executed. + let minimumExpectedDelay = (BACKOFF - 1) * 1000; + do_check_true(Status.minimumNextSync >= Date.now() + minimumExpectedDelay); + + // Verify that the next sync is actually going to wait that long. + do_check_true(SyncScheduler.nextSync >= Date.now() + minimumExpectedDelay); + do_check_true(SyncScheduler.syncTimer.delay >= minimumExpectedDelay); + + Service.startOver(); + server.stop(run_next_test); +});
--- a/services/sync/tests/unit/xpcshell.ini +++ b/services/sync/tests/unit/xpcshell.ini @@ -34,16 +34,17 @@ skip-if = os == "android" # Bug 676978: test hangs on Android (see also testing/xpcshell/xpcshell.ini) skip-if = (os == "mac" && debug) || os == "android" [test_forms_store.js] [test_forms_tracker.js] [test_history_engine.js] [test_history_store.js] [test_history_tracker.js] [test_hmac_error.js] +[test_httpd_sync_server.js] [test_interval_triggers.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" [test_keys.js] [test_load_modules.js] [test_log4moz.js] @@ -56,16 +57,17 @@ skip-if = os == "win" || os == "android" [test_records_crypto.js] [test_records_crypto_generateEntry.js] [test_records_wbo.js] [test_resource.js] [test_resource_async.js] [test_resource_ua.js] [test_restrequest.js] [test_score_triggers.js] +[test_sendcredentials_controller.js] [test_service_attributes.js] [test_service_changePassword.js] [test_service_checkAccount.js] [test_service_cluster.js] [test_service_createAccount.js] [test_service_detect_upgrade.js] [test_service_getStorageInfo.js] [test_service_login.js]
--- a/services/sync/tps/extensions/mozmill/install.rdf +++ b/services/sync/tps/extensions/mozmill/install.rdf @@ -8,17 +8,17 @@ <em:creator>Adam Christian</em:creator> <em:description>A testing extension based on the Windmill Testing Framework client source</em:description> <em:unpack>true</em:unpack> <em:targetApplication> <!-- Firefox --> <Description> <em:id>{ec8030f7-c20a-464f-9b0e-13a3a9e97384}</em:id> <em:minVersion>3.5</em:minVersion> - <em:maxVersion>9.*</em:maxVersion> + <em:maxVersion>12.*</em:maxVersion> </Description> </em:targetApplication> <em:targetApplication> <!-- Thunderbird --> <Description> <em:id>{3550f703-e582-4d05-9a08-453d09bdfdc6}</em:id> <em:minVersion>3.0a1pre</em:minVersion> <em:maxVersion>9.*</em:maxVersion>
--- a/services/sync/tps/extensions/tps/install.rdf +++ b/services/sync/tps/extensions/tps/install.rdf @@ -5,17 +5,17 @@ <em:id>tps@mozilla.org</em:id> <em:version>0.2</em:version> <em:targetApplication> <!-- Firefox --> <Description> <em:id>{ec8030f7-c20a-464f-9b0e-13a3a9e97384}</em:id> <em:minVersion>3.5.0</em:minVersion> - <em:maxVersion>10.0.*</em:maxVersion> + <em:maxVersion>12.0.*</em:maxVersion> </Description> </em:targetApplication> <!-- front-end metadata --> <em:name>TPS</em:name> <em:description>Sync test extension</em:description> <em:creator>Jonathan Griffin</em:creator> <em:homepageURL>http://www.mozilla.org/</em:homepageURL>