Bug 1089011: make sure to only import contacts that are part of the default contacts group. r=MattN a=lmandel
authorMike de Boer <mdeboer@mozilla.com>
Wed, 29 Oct 2014 17:40:57 +0100
changeset 225897 8b1b897ca39c
parent 225896 5238acab8176
child 225898 d4ad7d727dd6
push id4061
push userrjesup@wgate.com
push date2014-11-02 13:13 +0000
treeherdermozilla-beta@8b1b897ca39c [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersMattN, lmandel
bugs1089011
milestone34.0
Bug 1089011: make sure to only import contacts that are part of the default contacts group. r=MattN a=lmandel
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
@@ -240,13 +240,14 @@ 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");