Bug 1089011: make sure to only import contacts that are part of the default contacts group. r=MattN
authorMike de Boer <mdeboer@mozilla.com>
Wed, 29 Oct 2014 17:40:57 +0100
changeset 213106 b4729ee1bb8170c408aa1c94241f2d66d6cb84a9
parent 213105 0746593b7cde2f7e5dc9c82193946f0fab7939fa
child 213107 cf2ccaa2ec7931f3d5498a7d496f4bcaf77a26e3
push id51143
push usercbook@mozilla.com
push dateThu, 30 Oct 2014 14:14:04 +0000
treeherdermozilla-inbound@e80345c5bf6f [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersMattN
bugs1089011
milestone36.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1089011: make sure to only import contacts that are part of the default contacts group. r=MattN
browser/components/loop/GoogleImporter.jsm
browser/components/loop/test/mochitest/browser.ini
browser/components/loop/test/mochitest/browser_GoogleImporter.js
browser/components/loop/test/mochitest/fixtures/google_contacts.txt
browser/components/loop/test/mochitest/fixtures/google_groups.txt
browser/components/loop/test/mochitest/google_service.sjs
testing/profiles/prefs_general.js
--- a/browser/components/loop/GoogleImporter.jsm
+++ b/browser/components/loop/GoogleImporter.jsm
@@ -43,17 +43,17 @@ log.addAppender(new Log.ConsoleAppender(
 const extractFieldsFromNode = function(fieldMap, node, ns = null, target = {}, wrapInArray = false) {
   for (let [field, nodeName] of fieldMap) {
     let nodeList = ns ? node.getElementsByTagNameNS(ns, nodeName) :
                         node.getElementsByTagName(nodeName);
     if (nodeList.length) {
       if (!nodeList[0].firstChild) {
         continue;
       }
-      let value = nodeList[0].firstChild.nodeValue;
+      let value = nodeList[0].textContent;
       target[field] = wrapInArray ? [value] : value;
     }
   }
   return target;
 };
 
 /**
  * Helper function that reads the type of (email-)address or phone number from an
@@ -163,18 +163,18 @@ this.GoogleImporter.prototype = {
    *                                 invoked from. It will be used to be able to
    *                                 open a window for the OAuth process with chrome
    *                                 privileges.
    */
   startImport: function(options, callback, db, windowRef) {
     Task.spawn(function* () {
       let code = yield this._promiseAuthCode(windowRef);
       let tokenSet = yield this._promiseTokenSet(code);
-      let contactEntries = yield this._promiseContactEntries(tokenSet);
-      let {total, success, ids} = yield this._processContacts(contactEntries, db);
+      let contactEntries = yield this._getContactEntries(tokenSet);
+      let {total, success, ids} = yield this._processContacts(contactEntries, db, tokenSet);
       yield this._purgeContacts(ids, db);
 
       return {
         total: total,
         success: success
       };
     }.bind(this)).then(stats => callback(null, stats),
                        error => callback(error))
@@ -281,97 +281,138 @@ this.GoogleImporter.prototype = {
                                                  "loop.oauth.google.clientSecretOverride") +
                  "&redirect_uri=" + encodeURIComponent(Services.prefs.getCharPref(
                                                        "loop.oauth.google.redirect_uri"));
 
       request.send(body);
     });
   },
 
-  /**
-   * Fetches all the contacts in a users' address book.
-   *
-   * @see https://developers.google.com/google-apps/contacts/v3/#retrieving_all_contacts
-   *
-   * @param {Object} tokenSet OAuth tokenset used to authenticate the request
-   * @returns An `Error` object upon failure or an Array of contact XML nodes.
-   */
-  _promiseContactEntries: function(tokenSet) {
-    return new Promise(function(resolve, reject) {
+  _promiseRequestXML: function(URL, tokenSet) {
+    return new Promise((resolve, reject) => {
       let request = Cc["@mozilla.org/xmlextras/xmlhttprequest;1"]
                       .createInstance(Ci.nsIXMLHttpRequest);
 
-      request.open("GET", getUrlParam("https://www.google.com/m8/feeds/contacts/default/full",
-                                      "loop.oauth.google.getContactsURL",
-                                      false) + "?max-results=" + kContactsMaxResults);
+      request.open("GET", URL);
 
       request.setRequestHeader("Content-Type", "application/xml; charset=utf-8");
       request.setRequestHeader("GData-Version", "3.0");
       request.setRequestHeader("Authorization", "Bearer " + tokenSet.access_token);
 
       request.onload = function() {
         if (request.status < 400) {
           let doc = request.responseXML;
-          // First get the profile id.
+          // First get the profile id, which is present in each XML request.
           let currNode = doc.documentElement.firstChild;
           while (currNode) {
             if (currNode.nodeType == 1 && currNode.localName == "id") {
-              gProfileId = currNode.firstChild.nodeValue;
+              gProfileId = currNode.textContent;
               break;
             }
             currNode = currNode.nextSibling;
           }
 
-          // Then kick of the importing of contact entries.
-          let entries = Array.prototype.slice.call(doc.querySelectorAll("entry"));
-          resolve(entries);
+          resolve(doc);
         } else {
           reject(new Error(request.status + " " + request.statusText));
         }
       };
 
       request.onerror = function(error) {
         reject(error);
       }
 
       request.send();
     });
   },
 
   /**
+   * Fetches all the contacts in a users' address book.
+   *
+   * @see https://developers.google.com/google-apps/contacts/v3/#retrieving_all_contacts
+   *
+   * @param {Object} tokenSet OAuth tokenset used to authenticate the request
+   * @returns An `Error` object upon failure or an Array of contact XML nodes.
+   */
+  _getContactEntries: Task.async(function* (tokenSet) {
+    let URL = getUrlParam("https://www.google.com/m8/feeds/contacts/default/full",
+                          "loop.oauth.google.getContactsURL",
+                          false) + "?max-results=" + kContactsMaxResults;
+    let xmlDoc = yield this._promiseRequestXML(URL, tokenSet);
+    // Then kick of the importing of contact entries.
+    return Array.prototype.slice.call(xmlDoc.querySelectorAll("entry"));
+  }),
+
+  /**
+   * Fetches the default group from a users' address book, called 'Contacts'.
+   *
+   * @see https://developers.google.com/google-apps/contacts/v3/#retrieving_all_contact_groups
+   *
+   * @param {Object} tokenSet OAuth tokenset used to authenticate the request
+   * @returns An `Error` object upon failure or the String group ID.
+   */
+  _getContactsGroupId: Task.async(function* (tokenSet) {
+    let URL = getUrlParam("https://www.google.com/m8/feeds/groups/default/full",
+                          "loop.oauth.google.getGroupsURL",
+                          false) + "?max-results=" + kContactsMaxResults;
+    let xmlDoc = yield this._promiseRequestXML(URL, tokenSet);
+    let contactsEntry = xmlDoc.querySelector("systemGroup[id=\"Contacts\"]");
+    if (!contactsEntry) {
+      throw new Error("Contacts group not present");
+    }
+    // Select the actual <entry> node, which is the parent of the <systemGroup>
+    // node we just selected.
+    contactsEntry = contactsEntry.parentNode;
+    return contactsEntry.getElementsByTagName("id")[0].textContent;
+  }),
+
+  /**
    * Process the contact XML nodes that Google provides, convert them to the MozContact
    * format, check if the contact already exists in the database and when it doesn't,
    * store it permanently.
    * During this process statistics are collected about the amount of successful
    * imports. The consumer of this class may use these statistics to inform the
    * user.
+   * Note: only contacts that are part of the 'Contacts' system group will be
+   *       imported.
    *
    * @param {Array}        contactEntries List of XML DOMNodes contact entries.
    * @param {LoopContacts} db             Instance of the LoopContacts database
    *                                      object, which will store the newly found
    *                                      contacts.
+   * @param {Object}       tokenSet       OAuth tokenset used to authenticate a
+   *                                      request
    * @returns An `Error` object upon failure or an Object with statistics in the
    *          following format: `{ total: 25, success: 13, ids: {} }`.
    */
-  _processContacts: Task.async(function* (contactEntries, db) {
+  _processContacts: Task.async(function* (contactEntries, db, tokenSet) {
     let stats = {
       total: contactEntries.length,
       success: 0,
       ids: {}
     };
 
+    // Contacts that are _not_ part of the 'Contacts' group will be ignored.
+    let contactsGroupId = yield this._getContactsGroupId(tokenSet);
+
     for (let entry of contactEntries) {
       let contact = this._processContactFields(entry);
 
       stats.ids[contact.id] = 1;
       let existing = yield db.promise("getByServiceId", contact.id);
       if (existing) {
         yield db.promise("remove", existing._guid);
       }
 
+      // After contact removal, check if the entry is part of the correct group.
+      if (!entry.querySelector("groupMembershipInfo[deleted=\"false\"][href=\"" +
+                               contactsGroupId + "\"]")) {
+        continue;
+      }
+
       // If the contact contains neither email nor phone number, then it is not
       // useful in the Loop address book: do not add.
       if (!("email" in contact) && !("tel" in contact)) {
         continue;
       }
 
       yield db.promise("add", contact);
       stats.success++;
@@ -445,34 +486,34 @@ this.GoogleImporter.prototype = {
 
     // Process telephone numbers.
     let phoneNodes = entry.getElementsByTagNameNS(kNS_GD, "phoneNumber");
     if (phoneNodes.length) {
       contact.tel = [];
       for (let [,phoneNode] of Iterator(phoneNodes)) {
         let phoneNumber = phoneNode.hasAttribute("uri") ?
           phoneNode.getAttribute("uri").replace("tel:", "") :
-          phoneNode.firstChild.nodeValue;
+          phoneNode.textContent;
         contact.tel.push({
           pref: (phoneNode.getAttribute("primary") == "true"),
           type: [getFieldType(phoneNode)],
           value: phoneNumber
         });
       }
     }
 
     let orgNodes = entry.getElementsByTagNameNS(kNS_GD, "organization");
     if (orgNodes.length) {
       contact.org = [];
       contact.jobTitle = [];
       for (let [,orgNode] of Iterator(orgNodes)) {
         let orgElement = orgNode.getElementsByTagNameNS(kNS_GD, "orgName")[0];
         let titleElement = orgNode.getElementsByTagNameNS(kNS_GD, "orgTitle")[0];
-        contact.org.push(orgElement ? orgElement.firstChild.nodeValue : "")
-        contact.jobTitle.push(titleElement ? titleElement.firstChild.nodeValue : "");
+        contact.org.push(orgElement ? orgElement.textContent : "")
+        contact.jobTitle.push(titleElement ? titleElement.textContent : "");
       }
     }
 
     contact.category = ["google"];
 
     // Basic sanity checking: make sure the name field isn't empty
     if (!("name" in contact) || contact.name[0].length == 0) {
       if (("familyName" in contact) && ("givenName" in contact)) {
--- a/browser/components/loop/test/mochitest/browser.ini
+++ b/browser/components/loop/test/mochitest/browser.ini
@@ -1,12 +1,13 @@
 [DEFAULT]
 support-files =
     fixtures/google_auth.txt
     fixtures/google_contacts.txt
+    fixtures/google_groups.txt
     fixtures/google_token.txt
     google_service.sjs
     head.js
     loop_fxa.sjs
     ../../../../base/content/test/general/browser_fxa_oauth.html
 
 [browser_CardDavImporter.js]
 [browser_fxa_login.js]
--- a/browser/components/loop/test/mochitest/browser_GoogleImporter.js
+++ b/browser/components/loop/test/mochitest/browser_GoogleImporter.js
@@ -12,33 +12,32 @@ function promiseImport() {
         reject(err);
       } else {
         resolve(stats);
       }
     }, mockDb, window);
   });
 }
 
-const kContactsCount = 7;
+const kIncomingTotalContactsCount = 8;
+const kExpectedImportCount = 7;
 
 add_task(function* test_GoogleImport() {
   let stats;
   // An error may throw and the test will fail when that happens.
   stats = yield promiseImport();
 
-  let contactsCount = mockDb.size;
-
   // Assert the world.
-  Assert.equal(stats.total, contactsCount, "Five contacts should get processed");
-  Assert.equal(stats.success, contactsCount, "Five contacts should be imported");
+  Assert.equal(stats.total, kIncomingTotalContactsCount, kIncomingTotalContactsCount + " contacts should get processed");
+  Assert.equal(stats.success, kExpectedImportCount, kExpectedImportCount + " contacts should be imported");
 
   yield promiseImport();
-  Assert.equal(Object.keys(mockDb._store).length, contactsCount, "Database should be the same size after reimport");
+  Assert.equal(mockDb.size, kExpectedImportCount, "Database should be the same size after reimport");
 
-  let currentContact = contactsCount;
+  let currentContact = kExpectedImportCount;
 
   let c = mockDb._store[mockDb._next_guid - currentContact];
   Assert.equal(c.name[0], "John Smith", "Full name should match");
   Assert.equal(c.givenName[0], "John", "Given name should match");
   Assert.equal(c.familyName[0], "Smith", "Family name should match");
   Assert.equal(c.email[0].type, "other", "Email type should match");
   Assert.equal(c.email[0].value, "john.smith@example.com", "Email should match");
   Assert.equal(c.email[0].pref, true, "Pref should match");
@@ -91,9 +90,12 @@ add_task(function* test_GoogleImport() {
 
   c = mockDb._store[mockDb._next_guid - (--currentContact)];
   Assert.equal(c.name[0], "215234523452345", "Full name should match");
   Assert.equal(c.tel[0].type, "mobile", "Phone type should match");
   Assert.equal(c.tel[0].value, "215234523452345", "Phone should match");
   Assert.equal(c.tel[0].pref, false, "Pref should match");
   Assert.equal(c.category[0], "google", "Category should match");
   Assert.equal(c.id, "http://www.google.com/m8/feeds/contacts/tester%40mochi.com/base/6", "UID should match and be scoped to provider");
+
+  c = yield mockDb.promise("getByServiceId", "http://www.google.com/m8/feeds/contacts/tester%40mochi.com/base/9");
+  Assert.equal(c, null, "Contacts that are not part of the default group should not be imported");
 });
--- a/browser/components/loop/test/mochitest/fixtures/google_contacts.txt
+++ b/browser/components/loop/test/mochitest/fixtures/google_contacts.txt
@@ -29,16 +29,17 @@
     <link href="https://www.google.com/m8/feeds/contacts/tester%40mochi.com/full/0" rel="edit" type="application/atom+xml"/>
     <gd:name>
       <gd:fullName>John Smith</gd:fullName>
       <gd:givenName>John</gd:givenName>
       <gd:familyName>Smith</gd:familyName>
     </gd:name>
     <gd:email address="john.smith@example.com" primary="true" rel="http://schemas.google.com/g/2005#other"/>
     <gContact:website href="http://www.google.com/profiles/109576547678240773721" rel="profile"/>
+    <gContact:groupMembershipInfo deleted="false" href="http://www.google.com/m8/feeds/groups/tester%40mochi.com/base/6"/>
   </entry>
   <entry gd:etag="&quot;R3YyejRVLit7I2A9WhJWEkkNQwc.&quot;">
     <id>http://www.google.com/m8/feeds/contacts/tester%40mochi.com/base/1</id>
     <updated>2012-08-17T23:50:36.892Z</updated>
     <app:edited xmlns:app="http://www.w3.org/2007/app">2012-08-17T23:50:36.892Z</app:edited>
     <category scheme="http://schemas.google.com/g/2005#kind" term="http://schemas.google.com/contact/2008#contact"/>
     <title>Jane Smith</title>
     <link gd:etag="&quot;WA9BY1xFWit7I2BhLEkieCxLHEYTGCYuNxo.&quot;" href="https://www.google.com/m8/feeds/photos/media/tester%40mochi.com/1" rel="http://schemas.google.com/contacts/2008/rel#photo" type="image/*"/>
@@ -46,16 +47,17 @@
     <link href="https://www.google.com/m8/feeds/contacts/tester%40mochi.com/full/1" rel="edit" type="application/atom+xml"/>
     <gd:name>
       <gd:fullName>Jane Smith</gd:fullName>
       <gd:givenName>Jane</gd:givenName>
       <gd:familyName>Smith</gd:familyName>
     </gd:name>
     <gd:email address="jane.smith@example.com" primary="true" rel="http://schemas.google.com/g/2005#other"/>
     <gContact:website href="http://www.google.com/profiles/112886528199784431028" rel="profile"/>
+    <gContact:groupMembershipInfo deleted="false" href="http://www.google.com/m8/feeds/groups/tester%40mochi.com/base/6"/>
   </entry>
   <entry gd:etag="&quot;R3YyejRVLit7I2A9WhJWEkkNQwc.&quot;">
     <id>http://www.google.com/m8/feeds/contacts/tester%40mochi.com/base/2</id>
     <updated>2012-08-17T23:50:36.892Z</updated>
     <app:edited xmlns:app="http://www.w3.org/2007/app">2012-08-17T23:50:36.892Z</app:edited>
     <category scheme="http://schemas.google.com/g/2005#kind" term="http://schemas.google.com/contact/2008#contact"/>
     <title>Davy Randall Jones</title>
     <link gd:etag="&quot;KiV2PkYRfCt7I2BuD1AzEBFxD1VcGjwBUyA.&quot;" href="https://www.google.com/m8/feeds/photos/media/tester%40mochi.com/2" rel="http://schemas.google.com/contacts/2008/rel#photo" type="image/*"/>
@@ -63,55 +65,76 @@
     <link href="https://www.google.com/m8/feeds/contacts/tester%40mochi.com/full/2" rel="edit" type="application/atom+xml"/>
     <gd:name>
       <gd:fullName>Davy Randall Jones</gd:fullName>
       <gd:givenName>Davy Randall</gd:givenName>
       <gd:familyName>Jones</gd:familyName>
     </gd:name>
     <gd:email address="davy.jones@example.com" primary="true" rel="http://schemas.google.com/g/2005#other"/>
     <gContact:website href="http://www.google.com/profiles/109710625881478599011" rel="profile"/>
+    <gContact:groupMembershipInfo deleted="false" href="http://www.google.com/m8/feeds/groups/tester%40mochi.com/base/6"/>
   </entry>
   <entry gd:etag="&quot;Q3w7ezVSLit7I2A9WB5WGUkNRgE.&quot;">
     <id>http://www.google.com/m8/feeds/contacts/tester%40mochi.com/base/3</id>
     <updated>2007-08-01T05:45:52.203Z</updated>
     <app:edited xmlns:app="http://www.w3.org/2007/app">2007-08-01T05:45:52.203Z</app:edited>
     <category scheme="http://schemas.google.com/g/2005#kind" term="http://schemas.google.com/contact/2008#contact"/>
     <title/>
     <link href="https://www.google.com/m8/feeds/photos/media/tester%40mochi.com/3" rel="http://schemas.google.com/contacts/2008/rel#photo" type="image/*"/>
     <link href="https://www.google.com/m8/feeds/contacts/tester%40mochi.com/full/3" rel="self" type="application/atom+xml"/>
     <link href="https://www.google.com/m8/feeds/contacts/tester%40mochi.com/full/3" rel="edit" type="application/atom+xml"/>
     <gd:email address="noname@example.com" primary="true" rel="http://schemas.google.com/g/2005#other"/>
+    <gContact:groupMembershipInfo deleted="false" href="http://www.google.com/m8/feeds/groups/tester%40mochi.com/base/6"/>
   </entry>
   <entry gd:etag="&quot;Q3w7ezVSLit7I2A9WB5WGUkNRgE.&quot;">
     <id>http://www.google.com/m8/feeds/contacts/tester%40mochi.com/base/7</id>
     <updated>2007-08-01T05:45:52.203Z</updated>
     <app:edited xmlns:app="http://www.w3.org/2007/app">2007-08-01T05:45:52.203Z</app:edited>
     <category scheme="http://schemas.google.com/g/2005#kind" term="http://schemas.google.com/contact/2008#contact"/>
     <title/>
     <link href="https://www.google.com/m8/feeds/photos/media/tester%40mochi.com/7" rel="http://schemas.google.com/contacts/2008/rel#photo" type="image/*"/>
     <link href="https://www.google.com/m8/feeds/contacts/tester%40mochi.com/full/7" rel="self" type="application/atom+xml"/>
     <link href="https://www.google.com/m8/feeds/contacts/tester%40mochi.com/full/7" rel="edit" type="application/atom+xml"/>
     <gd:email address="lycnix" primary="true" rel="http://schemas.google.com/g/2005#other"/>
+    <gContact:groupMembershipInfo deleted="false" href="http://www.google.com/m8/feeds/groups/tester%40mochi.com/base/6"/>
   </entry>
   <entry gd:etag="&quot;RXkzfjVSLit7I2A9XRdRGUgITgA.&quot;">
     <id>http://www.google.com/m8/feeds/contacts/tester%40mochi.com/base/8</id>
     <updated>2014-10-10T14:55:44.786Z</updated>
     <app:edited xmlns:app="http://www.w3.org/2007/app">2014-10-10T14:55:44.786Z</app:edited>
     <category scheme="http://schemas.google.com/g/2005#kind" term="http://schemas.google.com/contact/2008#contact"/>
     <title/>
     <link href="https://www.google.com/m8/feeds/photos/media/tester%40mochi.com/8" rel="http://schemas.google.com/contacts/2008/rel#photo" type="image/*"/>
     <link href="https://www.google.com/m8/feeds/contacts/tester%40mochi.com/full/8" rel="self" type="application/atom+xml"/>
     <link href="https://www.google.com/m8/feeds/contacts/tester%40mochi.com/full/8" rel="edit" type="application/atom+xml"/>
     <gd:phoneNumber rel="http://schemas.google.com/g/2005#mobile" uri="tel:+31-6-12345678">0612345678</gd:phoneNumber>
+    <gContact:groupMembershipInfo deleted="false" href="http://www.google.com/m8/feeds/groups/tester%40mochi.com/base/6"/>
   </entry>
   <entry gd:etag="&quot;SX8-ejVSLit7I2A9XRdQFUkDRgY.&quot;">
     <id>http://www.google.com/m8/feeds/contacts/tester%40mochi.com/base/6</id>
     <updated>2014-10-17T12:32:08.152Z</updated>
     <app:edited xmlns:app="http://www.w3.org/2007/app">2014-10-17T12:32:08.152Z</app:edited>
     <category scheme="http://schemas.google.com/g/2005#kind" term="http://schemas.google.com/contact/2008#contact"/>
     <title/>
     <link href="https://www.google.com/m8/feeds/photos/media/tester%40mochi.com/6" rel="http://schemas.google.com/contacts/2008/rel#photo" type="image/*"/>
     <link href="https://www.google.com/m8/feeds/contacts/tester%40mochi.com/full/6" rel="self" type="application/atom+xml"/>
     <link href="https://www.google.com/m8/feeds/contacts/tester%40mochi.com/full/6" rel="edit" type="application/atom+xml"/>
     <gd:phoneNumber rel="http://schemas.google.com/g/2005#mobile">215234523452345</gd:phoneNumber>
     <gContact:groupMembershipInfo deleted="false" href="http://www.google.com/m8/feeds/groups/tester%40mochi.com/base/6"/>
   </entry>
+  <entry gd:etag="&quot;Rn8zejVSLit7I2A9WhVRFUQOQQc.&quot;">
+    <id>http://www.google.com/m8/feeds/contacts/tester%40mochi.com/base/9</id>
+    <updated>2012-03-24T13:10:37.182Z</updated>
+    <app:edited xmlns:app="http://www.w3.org/2007/app">2012-03-24T13:10:37.182Z</app:edited>
+    <category scheme="http://schemas.google.com/g/2005#kind" term="http://schemas.google.com/contact/2008#contact"/>
+    <title>Little Smith</title>
+    <link href="https://www.google.com/m8/feeds/photos/media/tester%40mochi.com/9" rel="http://schemas.google.com/contacts/2008/rel#photo" type="image/*"/>
+    <link href="https://www.google.com/m8/feeds/contacts/tester%40mochi.com/full/9" rel="self" type="application/atom+xml"/>
+    <link href="https://www.google.com/m8/feeds/contacts/tester%40mochi.com/full/9" rel="edit" type="application/atom+xml"/>
+    <gd:name>
+      <gd:fullName>Little Smith</gd:fullName>
+      <gd:givenName>Little</gd:givenName>
+      <gd:familyName>Smith</gd:familyName>
+    </gd:name>
+    <gd:email address="littlebabysmith@example.com" primary="true" rel="http://schemas.google.com/g/2005#other"/>
+    <gContact:website href="http://www.google.com/profiles/111456826635924971693" rel="profile"/>
+  </entry>
 </feed>
new file mode 100644
--- /dev/null
+++ b/browser/components/loop/test/mochitest/fixtures/google_groups.txt
@@ -0,0 +1,56 @@
+<?xml version='1.0' encoding='UTF-8'?>
+<feed gd:etag="W/&quot;CEIAQngzfyt7I2A9XRdXFEQ.&quot;" xmlns="http://www.w3.org/2005/Atom" xmlns:batch="http://schemas.google.com/gdata/batch" xmlns:gContact="http://schemas.google.com/contact/2008" xmlns:gd="http://schemas.google.com/g/2005" xmlns:openSearch="http://a9.com/-/spec/opensearch/1.1/">
+  <id>tester@mochi.com</id>
+  <updated>2014-10-28T10:35:43.687Z</updated>
+  <category scheme="http://schemas.google.com/g/2005#kind" term="http://schemas.google.com/contact/2008#group"/>
+  <title>Mochi Tester's Contact Groups</title>
+  <link href="http://www.google.com/" rel="alternate" type="text/html"/>
+  <link href="https://www.google.com/m8/feeds/groups/tester%40mochi.com/full" rel="http://schemas.google.com/g/2005#feed" type="application/atom+xml"/>
+  <link href="https://www.google.com/m8/feeds/groups/tester%40mochi.com/full" rel="http://schemas.google.com/g/2005#post" type="application/atom+xml"/>
+  <link href="https://www.google.com/m8/feeds/groups/tester%40mochi.com/full/batch" rel="http://schemas.google.com/g/2005#batch" type="application/atom+xml"/>
+  <link href="https://www.google.com/m8/feeds/groups/tester%40mochi.com/full?max-results=10000000" rel="self" type="application/atom+xml"/>
+  <author>
+    <name>Mochi Tester</name>
+    <email>tester@mochi.com</email>
+  </author>
+  <generator uri="http://www.google.com/m8/feeds" version="1.0">Contacts</generator>
+  <openSearch:totalResults>4</openSearch:totalResults>
+  <openSearch:startIndex>1</openSearch:startIndex>
+  <openSearch:itemsPerPage>10000000</openSearch:itemsPerPage>
+  <entry gd:etag="&quot;YDwreyM.&quot;">
+    <id>http://www.google.com/m8/feeds/groups/tester%40mochi.com/base/6</id>
+    <updated>1970-01-01T00:00:00.000Z</updated>
+    <category scheme="http://schemas.google.com/g/2005#kind" term="http://schemas.google.com/contact/2008#group"/>
+    <title>System Group: My Contacts</title>
+    <content>System Group: My Contacts</content>
+    <link href="https://www.google.com/m8/feeds/groups/tester%40mochi.com/full/6" rel="self" type="application/atom+xml"/>
+    <gContact:systemGroup id="Contacts"/>
+  </entry>
+  <entry gd:etag="&quot;YDwreyM.&quot;">
+    <id>http://www.google.com/m8/feeds/groups/tester%40mochi.com/base/d</id>
+    <updated>1970-01-01T00:00:00.000Z</updated>
+    <category scheme="http://schemas.google.com/g/2005#kind" term="http://schemas.google.com/contact/2008#group"/>
+    <title>System Group: Friends</title>
+    <content>System Group: Friends</content>
+    <link href="https://www.google.com/m8/feeds/groups/tester%40mochi.com/full/d" rel="self" type="application/atom+xml"/>
+    <gContact:systemGroup id="Friends"/>
+  </entry>
+  <entry gd:etag="&quot;YDwreyM.&quot;">
+    <id>http://www.google.com/m8/feeds/groups/tester%40mochi.com/base/e</id>
+    <updated>1970-01-01T00:00:00.000Z</updated>
+    <category scheme="http://schemas.google.com/g/2005#kind" term="http://schemas.google.com/contact/2008#group"/>
+    <title>System Group: Family</title>
+    <content>System Group: Family</content>
+    <link href="https://www.google.com/m8/feeds/groups/tester%40mochi.com/full/e" rel="self" type="application/atom+xml"/>
+    <gContact:systemGroup id="Family"/>
+  </entry>
+  <entry gd:etag="&quot;YDwreyM.&quot;">
+    <id>http://www.google.com/m8/feeds/groups/tester%40mochi.com/base/f</id>
+    <updated>1970-01-01T00:00:00.000Z</updated>
+    <category scheme="http://schemas.google.com/g/2005#kind" term="http://schemas.google.com/contact/2008#group"/>
+    <title>System Group: Coworkers</title>
+    <content>System Group: Coworkers</content>
+    <link href="https://www.google.com/m8/feeds/groups/tester%40mochi.com/full/f" rel="self" type="application/atom+xml"/>
+    <gContact:systemGroup id="Coworkers"/>
+  </entry>
+</feed>
--- a/browser/components/loop/test/mochitest/google_service.sjs
+++ b/browser/components/loop/test/mochitest/google_service.sjs
@@ -138,10 +138,20 @@ const methodHandlers = {
     try {
       checkAuth(req);
     } catch (ex) {
       sendError(res, ex, ex.code);
       return;
     }
 
     respondWithFile(res, "google_contacts.txt", "text/xml");
+  },
+
+  groups: function(req, res, params) {
+    try {
+      checkAuth(req);
+    } catch (ex) {
+      sendError(res, ex, ex.code);
+    }
+
+    respondWithFile(res, "google_groups.txt", "text/xml");
   }
 };
--- a/testing/profiles/prefs_general.js
+++ b/testing/profiles/prefs_general.js
@@ -247,16 +247,17 @@ user_pref("dom.mozApps.debug", true);
 user_pref("browser.newtabpage.directory.source", 'data:application/json,{"testing":1}');
 user_pref("browser.newtabpage.directory.ping", "");
 
 // Enable Loop
 user_pref("loop.enabled", true);
 user_pref("loop.throttled", false);
 user_pref("loop.oauth.google.URL", "http://%(server)s/browser/browser/components/loop/test/mochitest/google_service.sjs?action=");
 user_pref("loop.oauth.google.getContactsURL", "http://%(server)s/browser/browser/components/loop/test/mochitest/google_service.sjs?action=contacts");
+user_pref("loop.oauth.google.getGroupsURL", "http://%(server)s/browser/browser/components/loop/test/mochitest/google_service.sjs?action=groups");
 user_pref("loop.CSP","default-src 'self' about: file: chrome: data: wss://* http://* https://*");
 
 // Ensure UITour won't hit the network
 user_pref("browser.uitour.pinnedTabUrl", "http://%(server)s/uitour-dummy/pinnedTab");
 user_pref("browser.uitour.url", "http://%(server)s/uitour-dummy/tour");
 
 user_pref("media.eme.enabled", true);