Bug 1395122 - [Form Autofill] Part 4: Add unit tests about encryption/decryption in credit card sync modules. r=markh,kitcambridge,seanlee draft
authorLuke Chang <lchang@mozilla.com>
Tue, 05 Sep 2017 15:16:53 +0800
changeset 660548 39ae9bcd3cd74af65e942f38e3c328ad452713fb
parent 660547 51e2df8fd667b9d2e8fab44fff3e0e0b51d0412e
child 730260 2cfa74b6f4c7e7ee64125cc80868113b27da692d
push id78427
push userbmo:lchang@mozilla.com
push dateThu, 07 Sep 2017 03:59:04 +0000
reviewersmarkh, kitcambridge, seanlee
bugs1395122
milestone57.0a1
Bug 1395122 - [Form Autofill] Part 4: Add unit tests about encryption/decryption in credit card sync modules. r=markh,kitcambridge,seanlee MozReview-Commit-ID: 5cAoDyM0vvN
browser/extensions/formautofill/test/unit/head.js
browser/extensions/formautofill/test/unit/test_addressRecords.js
browser/extensions/formautofill/test/unit/test_reconcile.js
browser/extensions/formautofill/test/unit/test_storage_syncfields.js
browser/extensions/formautofill/test/unit/test_sync.js
--- a/browser/extensions/formautofill/test/unit/head.js
+++ b/browser/extensions/formautofill/test/unit/head.js
@@ -97,30 +97,34 @@ function getTempFile(leafName) {
     if (file.exists()) {
       file.remove(false);
     }
   });
 
   return file;
 }
 
-async function initProfileStorage(fileName, records) {
+async function initProfileStorage(fileName, collectionName, records) {
   let {ProfileStorage} = Cu.import("resource://formautofill/ProfileStorage.jsm", {});
   let path = getTempFile(fileName).path;
   let profileStorage = new ProfileStorage(path);
   await profileStorage.initialize();
 
   if (!records || !Array.isArray(records)) {
     return profileStorage;
   }
 
   let onChanged = TestUtils.topicObserved("formautofill-storage-changed",
                                           (subject, data) => data == "add");
   for (let record of records) {
-    do_check_true(profileStorage.addresses.add(record));
+    let clonedRecord = Object.assign({}, record);
+    if (profileStorage[collectionName].encryptCCNumberFields) {
+      await profileStorage[collectionName].encryptCCNumberFields(clonedRecord);
+    }
+    do_check_true(profileStorage[collectionName].add(clonedRecord));
     await onChanged;
   }
   await profileStorage._saveImmediately();
   return profileStorage;
 }
 
 function runHeuristicsTest(patterns, fixturePathPrefix) {
   Cu.import("resource://formautofill/FormAutofillHeuristics.jsm");
--- a/browser/extensions/formautofill/test/unit/test_addressRecords.js
+++ b/browser/extensions/formautofill/test/unit/test_addressRecords.js
@@ -1,15 +1,16 @@
 /**
  * Tests ProfileStorage object with addresses records.
  */
 
 "use strict";
 
 const TEST_STORE_FILE_NAME = "test-profile.json";
+const TEST_COLLECTION_NAME = "addresses";
 
 const TEST_ADDRESS_1 = {
   "given-name": "Timothy",
   "additional-name": "John",
   "family-name": "Berners-Lee",
   organization: "World Wide Web Consortium",
   "street-address": "32 Vassar Street\nMIT Room 32-G524",
   "address-level2": "Cambridge",
@@ -208,37 +209,37 @@ const MERGE_TESTCASES = [
 
 let do_check_record_matches = (recordWithMeta, record) => {
   for (let key in record) {
     do_check_eq(recordWithMeta[key], record[key]);
   }
 };
 
 add_task(async function test_initialize() {
-  let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME);
+  let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, TEST_COLLECTION_NAME);
 
   do_check_eq(profileStorage._store.data.version, 1);
   do_check_eq(profileStorage._store.data.addresses.length, 0);
 
   let data = profileStorage._store.data;
   Assert.deepEqual(data.addresses, []);
 
   await profileStorage._saveImmediately();
 
-  profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME);
+  profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, TEST_COLLECTION_NAME);
 
   Assert.deepEqual(profileStorage._store.data, data);
   for (let {_sync} of profileStorage._store.data.addresses) {
     Assert.ok(_sync);
     Assert.equal(_sync.changeCounter, 1);
   }
 });
 
 add_task(async function test_getAll() {
-  let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME,
+  let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, TEST_COLLECTION_NAME,
                                                 [TEST_ADDRESS_1, TEST_ADDRESS_2]);
 
   let addresses = profileStorage.addresses.getAll();
 
   do_check_eq(addresses.length, 2);
   do_check_record_matches(addresses[0], TEST_ADDRESS_1);
   do_check_record_matches(addresses[1], TEST_ADDRESS_2);
 
@@ -254,17 +255,17 @@ add_task(async function test_getAll() {
   do_check_eq(addresses[0]["address-line2"], undefined);
 
   // Modifying output shouldn't affect the storage.
   addresses[0].organization = "test";
   do_check_record_matches(profileStorage.addresses.getAll()[0], TEST_ADDRESS_1);
 });
 
 add_task(async function test_get() {
-  let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME,
+  let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, TEST_COLLECTION_NAME,
                                                 [TEST_ADDRESS_1, TEST_ADDRESS_2]);
 
   let addresses = profileStorage.addresses.getAll();
   let guid = addresses[0].guid;
 
   let address = profileStorage.addresses.get(guid);
   do_check_record_matches(address, TEST_ADDRESS_1);
 
@@ -277,17 +278,17 @@ add_task(async function test_get() {
   // Modifying output shouldn't affect the storage.
   address.organization = "test";
   do_check_record_matches(profileStorage.addresses.get(guid), TEST_ADDRESS_1);
 
   do_check_eq(profileStorage.addresses.get("INVALID_GUID"), null);
 });
 
 add_task(async function test_add() {
-  let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME,
+  let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, TEST_COLLECTION_NAME,
                                                 [TEST_ADDRESS_1, TEST_ADDRESS_2]);
 
   let addresses = profileStorage.addresses.getAll();
 
   do_check_eq(addresses.length, 2);
 
   do_check_record_matches(addresses[0], TEST_ADDRESS_1);
   do_check_record_matches(addresses[1], TEST_ADDRESS_2);
@@ -299,17 +300,17 @@ add_task(async function test_add() {
   do_check_eq(addresses[0].timeLastUsed, 0);
   do_check_eq(addresses[0].timesUsed, 0);
 
   Assert.throws(() => profileStorage.addresses.add(TEST_ADDRESS_WITH_INVALID_FIELD),
     /"invalidField" is not a valid field\./);
 });
 
 add_task(async function test_update() {
-  let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME,
+  let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, TEST_COLLECTION_NAME,
                                                 [TEST_ADDRESS_1, TEST_ADDRESS_2]);
 
   let addresses = profileStorage.addresses.getAll();
   let guid = addresses[1].guid;
   let timeLastModified = addresses[1].timeLastModified;
 
   let onChanged = TestUtils.topicObserved("formautofill-storage-changed",
                                           (subject, data) => data == "update");
@@ -352,17 +353,17 @@ add_task(async function test_update() {
 
   Assert.throws(
     () => profileStorage.addresses.update(guid, TEST_ADDRESS_WITH_INVALID_FIELD),
     /"invalidField" is not a valid field\./
   );
 });
 
 add_task(async function test_notifyUsed() {
-  let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME,
+  let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, TEST_COLLECTION_NAME,
                                                 [TEST_ADDRESS_1, TEST_ADDRESS_2]);
 
   let addresses = profileStorage.addresses.getAll();
   let guid = addresses[1].guid;
   let timeLastUsed = addresses[1].timeLastUsed;
   let timesUsed = addresses[1].timesUsed;
 
   profileStorage.addresses.pullSyncChanges(); // force sync metadata, which we check below.
@@ -383,17 +384,17 @@ add_task(async function test_notifyUsed(
   do_check_eq(getSyncChangeCounter(profileStorage.addresses, guid),
     changeCounter);
 
   Assert.throws(() => profileStorage.addresses.notifyUsed("INVALID_GUID"),
     /No matching record\./);
 });
 
 add_task(async function test_remove() {
-  let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME,
+  let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, TEST_COLLECTION_NAME,
                                                 [TEST_ADDRESS_1, TEST_ADDRESS_2]);
 
   let addresses = profileStorage.addresses.getAll();
   let guid = addresses[1].guid;
 
   let onChanged = TestUtils.topicObserved("formautofill-storage-changed",
                                           (subject, data) => data == "remove");
 
@@ -407,17 +408,17 @@ add_task(async function test_remove() {
   do_check_eq(addresses.length, 1);
 
   do_check_eq(profileStorage.addresses.get(guid), null);
 });
 
 MERGE_TESTCASES.forEach((testcase) => {
   add_task(async function test_merge() {
     do_print("Starting testcase: " + testcase.description);
-    let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME,
+    let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, TEST_COLLECTION_NAME,
                                                   [testcase.addressInStorage]);
     let addresses = profileStorage.addresses.getAll();
     // Merge address and verify the guid in notifyObservers subject
     let onMerged = TestUtils.topicObserved(
       "formautofill-storage-changed",
       (subject, data) =>
         data == "merge" && subject.QueryInterface(Ci.nsISupportsString).data == addresses[0].guid
     );
@@ -429,38 +430,39 @@ MERGE_TESTCASES.forEach((testcase) => {
     addresses = profileStorage.addresses.getAll();
     Assert.equal(addresses.length, 1);
     Assert.notEqual(addresses[0].timeLastModified, timeLastModified);
     do_check_record_matches(addresses[0], testcase.expectedAddress);
   });
 });
 
 add_task(async function test_merge_same_address() {
-  let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, [TEST_ADDRESS_1]);
+  let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, TEST_COLLECTION_NAME,
+                                                [TEST_ADDRESS_1]);
   let addresses = profileStorage.addresses.getAll();
   let timeLastModified = addresses[0].timeLastModified;
   // Merge same address will still return true but it won't update timeLastModified.
   Assert.equal(profileStorage.addresses.mergeIfPossible(addresses[0].guid, TEST_ADDRESS_1), true);
   Assert.equal(addresses[0].timeLastModified, timeLastModified);
 });
 
 add_task(async function test_merge_unable_merge() {
-  let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME,
+  let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, TEST_COLLECTION_NAME,
                                                 [TEST_ADDRESS_1, TEST_ADDRESS_2]);
 
   let addresses = profileStorage.addresses.getAll();
   // Unable to merge because of conflict
   do_check_eq(profileStorage.addresses.mergeIfPossible(addresses[1].guid, TEST_ADDRESS_3), false);
 
   // Unable to merge because no overlap
   do_check_eq(profileStorage.addresses.mergeIfPossible(addresses[1].guid, TEST_ADDRESS_4), false);
 });
 
 add_task(async function test_mergeToStorage() {
-  let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME,
+  let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, TEST_COLLECTION_NAME,
                                                 [TEST_ADDRESS_1, TEST_ADDRESS_2]);
   // Merge an address to storage
   let anotherAddress = profileStorage.addresses._clone(TEST_ADDRESS_2);
   profileStorage.addresses.add(anotherAddress);
   anotherAddress.email = "timbl@w3.org";
   do_check_eq(profileStorage.addresses.mergeToStorage(anotherAddress).length, 2);
   do_check_eq(profileStorage.addresses.getAll()[1].email, anotherAddress.email);
   do_check_eq(profileStorage.addresses.getAll()[2].email, anotherAddress.email);
--- a/browser/extensions/formautofill/test/unit/test_reconcile.js
+++ b/browser/extensions/formautofill/test/unit/test_reconcile.js
@@ -6,17 +6,17 @@ const TEST_STORE_FILE_NAME = "test-profi
 // parent: What the local record looked like the last time we wrote the
 //         record to the Sync server.
 // local:  What the local record looks like now. IOW, the differences between
 //         'parent' and 'local' are changes recently made which we wish to sync.
 // remote: An incoming record we need to apply (ie, a record that was possibly
 //         changed on a remote device)
 //
 // To further help understanding this, a few of the testcases are annotated.
-const RECONCILE_TESTCASES = [
+const ADDRESS_RECONCILE_TESTCASES = [
   {
     description: "Local change",
     parent: {
       // So when we last wrote the record to the server, it had these values.
       "guid": "2bbd2d8fbc6b",
       "version": 1,
       "given-name": "Mark",
       "family-name": "Hammond",
@@ -459,30 +459,482 @@ const RECONCILE_TESTCASES = [
       "family-name": "Hammond",
       "timeCreated": 1234,
       "timeLastUsed": 5678,
       "timesUsed": 6,
     },
   },
 ];
 
+const CREDIT_CARD_RECONCILE_TESTCASES = [
+  {
+    description: "Local change",
+    parent: {
+      // So when we last wrote the record to the server, it had these values.
+      "guid": "2bbd2d8fbc6b",
+      "version": 1,
+      "cc-name": "Mark Hammond",
+      "cc-number": "1111222233334444",
+    },
+    local: [{
+      // The current local record - by comparing against parent we can see that
+      // only the given-name has changed locally.
+      "cc-name": "Mark Hammond",
+      "cc-number": "4444333322221111",
+    }],
+    remote: {
+      // This is the incoming record. It has the same values as "parent", so
+      // we can deduce the record hasn't actually been changed remotely so we
+      // can safely ignore the incoming record and write our local changes.
+      "guid": "2bbd2d8fbc6b",
+      "version": 1,
+      "cc-name": "Mark Hammond",
+      "cc-number": "1111222233334444",
+    },
+    reconciled: {
+      "guid": "2bbd2d8fbc6b",
+      "cc-name": "Mark Hammond",
+      "cc-number": "4444333322221111",
+    },
+  },
+  {
+    description: "Remote change",
+    parent: {
+      "guid": "e3680e9f890d",
+      "version": 1,
+      "cc-name": "Mark Hammond",
+      "cc-number": "1111222233334444",
+    },
+    local: [{
+      "cc-name": "Mark Hammond",
+      "cc-number": "1111222233334444",
+    }],
+    remote: {
+      "guid": "e3680e9f890d",
+      "version": 1,
+      "cc-name": "Mark Hammond",
+      "cc-number": "4444333322221111",
+    },
+    reconciled: {
+      "guid": "e3680e9f890d",
+      "cc-name": "Mark Hammond",
+      "cc-number": "4444333322221111",
+    },
+  },
+
+  {
+    description: "New local field",
+    parent: {
+      "guid": "0cba738b1be0",
+      "version": 1,
+      "cc-name": "Mark Hammond",
+      "cc-number": "1111222233334444",
+    },
+    local: [{
+      "cc-name": "Mark Hammond",
+      "cc-number": "1111222233334444",
+      "cc-exp-month": 12,
+    }],
+    remote: {
+      "guid": "0cba738b1be0",
+      "version": 1,
+      "cc-name": "Mark Hammond",
+      "cc-number": "1111222233334444",
+    },
+    reconciled: {
+      "guid": "0cba738b1be0",
+      "cc-name": "Mark Hammond",
+      "cc-number": "1111222233334444",
+      "cc-exp-month": 12,
+    },
+  },
+  {
+    description: "New remote field",
+    parent: {
+      "guid": "be3ef97f8285",
+      "version": 1,
+      "cc-name": "Mark Hammond",
+      "cc-number": "1111222233334444",
+    },
+    local: [{
+      "cc-name": "Mark Hammond",
+      "cc-number": "1111222233334444",
+    }],
+    remote: {
+      "guid": "be3ef97f8285",
+      "version": 1,
+      "cc-name": "Mark Hammond",
+      "cc-number": "1111222233334444",
+      "cc-exp-month": 12,
+    },
+    reconciled: {
+      "guid": "be3ef97f8285",
+      "cc-name": "Mark Hammond",
+      "cc-number": "1111222233334444",
+      "cc-exp-month": 12,
+    },
+  },
+  {
+    description: "Deleted field locally",
+    parent: {
+      "guid": "9627322248ec",
+      "version": 1,
+      "cc-name": "Mark Hammond",
+      "cc-number": "1111222233334444",
+      "cc-exp-month": 12,
+    },
+    local: [{
+      "cc-name": "Mark Hammond",
+      "cc-number": "1111222233334444",
+    }],
+    remote: {
+      "guid": "9627322248ec",
+      "version": 1,
+      "cc-name": "Mark Hammond",
+      "cc-number": "1111222233334444",
+      "cc-exp-month": 12,
+    },
+    reconciled: {
+      "guid": "9627322248ec",
+      "cc-name": "Mark Hammond",
+      "cc-number": "1111222233334444",
+    },
+  },
+  {
+    description: "Deleted field remotely",
+    parent: {
+      "guid": "7d7509f3eeb2",
+      "version": 1,
+      "cc-name": "Mark Hammond",
+      "cc-number": "1111222233334444",
+      "cc-exp-month": 12,
+    },
+    local: [{
+      "cc-name": "Mark Hammond",
+      "cc-number": "1111222233334444",
+      "cc-exp-month": 12,
+    }],
+    remote: {
+      "guid": "7d7509f3eeb2",
+      "version": 1,
+      "cc-name": "Mark Hammond",
+      "cc-number": "1111222233334444",
+    },
+    reconciled: {
+      "guid": "7d7509f3eeb2",
+      "cc-name": "Mark Hammond",
+      "cc-number": "1111222233334444",
+    },
+  },
+  {
+    description: "Local and remote changes to unrelated fields",
+    parent: {
+      // The last time we wrote this to the server, "cc-exp-month" was 12.
+      "guid": "e087a06dfc57",
+      "version": 1,
+      "cc-name": "Mark Hammond",
+      "cc-number": "1111222233334444",
+      "cc-exp-month": 12,
+    },
+    local: [{
+      // The current local record - so locally we've changed "cc-number".
+      "cc-name": "Mark Hammond",
+      "cc-number": "4444333322221111",
+      "cc-exp-month": 12,
+    }],
+    remote: {
+      // Remotely, we've changed "cc-exp-month" to 1.
+      "guid": "e087a06dfc57",
+      "version": 1,
+      "cc-name": "Mark Hammond",
+      "cc-number": "1111222233334444",
+      "cc-exp-month": 1,
+    },
+    reconciled: {
+      "guid": "e087a06dfc57",
+      "cc-name": "Mark Hammond",
+      "cc-number": "4444333322221111",
+      "cc-exp-month": 1,
+    },
+  },
+  {
+    description: "Multiple local changes",
+    parent: {
+      "guid": "340a078c596f",
+      "version": 1,
+      "cc-name": "Mark Hammond",
+      "cc-number": "1111222233334444",
+    },
+    local: [{
+      "cc-name": "Skip",
+      "cc-number": "1111222233334444",
+    }, {
+      "cc-name": "Skip",
+      "cc-number": "1111222233334444",
+      "cc-exp-month": 12,
+    }],
+    remote: {
+      "guid": "340a078c596f",
+      "version": 1,
+      "cc-name": "Mark Hammond",
+      "cc-number": "1111222233334444",
+      "cc-exp-year": 2000,
+    },
+    reconciled: {
+      "guid": "340a078c596f",
+      "cc-name": "Skip",
+      "cc-number": "1111222233334444",
+      "cc-exp-month": 12,
+      "cc-exp-year": 2000,
+    },
+  },
+  {
+    // Local and remote diverged from the shared parent, but the values are the
+    // same, so we shouldn't fork.
+    description: "Same change to local and remote",
+    parent: {
+      "guid": "0b3a72a1bea2",
+      "version": 1,
+      "cc-name": "Mark Hammond",
+      "cc-number": "1111222233334444",
+    },
+    local: [{
+      "cc-name": "Mark Hammond",
+      "cc-number": "4444333322221111",
+    }],
+    remote: {
+      "guid": "0b3a72a1bea2",
+      "version": 1,
+      "cc-name": "Mark Hammond",
+      "cc-number": "4444333322221111",
+    },
+    reconciled: {
+      "guid": "0b3a72a1bea2",
+      "cc-name": "Mark Hammond",
+      "cc-number": "4444333322221111",
+    },
+  },
+  {
+    description: "Conflicting changes to single field",
+    parent: {
+      // This is what we last wrote to the sync server.
+      "guid": "62068784d089",
+      "version": 1,
+      "cc-name": "Mark Hammond",
+      "cc-number": "1111222233334444",
+    },
+    local: [{
+      // The current version of the local record - the cc-number has changed locally.
+      "cc-name": "Mark Hammond",
+      "cc-number": "1111111111111111",
+    }],
+    remote: {
+      // An incoming record has a different cc-number than any of the above!
+      "guid": "62068784d089",
+      "version": 1,
+      "cc-name": "Mark Hammond",
+      "cc-number": "4444333322221111",
+    },
+    forked: {
+      // So we've forked the local record to a new GUID (and the next sync is
+      // going to write this as a new record)
+      "cc-name": "Mark Hammond",
+      "cc-number": "1111111111111111",
+    },
+    reconciled: {
+      // And we've updated the local version of the record to be the remote version.
+      guid: "62068784d089",
+      "cc-name": "Mark Hammond",
+      "cc-number": "4444333322221111",
+    },
+  },
+  {
+    description: "Conflicting changes to multiple fields",
+    parent: {
+      "guid": "244dbb692e94",
+      "version": 1,
+      "cc-name": "Mark Hammond",
+      "cc-number": "1111222233334444",
+      "cc-exp-month": 12,
+    },
+    local: [{
+      "cc-name": "Mark Hammond",
+      "cc-number": "1111111111111111",
+      "cc-exp-month": 1,
+    }],
+    remote: {
+      "guid": "244dbb692e94",
+      "version": 1,
+      "cc-name": "Mark Hammond",
+      "cc-number": "4444333322221111",
+      "cc-exp-month": 3,
+    },
+    forked: {
+      "cc-name": "Mark Hammond",
+      "cc-number": "1111111111111111",
+      "cc-exp-month": 1,
+    },
+    reconciled: {
+      "guid": "244dbb692e94",
+      "cc-name": "Mark Hammond",
+      "cc-number": "4444333322221111",
+      "cc-exp-month": 3,
+    },
+  },
+  {
+    description: "Field deleted locally, changed remotely",
+    parent: {
+      "guid": "6fc45e03d19a",
+      "version": 1,
+      "cc-name": "Mark Hammond",
+      "cc-number": "1111222233334444",
+      "cc-exp-month": 12,
+    },
+    local: [{
+      "cc-name": "Mark Hammond",
+      "cc-number": "1111222233334444",
+    }],
+    remote: {
+      "guid": "6fc45e03d19a",
+      "version": 1,
+      "cc-name": "Mark Hammond",
+      "cc-number": "1111222233334444",
+      "cc-exp-month": 3,
+    },
+    forked: {
+      "cc-name": "Mark Hammond",
+      "cc-number": "1111222233334444",
+    },
+    reconciled: {
+      "guid": "6fc45e03d19a",
+      "cc-name": "Mark Hammond",
+      "cc-number": "1111222233334444",
+      "cc-exp-month": 3,
+    },
+  },
+  {
+    description: "Field changed locally, deleted remotely",
+    parent: {
+      "guid": "fff9fa27fa18",
+      "version": 1,
+      "cc-name": "Mark Hammond",
+      "cc-number": "1111222233334444",
+      "cc-exp-month": 12,
+    },
+    local: [{
+      "cc-name": "Mark Hammond",
+      "cc-number": "1111222233334444",
+      "cc-exp-month": 3,
+    }],
+    remote: {
+      "guid": "fff9fa27fa18",
+      "version": 1,
+      "cc-name": "Mark Hammond",
+      "cc-number": "1111222233334444",
+    },
+    forked: {
+      "cc-name": "Mark Hammond",
+      "cc-number": "1111222233334444",
+      "cc-exp-month": 3,
+    },
+    reconciled: {
+      "guid": "fff9fa27fa18",
+      "cc-name": "Mark Hammond",
+      "cc-number": "1111222233334444",
+    },
+  },
+  {
+    // Created, last modified should be synced; last used and times used should
+    // be local. Remote created time older than local, remote modified time
+    // newer than local.
+    description: "Created, last modified time reconciliation without local changes",
+    parent: {
+      "guid": "5113f329c42f",
+      "version": 1,
+      "cc-name": "Mark Hammond",
+      "cc-number": "1111222233334444",
+      "timeCreated": 1234,
+      "timeLastModified": 5678,
+      "timeLastUsed": 5678,
+      "timesUsed": 6,
+    },
+    local: [],
+    remote: {
+      "guid": "5113f329c42f",
+      "version": 1,
+      "cc-name": "Mark Hammond",
+      "cc-number": "1111222233334444",
+      "timeCreated": 1200,
+      "timeLastModified": 5700,
+      "timeLastUsed": 5700,
+      "timesUsed": 3,
+    },
+    reconciled: {
+      "guid": "5113f329c42f",
+      "cc-name": "Mark Hammond",
+      "cc-number": "1111222233334444",
+      "timeCreated": 1200,
+      "timeLastModified": 5700,
+      "timeLastUsed": 5678,
+      "timesUsed": 6,
+    },
+  },
+  {
+    // Local changes, remote created time newer than local, remote modified time
+    // older than local.
+    description: "Created, last modified time reconciliation with local changes",
+    parent: {
+      "guid": "791e5608b80a",
+      "version": 1,
+      "cc-name": "Mark Hammond",
+      "cc-number": "1111222233334444",
+      "timeCreated": 1234,
+      "timeLastModified": 5678,
+      "timeLastUsed": 5678,
+      "timesUsed": 6,
+    },
+    local: [{
+      "cc-name": "Mark Hammond",
+      "cc-number": "4444333322221111",
+    }],
+    remote: {
+      "guid": "791e5608b80a",
+      "version": 1,
+      "cc-name": "Mark Hammond",
+      "cc-number": "1111222233334444",
+      "timeCreated": 1300,
+      "timeLastModified": 5000,
+      "timeLastUsed": 5000,
+      "timesUsed": 3,
+    },
+    reconciled: {
+      "guid": "791e5608b80a",
+      "cc-name": "Mark Hammond",
+      "cc-number": "4444333322221111",
+      "timeCreated": 1234,
+      "timeLastUsed": 5678,
+      "timesUsed": 6,
+    },
+  },
+];
+
 add_task(async function test_reconcile_unknown_version() {
-  let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME);
+  let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, "addresses");
 
   // Cross-version reconciliation isn't supported yet. See bug 1377204.
   await Assert.rejects(profileStorage.addresses.reconcile({
     "guid": "31d83d2725ec",
     "version": 2,
     "given-name": "Mark",
     "family-name": "Hammond",
   }), /Got unknown record version/);
 });
 
 add_task(async function test_reconcile_idempotent() {
-  let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME);
+  let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, "addresses");
 
   let guid = "de1ba7b094fe";
   profileStorage.addresses.add({
     guid,
     version: 1,
     "given-name": "Mark",
     "family-name": "Hammond",
   }, {sourceSync: true});
@@ -528,20 +980,20 @@ add_task(async function test_reconcile_i
       "given-name": "Skip",
       "family-name": "Hammond",
       "organization": "Mozilla",
       "tel": "123456",
     }), "Second merge should not change record");
   }
 });
 
-add_task(async function test_reconcile_three_way_merge() {
-  let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME);
+add_task(async function test_addresses_reconcile_three_way_merge() {
+  let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, "addresses");
 
-  for (let test of RECONCILE_TESTCASES) {
+  for (let test of ADDRESS_RECONCILE_TESTCASES) {
     do_print(test.description);
 
     profileStorage.addresses.add(test.parent, {sourceSync: true});
 
     for (let updatedRecord of test.local) {
       profileStorage.addresses.update(test.parent.guid, updatedRecord);
     }
 
@@ -564,8 +1016,51 @@ add_task(async function test_reconcile_t
         `${test.description} should fork record`);
     } else {
       ok(!test.forked, `${test.description} should not fork record`);
     }
 
     ok(objectMatches(reconciledRecord, test.reconciled));
   }
 });
+
+add_task(async function test_creditCards_reconcile_three_way_merge() {
+  let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, "creditCards");
+
+  for (let test of CREDIT_CARD_RECONCILE_TESTCASES) {
+    do_print(test.description);
+
+    let clonedParent = Object.assign({}, test.parent);
+    await profileStorage.creditCards.encryptCCNumberFields(clonedParent);
+    profileStorage.creditCards.add(clonedParent, {sourceSync: true});
+
+    for (let updatedRecord of test.local) {
+      let clonedUpdatedRecord = Object.assign({}, updatedRecord);
+      await profileStorage.creditCards.encryptCCNumberFields(clonedUpdatedRecord);
+      profileStorage.creditCards.update(test.parent.guid, clonedUpdatedRecord);
+    }
+
+    let localRecord = profileStorage.creditCards.get(test.parent.guid, {
+      rawData: true,
+    });
+
+    let {forkedGUID} = await profileStorage.creditCards.reconcile(test.remote);
+    let reconciledRecord = profileStorage.creditCards.get(test.parent.guid, {
+      rawData: true,
+    });
+    await profileStorage.creditCards.decryptCCNumberFields(reconciledRecord);
+
+    if (forkedGUID) {
+      let forkedRecord = profileStorage.creditCards.get(forkedGUID, {
+        rawData: true,
+      });
+
+      notEqual(forkedRecord.guid, reconciledRecord.guid);
+      equal(forkedRecord.timeLastModified, localRecord.timeLastModified);
+      ok(objectMatches(forkedRecord, test.forked),
+        `${test.description} should fork record`);
+    } else {
+      ok(!test.forked, `${test.description} should not fork record`);
+    }
+
+    ok(objectMatches(reconciledRecord, test.reconciled));
+  }
+});
--- a/browser/extensions/formautofill/test/unit/test_storage_syncfields.js
+++ b/browser/extensions/formautofill/test/unit/test_storage_syncfields.js
@@ -1,65 +1,73 @@
 /**
  * Tests ProfileStorage objects support for sync related fields.
  */
 
 "use strict";
 
 // The duplication of some of these fixtures between tests is unfortunate.
 const TEST_STORE_FILE_NAME = "test-profile.json";
+const TEST_COLLECTION_NAME = "addresses";
 
 const TEST_ADDRESS_1 = {
   "given-name": "Timothy",
   "additional-name": "John",
   "family-name": "Berners-Lee",
   organization: "World Wide Web Consortium",
   "street-address": "32 Vassar Street\nMIT Room 32-G524",
   "address-level2": "Cambridge",
   "address-level1": "MA",
   "postal-code": "02139",
   country: "US",
-  tel: "+1 617 253 5702",
+  tel: "+16172535702",
   email: "timbl@w3.org",
 };
 
 const TEST_ADDRESS_2 = {
   "street-address": "Some Address",
   country: "US",
 };
 
 const TEST_ADDRESS_3 = {
   "street-address": "Other Address",
   "postal-code": "12345",
 };
 
+const TEST_CREDIT_CARD_1 = {
+  "cc-name": "John Doe",
+  "cc-number": "1234567812345678",
+  "cc-exp-month": 4,
+  "cc-exp-year": 2017,
+};
+
 // storage.get() doesn't support getting deleted items. However, this test
 // wants to do that, so rather than making .get() support that just for this
 // test, we use this helper.
 function findGUID(storage, guid, options) {
   let all = storage.getAll(options);
   let records = all.filter(r => r.guid == guid);
   equal(records.length, 1);
   return records[0];
 }
 
 add_task(async function test_changeCounter() {
-  let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME,
+  let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, TEST_COLLECTION_NAME,
                                                 [TEST_ADDRESS_1]);
 
   let [address] = profileStorage.addresses.getAll();
   // new records don't get the sync metadata.
   equal(getSyncChangeCounter(profileStorage.addresses, address.guid), -1);
   // But we can force one.
   profileStorage.addresses.pullSyncChanges();
   equal(getSyncChangeCounter(profileStorage.addresses, address.guid), 1);
 });
 
 add_task(async function test_pushChanges() {
-  let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME,
+  let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, TEST_COLLECTION_NAME,
                                                 [TEST_ADDRESS_1, TEST_ADDRESS_2]);
 
   profileStorage.addresses.pullSyncChanges(); // force sync metadata for all items
 
   let [, address] = profileStorage.addresses.getAll();
   let guid = address.guid;
   let changeCounter = getSyncChangeCounter(profileStorage.addresses, guid);
 
@@ -106,17 +114,17 @@ async function checkingSyncChange(action
   let onChanged = TestUtils.topicObserved("formautofill-storage-changed",
                                           (subject, data) => data == action);
   await callback();
   let [subject] = await onChanged;
   ok(subject.wrappedJSObject.sourceSync, "change notification should have source sync");
 }
 
 add_task(async function test_add_sourceSync() {
-  let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, []);
+  let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, TEST_COLLECTION_NAME, []);
 
   // Hardcode a guid so that we don't need to generate a dynamic regex
   let guid = "aaaaaaaaaaaa";
   let testAddr = Object.assign({guid, version: 1}, TEST_ADDRESS_1);
 
   await checkingSyncChange("add", () =>
     profileStorage.addresses.add(testAddr, {sourceSync: true}));
 
@@ -125,17 +133,17 @@ add_task(async function test_add_sourceS
 
   Assert.throws(() =>
     profileStorage.addresses.add({guid, deleted: true}, {sourceSync: true}),
     /Record aaaaaaaaaaaa already exists/
   );
 });
 
 add_task(async function test_add_tombstone_sourceSync() {
-  let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, []);
+  let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, TEST_COLLECTION_NAME, []);
 
   let guid = profileStorage.addresses._generateGUID();
   let testAddr = {guid, deleted: true};
   await checkingSyncChange("add", () =>
     profileStorage.addresses.add(testAddr, {sourceSync: true}));
 
   let added = findGUID(profileStorage.addresses, guid,
     {includeDeleted: true});
@@ -149,17 +157,17 @@ add_task(async function test_add_tombsto
 
   added = findGUID(profileStorage.addresses, guid,
     {includeDeleted: true});
   equal(getSyncChangeCounter(profileStorage.addresses, guid), 0);
   ok(added.deleted);
 });
 
 add_task(async function test_add_resurrects_tombstones() {
-  let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, []);
+  let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, TEST_COLLECTION_NAME, []);
 
   let guid = profileStorage.addresses._generateGUID();
 
   // Add a tombstone.
   profileStorage.addresses.add({guid, deleted: true});
 
   // You can't re-add an item with an explicit GUID.
   let resurrected = Object.assign({}, TEST_ADDRESS_1, {guid, version: 1});
@@ -170,49 +178,50 @@ add_task(async function test_add_resurre
   let guid3 = profileStorage.addresses.add(resurrected, {sourceSync: true});
   equal(guid, guid3);
 
   let got = profileStorage.addresses.get(guid);
   equal(got["given-name"], TEST_ADDRESS_1["given-name"]);
 });
 
 add_task(async function test_remove_sourceSync_localChanges() {
-  let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, [TEST_ADDRESS_1]);
+  let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, TEST_COLLECTION_NAME,
+                                                [TEST_ADDRESS_1]);
   profileStorage.addresses.pullSyncChanges(); // force sync metadata
 
   let [{guid}] = profileStorage.addresses.getAll();
 
   equal(getSyncChangeCounter(profileStorage.addresses, guid), 1);
   // try and remove a record stored locally with local changes
   await checkingSyncChange("remove", () =>
     profileStorage.addresses.remove(guid, {sourceSync: true}));
 
   let record = profileStorage.addresses.get(guid);
   ok(record);
   equal(getSyncChangeCounter(profileStorage.addresses, guid), 1);
 });
 
 add_task(async function test_remove_sourceSync_unknown() {
   // remove a record not stored locally
-  let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, []);
+  let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, TEST_COLLECTION_NAME, []);
 
   let guid = profileStorage.addresses._generateGUID();
   await checkingSyncChange("remove", () =>
     profileStorage.addresses.remove(guid, {sourceSync: true}));
 
   let tombstone = findGUID(profileStorage.addresses, guid, {
     includeDeleted: true,
   });
   ok(tombstone.deleted);
   equal(getSyncChangeCounter(profileStorage.addresses, guid), 0);
 });
 
 add_task(async function test_remove_sourceSync_unchanged() {
   // Remove a local record without a change counter.
-  let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, []);
+  let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, TEST_COLLECTION_NAME, []);
 
   let guid = profileStorage.addresses._generateGUID();
   let addr = Object.assign({guid, version: 1}, TEST_ADDRESS_1);
   // add a record with sourceSync to guarantee changeCounter == 0
   await checkingSyncChange("add", () =>
     profileStorage.addresses.add(addr, {sourceSync: true}));
 
   equal(getSyncChangeCounter(profileStorage.addresses, guid), 0);
@@ -223,17 +232,17 @@ add_task(async function test_remove_sour
   let tombstone = findGUID(profileStorage.addresses, guid, {
     includeDeleted: true,
   });
   ok(tombstone.deleted);
   equal(getSyncChangeCounter(profileStorage.addresses, guid), 0);
 });
 
 add_task(async function test_pullSyncChanges() {
-  let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME,
+  let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, TEST_COLLECTION_NAME,
                                                 [TEST_ADDRESS_1, TEST_ADDRESS_2]);
 
   let startAddresses = profileStorage.addresses.getAll();
   equal(startAddresses.length, 2);
   // All should start without sync metadata
   for (let {guid} of profileStorage.addresses._store.data.addresses) {
     let changeCounter = getSyncChangeCounter(profileStorage.addresses, guid);
     equal(changeCounter, -1);
@@ -286,17 +295,17 @@ add_task(async function test_pullSyncCha
       change.profile.guid);
     equal(change.counter, changeCounter);
     ok(!change.synced);
   }
 });
 
 add_task(async function test_pullPushChanges() {
   // round-trip changes between pull and push
-  let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, []);
+  let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, TEST_COLLECTION_NAME, []);
   let psa = profileStorage.addresses;
 
   let guid1 = psa.add(TEST_ADDRESS_1);
   let guid2 = psa.add(TEST_ADDRESS_2);
   let guid3 = psa.add(TEST_ADDRESS_3);
 
   let changes = psa.pullSyncChanges();
 
@@ -318,17 +327,17 @@ add_task(async function test_pullPushCha
   equal(getSyncChangeCounter(psa, guid1), 0);
   // second was synced correctly, but it had a change while syncing.
   equal(getSyncChangeCounter(psa, guid2), 1);
   // 3rd wasn't marked as having synced.
   equal(getSyncChangeCounter(psa, guid3), 1);
 });
 
 add_task(async function test_changeGUID() {
-  let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, []);
+  let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, TEST_COLLECTION_NAME, []);
 
   let newguid = () => profileStorage.addresses._generateGUID();
 
   let guid_synced = profileStorage.addresses.add(TEST_ADDRESS_1);
 
   // pullSyncChanges so guid_synced is flagged as syncing.
   profileStorage.addresses.pullSyncChanges();
 
@@ -358,46 +367,59 @@ add_task(async function test_changeGUID(
 
   ok(profileStorage.addresses.get(guid_synced), "synced item still exists.");
   ok(profileStorage.addresses.get(guid_u2), "guid we didn't touch still exists.");
   ok(profileStorage.addresses.get(targetguid), "target guid exists.");
   ok(!profileStorage.addresses.get(guid_u1), "old guid no longer exists.");
 });
 
 add_task(async function test_findDuplicateGUID() {
-  let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME,
-                                                [TEST_ADDRESS_1]);
+  const TEST_RECORDS = {
+    "addresses": [TEST_ADDRESS_1],
+    "creditCards": [TEST_CREDIT_CARD_1],
+  };
 
-  let [record] = profileStorage.addresses.getAll({rawData: true});
-  await Assert.rejects(profileStorage.addresses.findDuplicateGUID(record),
-    /Record \w+ already exists/,
-    "Should throw if the GUID already exists");
+  for (let collectionName of Object.keys(TEST_RECORDS)) {
+    let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, collectionName,
+                                                  TEST_RECORDS[collectionName]);
+    let [record] = profileStorage[collectionName].getAll({rawData: true});
+    await Assert.rejects(profileStorage[collectionName].findDuplicateGUID(record),
+      /Record \w+ already exists/,
+      "Should throw if the GUID already exists");
 
-  // Add a malformed record, passing `sourceSync` to work around the record
-  // normalization logic that would prevent this.
-  let timeLastModified = Date.now();
-  let timeCreated = timeLastModified - 60 * 1000;
+    let targetRecord = Object.assign({}, TEST_RECORDS[collectionName][0], {
+      guid: profileStorage[collectionName]._generateGUID(),
+      version: record.version,
+    });
+    let duplicateGUID = await profileStorage[collectionName].findDuplicateGUID(targetRecord);
+    equal(duplicateGUID, record.guid);
 
-  profileStorage.addresses.add({
-    guid: profileStorage.addresses._generateGUID(),
-    version: 1,
-    timeCreated,
-    timeLastModified,
-  }, {sourceSync: true});
+    // Add a malformed record, passing `sourceSync` to work around the record
+    // normalization logic that would prevent this.
+    let timeLastModified = Date.now();
+    let timeCreated = timeLastModified - 60 * 1000;
 
-  strictEqual(await profileStorage.addresses.findDuplicateGUID({
-    guid: profileStorage.addresses._generateGUID(),
-    version: 1,
-    timeCreated,
-    timeLastModified,
-  }), null, "Should ignore internal fields and malformed records");
+    profileStorage[collectionName].add({
+      guid: profileStorage[collectionName]._generateGUID(),
+      version: 1,
+      timeCreated,
+      timeLastModified,
+    }, {sourceSync: true});
+
+    strictEqual(await profileStorage[collectionName].findDuplicateGUID({
+      guid: profileStorage[collectionName]._generateGUID(),
+      version: 1,
+      timeCreated,
+      timeLastModified,
+    }), null, "Should ignore internal fields and malformed records");
+  }
 });
 
 add_task(async function test_reset() {
-  let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME,
+  let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, TEST_COLLECTION_NAME,
                                                 [TEST_ADDRESS_1, TEST_ADDRESS_2]);
 
   let addresses = profileStorage.addresses.getAll();
   // All should start without sync metadata
   for (let {guid} of addresses) {
     let changeCounter = getSyncChangeCounter(profileStorage.addresses, guid);
     equal(changeCounter, -1);
   }
--- a/browser/extensions/formautofill/test/unit/test_sync.js
+++ b/browser/extensions/formautofill/test/unit/test_sync.js
@@ -16,16 +16,17 @@ Cu.import("resource://testing-common/ser
 let {sanitizeStorageObject, AutofillRecord, AddressesEngine} =
   Cu.import("resource://formautofill/FormAutofillSync.jsm", {});
 
 
 Services.prefs.setCharPref("extensions.formautofill.loglevel", "Trace");
 initTestLogging("Trace");
 
 const TEST_STORE_FILE_NAME = "test-profile.json";
+const TEST_COLLECTION_NAME = "addresses";
 
 const TEST_PROFILE_1 = {
   "given-name": "Timothy",
   "additional-name": "John",
   "family-name": "Berners-Lee",
   organization: "World Wide Web Consortium",
   "street-address": "32 Vassar Street\nMIT Room 32-G524",
   "address-level2": "Cambridge",
@@ -62,17 +63,17 @@ function expectLocalProfiles(profileStor
     do_print(JSON.stringify(expected, undefined, 2));
     do_print("against actual profiles:");
     do_print(JSON.stringify(profiles, undefined, 2));
     throw ex;
   }
 }
 
 async function setup() {
-  let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME);
+  let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, TEST_COLLECTION_NAME);
   // should always start with no profiles.
   Assert.equal(profileStorage.addresses.getAll({includeDeleted: true}).length, 0);
 
   Services.prefs.setCharPref("services.sync.log.logger.engine.addresses", "Trace");
   let engine = new AddressesEngine(Service);
   await engine.initialize();
   // Avoid accidental automatic sync due to our own changes
   Service.scheduler.syncThreshold = 10000000;