Bug 1409208 (part 2) - implement disconnect and sanitize functionality. r?eoger,kitcambridge draft
authorMark Hammond <mhammond@skippinet.com.au>
Fri, 27 Apr 2018 09:18:47 +1000
changeset 789670 a56e6db3ab31c582d08e3ba8897cdd04b2aa6407
parent 789668 78337d68da8a7eb8cfa56238d6e462380503e689
push id108297
push userbmo:markh@mozilla.com
push dateMon, 30 Apr 2018 02:04:36 +0000
reviewerseoger, kitcambridge
bugs1409208
milestone61.0a1
Bug 1409208 (part 2) - implement disconnect and sanitize functionality. r?eoger,kitcambridge MozReview-Commit-ID: 3Fqc6MiaQ4O
browser/components/preferences/in-content/sync.js
browser/components/preferences/in-content/syncDisconnect.js
browser/components/preferences/in-content/tests/browser.ini
browser/components/preferences/in-content/tests/browser_sync_sanitize.js
--- a/browser/components/preferences/in-content/sync.js
+++ b/browser/components/preferences/in-content/sync.js
@@ -444,35 +444,42 @@ var gSyncPane = {
 
     fxAccounts.resendVerificationEmail()
       .then(fxAccounts.getSignedInUser, onError)
       .then(onSuccess, onError);
   },
 
   unlinkFirefoxAccount(confirm) {
     let doUnlink = () => {
-      fxAccounts.signOut().then(() => {
+      this._fxAccounts_signout().catch(ex => {
+        console.error("Failed to unlink", ex);
+      }).then(() => {
         this.updateWeavePrefs();
       });
-    }
+    };
     if (confirm) {
       gSubDialog.open("chrome://browser/content/preferences/in-content/syncDisconnect.xul",
                       "resizable=no", /* aFeatures */
                       null, /* aParams */
                       event => { /* aClosingCallback */
                         if (event.detail.button == "accept") {
                           doUnlink();
                         }
                       });
       return;
     }
     // no confirmation implies no data removal, so just disconnect.
     doUnlink();
   },
 
+  // mockable in tests.
+  _fxAccounts_signout() {
+    return fxAccounts.signOut();
+  },
+
   _populateComputerName(value) {
     let textbox = document.getElementById("fxaSyncComputerName");
     if (!textbox.hasAttribute("placeholder")) {
       textbox.setAttribute("placeholder",
         Weave.Utils.getDefaultDeviceName());
     }
     textbox.value = value;
   },
--- a/browser/components/preferences/in-content/syncDisconnect.js
+++ b/browser/components/preferences/in-content/syncDisconnect.js
@@ -1,30 +1,139 @@
 // This Source Code Form is subject to the terms of the Mozilla Public
 // License, v. 2.0. If a copy of the MPL was not distributed with this
 // file, You can obtain one at http://mozilla.org/MPL/2.0/.
 
-let gSyncDisconnectDialog = {
+ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+  Log: "resource://gre/modules/Log.jsm",
+  Sanitizer: "resource:///modules/Sanitizer.jsm",
+});
+
+
+var gSyncDisconnectDialog = {
+  lockRetryInterval: 2000, // wait 2 seconds before trying for the lock again.
+  lockRetryCount: 60, // Try 60 times before giving up in disgust.
 
   // when either of the checkboxes are changed.
   onDeleteOptionChange() {
     let eitherChecked = document.getElementById("deleteRemoteSyncData").checked ||
                         document.getElementById("deleteRemoteOtherData").checked;
     let newTitle = eitherChecked ? "sync-disconnect-confirm-disconnect-delete" :
                                    "sync-disconnect-confirm-disconnect" ;
     let butDisconnect = document.getElementById("butDisconnect");
     document.l10n.setAttributes(butDisconnect, newTitle);
   },
 
+  // mocked by tests.
+  getWeave() {
+    return ChromeUtils.import("resource://services-sync/main.js", {}).Weave;
+  },
+
+  async doSantizeSync() {
+    let weave = this.getWeave();
+    // Get the sync logger - if stuff goes wrong it can be useful to have that
+    // recorded in the sync logs.
+    let log = Log.repository.getLogger("Sync.Service");
+    log.info("Starting santitize of Sync data");
+    try {
+      // We clobber data for all Sync engines that are enabled.
+      await weave.Service.promiseInitialized;
+      weave.Service.enabled = false;
+
+      // We might be syncing - poll for up to 2 minutes waiting for the lock.
+      let locked = await new Promise(resolve => {
+        let attempts = 0;
+        let checkLock = () => {
+          if (weave.Service.lock()) {
+            resolve(true);
+            return;
+          }
+          attempts += 1;
+          if (attempts >= this.lockRetryCount) {
+            log.error("Gave up waiting for the sync log - going ahead with sanitize anyway");
+            resolve(false);
+          }
+          log.debug("Waiting a couple of seconds to get the sync log");
+          setTimeout(checkLock, this.lockRetryInterval);
+        };
+        checkLock();
+      });
+
+      try {
+        for (let engine of weave.Service.engineManager.getAll()) {
+          if (engine.enabled) {
+            try {
+              log.info("Wiping engine", engine.name);
+              await engine.wipeClient();
+            } catch (ex) {
+              log.error("Failed to wipe engine", ex);
+            }
+          }
+        }
+      } finally {
+        if (locked) {
+          weave.Service.unlock();
+        }
+      }
+      log.info("Finished wiping sync data");
+    } catch (ex) {
+      log.error("Failed to sanitize Sync data", ex);
+      console.error("Failed to sanitize Sync data", ex);
+    }
+    try {
+      // ensure any logs we wrote a flushed to disk.
+      await weave.Service.errorHandler.resetFileLog();
+    } catch (ex) {
+      console.log("Failed to flush the Sync log", ex);
+    }
+  },
+
+  async doSanitizeBrowser() {
+    try {
+      // sanitize everything other than "open windows" (and we don't do that
+      // because (a) it may confuse the user - they probably want to see
+      // about:prefs with the disconnection reflected and (b) because the
+      // actual disconnection is done by the opening window, it doesn't
+      // happen if it is closed as part of the sanitize process.
+      let itemsToClear = Object.keys(Sanitizer.items);
+      let openWindowsIndex = itemsToClear.indexOf("openWindows");
+      if (openWindowsIndex >= 0) {
+        itemsToClear.splice(openWindowsIndex, 1);
+      }
+      await Sanitizer.sanitize(itemsToClear);
+    } catch (ex) {
+      console.error("Failed to sanitize other data", ex);
+    }
+  },
+
+  async doSanitize() {
+    if (document.getElementById("deleteRemoteSyncData").checked) {
+      await this.doSantizeSync();
+    }
+
+    if (document.getElementById("deleteRemoteOtherData").checked) {
+      await this.doSanitizeBrowser();
+    }
+  },
+
   accept(event) {
-    // * Check the check-boxes
-    // * Disconnect.
-    // We dispatch a custom event so the caller knows how we were closed
-    // (if we were a dialog we'd get this for free, but we'd also lose the
-    // ability to keep the dialog open while the sanitize was running)
-    let closingEvent = new CustomEvent("dialogclosing", {
-      bubbles: true,
-      detail: { button: "accept" },
+    // Disable the buttons while we are working.
+    for (let but of document.querySelectorAll(".syncDisconnectButton")) {
+      but.disabled = true;
+    }
+    // And do the santize.
+    this.doSanitize().catch(ex => {
+      console.error("Failed to sanitize", ex);
+    }).finally(() => {
+      // We dispatch a custom event so the caller knows how we were closed
+      // (if we were a dialog we'd get this for free, but we'd also lose the
+      // ability to keep the dialog open while the sanitize was running)
+      let closingEvent = new CustomEvent("dialogclosing", {
+        bubbles: true,
+        detail: { button: "accept" },
+      });
+      document.documentElement.dispatchEvent(closingEvent);
+      close(event);
     });
-    document.documentElement.dispatchEvent(closingEvent);
-    close(event);
   }
 };
--- a/browser/components/preferences/in-content/tests/browser.ini
+++ b/browser/components/preferences/in-content/tests/browser.ini
@@ -80,14 +80,15 @@ run-if = nightly_build
 [browser_security-2.js]
 [browser_spotlight.js]
 [browser_site_login_exceptions.js]
 [browser_permissions_dialog.js]
 [browser_subdialogs.js]
 support-files =
   subdialog.xul
   subdialog2.xul
+[browser_sync_sanitize.js]
 [browser_telemetry.js]
 # Skip this test on Android as FHR and Telemetry are separate systems there.
 skip-if = !healthreport || !telemetry || (os == 'linux' && debug) || (os == 'android')
 [browser_containers_name_input.js]
 run-if = nightly_build # Containers is enabled only on Nightly
 [browser_fluent.js]
new file mode 100644
--- /dev/null
+++ b/browser/components/preferences/in-content/tests/browser_sync_sanitize.js
@@ -0,0 +1,191 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+/* global sinon */
+
+"use strict";
+
+const {UIState} = ChromeUtils.import("resource://services-sync/UIState.jsm", {});
+const {Log} = ChromeUtils.import("resource://gre/modules/Log.jsm", {});
+
+// Use sinon for mocking.
+Services.scriptloader.loadSubScript("resource://testing-common/sinon-2.3.2.js");
+registerCleanupFunction(() => {
+  delete window.sinon; // test fails with this reference left behind.
+});
+
+
+add_task(async function setup() {
+  // Sync start-up will interfere with our tests, don't let UIState send UI updates.
+  const origNotifyStateUpdated = UIState._internal.notifyStateUpdated;
+  UIState._internal.notifyStateUpdated = () => {};
+
+  const origGet = UIState.get;
+  UIState.get = () => { return { status: UIState.STATUS_SIGNED_IN, email: "foo@bar.com" }; };
+
+  // browser_sync_sanitize uses the sync log, so arrange for that to end up
+  // in the test output.
+  const log = Log.repository.getLogger("Sync");
+  const appender = new Log.DumpAppender();
+  log.addAppender(appender);
+  log.level = appender.level = Log.Level.Trace;
+
+  registerCleanupFunction(() => {
+    UIState._internal.notifyStateUpdated = origNotifyStateUpdated;
+    UIState.get = origGet;
+  });
+});
+
+add_task(async function testDisconnectLabel() {
+  await runTestWithSanitizeDialog(async (win) => {
+    let doc = win.document;
+    let butDisconnect = doc.getElementById("butDisconnect");
+    let butDeleteSync = doc.getElementById("deleteRemoteSyncData");
+    let butDeleteOther = doc.getElementById("deleteRemoteOtherData");
+
+    // mock both sanitize functions and the fxa signout.
+    let spyBrowser = win.gSyncDisconnectDialog.doSanitizeBrowser = sinon.spy();
+    let spySync = win.gSyncDisconnectDialog.doSanitizeSync = sinon.spy();
+    let spySignout = win.parent.gSyncPane._fxAccounts_signout = sinon.stub();
+    spySignout.resolves();
+
+    Assert.equal(butDisconnect.getAttribute("data-l10n-id"), "sync-disconnect-confirm-disconnect");
+
+    // Both checkboxes default to unchecked.
+    Assert.ok(!butDeleteSync.checked);
+    Assert.ok(!butDeleteOther.checked);
+
+    // Hitting either of the checkboxes should change the text on the disconnect button/
+    butDeleteSync.click();
+    Assert.ok(butDeleteSync.checked);
+    Assert.equal(butDisconnect.getAttribute("data-l10n-id"), "sync-disconnect-confirm-disconnect-delete");
+
+    butDeleteOther.click();
+    Assert.ok(butDeleteOther.checked);
+    Assert.equal(butDisconnect.getAttribute("data-l10n-id"), "sync-disconnect-confirm-disconnect-delete");
+
+    butDeleteSync.click();
+    Assert.ok(!butDeleteSync.checked);
+    Assert.equal(butDisconnect.getAttribute("data-l10n-id"), "sync-disconnect-confirm-disconnect-delete");
+
+    butDeleteOther.click();
+    Assert.ok(!butDeleteOther.checked);
+    // button text should be back to "just disconnect"
+    Assert.equal(butDisconnect.getAttribute("data-l10n-id"), "sync-disconnect-confirm-disconnect");
+
+    // Cancel the dialog - ensure it closes without sanitizing anything and
+    // without disconnecting FxA.
+    let promiseUnloaded = BrowserTestUtils.waitForEvent(win, "unload");
+    doc.getElementById("butCancel").click();
+
+    info("waiting for dialog to unload");
+    await promiseUnloaded;
+
+    Assert.equal(spyBrowser.callCount, 0, "should not have sanitized the browser");
+    Assert.equal(spySync.callCount, 0, "should not have sanitized Sync");
+    Assert.equal(spySignout.callCount, 0, "should not have signed out of FxA");
+  });
+});
+
+add_task(async function testDisconnectSync() {
+  await runTestWithSanitizeDialog(async (win) => {
+    let doc = win.document;
+    let butDisconnect = doc.getElementById("butDisconnect");
+    let butDeleteSync = doc.getElementById("deleteRemoteSyncData");
+
+    win.gSyncDisconnectDialog.lockRetryInterval = 100;
+
+    let spySignout = win.parent.gSyncPane._fxAccounts_signout = sinon.stub();
+    spySignout.resolves();
+
+    // mock the "browser" sanitize function - it should not be called by
+    // this test.
+    let spyBrowser = win.gSyncDisconnectDialog.doSanitizeBrowser = sinon.spy();
+    // mock Sync
+    let mockEngine1 = {
+      enabled: true,
+      name: "Test Engine 1",
+      wipeClient: sinon.spy(),
+    };
+    let mockEngine2 = {
+      enabled: false,
+      name: "Test Engine 2",
+      wipeClient: sinon.spy(),
+    };
+
+    let lockStub = sinon.stub();
+    lockStub.onCall(0).returns(false); // first call fails to get the lock.
+    lockStub.onCall(1).returns(true); // second call gets the lock.
+    let Weave = {
+      Service: {
+        enabled: true,
+        lock: lockStub,
+        unlock: sinon.spy(),
+
+        engineManager: {
+          getAll: sinon.stub().returns([mockEngine1, mockEngine2]),
+        },
+        errorHandler: {
+          resetFileLog: sinon.spy(),
+        }
+      }
+    };
+    win.gSyncDisconnectDialog.getWeave = () => Weave;
+
+    let promiseUnloaded = BrowserTestUtils.waitForEvent(win, "unload");
+    butDeleteSync.click();
+    butDisconnect.click();
+    info("waiting for dialog to unload");
+    await promiseUnloaded;
+
+    Assert.equal(Weave.Service.lock.callCount, 2, "should have tried the lock twice");
+    Assert.equal(Weave.Service.unlock.callCount, 1, "should have unlocked at the end");
+    Assert.ok(!Weave.Service.enabled, "Weave should be and remain disabled");
+    Assert.equal(Weave.Service.errorHandler.resetFileLog.callCount, 1, "should have reset the log");
+    Assert.equal(mockEngine1.wipeClient.callCount, 1, "enabled engine should have been wiped");
+    Assert.equal(mockEngine2.wipeClient.callCount, 0, "disabled engine should not have been wiped");
+    Assert.equal(spyBrowser.callCount, 0, "should not sanitize the browser");
+    Assert.equal(spySignout.callCount, 1, "should have signed out of FxA");
+  });
+});
+
+add_task(async function testDisconnectBrowser() {
+  await runTestWithSanitizeDialog(async (win) => {
+    let doc = win.document;
+    let butDisconnect = doc.getElementById("butDisconnect");
+    let butDeleteOther = doc.getElementById("deleteRemoteOtherData");
+
+    let spySignout = win.parent.gSyncPane._fxAccounts_signout = sinon.stub();
+    spySignout.resolves();
+
+    // mock both sanitize functions.
+    let spyBrowser = win.gSyncDisconnectDialog.doSanitizeBrowser = sinon.spy();
+    let spySync = win.gSyncDisconnectDialog.doSanitizeSync = sinon.spy();
+
+    let promiseUnloaded = BrowserTestUtils.waitForEvent(win, "unload");
+    butDeleteOther.click();
+    butDisconnect.click();
+    info("waiting for dialog to unload");
+    await promiseUnloaded;
+
+    Assert.equal(spyBrowser.callCount, 1, "should have sanitized the browser");
+    Assert.equal(spySync.callCount, 0, "should not have sanitized Sync");
+    Assert.equal(spySignout.callCount, 1, "should have signed out of FxA");
+  });
+});
+
+async function runTestWithSanitizeDialog(test) {
+  await openPreferencesViaOpenPreferencesAPI("paneSync", {leaveOpen: true});
+
+  let doc = gBrowser.contentDocument;
+
+  let promiseSubDialogLoaded =
+      promiseLoadSubDialog("chrome://browser/content/preferences/in-content/syncDisconnect.xul");
+  doc.getElementById("fxaUnlinkButton").doCommand();
+
+  let win = await promiseSubDialogLoaded;
+
+  await test(win);
+
+  BrowserTestUtils.removeTab(gBrowser.selectedTab);
+}
+