Bug 1016733 - Implement form auto-fill profile storage; r=MattN draft
authorLuke Chang <lchang@mozilla.com>
Wed, 12 Oct 2016 15:43:58 +0800
changeset 433222 4246f89e1bf6435a34fc06f158a4a0bfadeaf5a8
parent 427779 f0f1aaf051d6798e1e73d1feee07ca847333167a
child 535827 87c551c0efd50b4fbd7f27e202b6accdce8fa05d
push id34510
push userbmo:lchang@mozilla.com
push dateThu, 03 Nov 2016 09:04:38 +0000
reviewersMattN
bugs1016733
milestone52.0a1
Bug 1016733 - Implement form auto-fill profile storage; r=MattN MozReview-Commit-ID: CH7DkuWbRKU
browser/extensions/formautofill/content/ProfileStorage.jsm
browser/extensions/formautofill/test/unit/.eslintrc
browser/extensions/formautofill/test/unit/head.js
browser/extensions/formautofill/test/unit/test_profileStorage.js
browser/extensions/formautofill/test/unit/xpcshell.ini
new file mode 100644
--- /dev/null
+++ b/browser/extensions/formautofill/content/ProfileStorage.jsm
@@ -0,0 +1,251 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/*
+ * Implements an interface of the storage of Form Autofill.
+ *
+ * The data is stored in JSON format, without indentation, using UTF-8 encoding.
+ * With indentation applied, the file would look like this:
+ *
+ * {
+ *   version: 1,
+ *   profiles: [
+ *     {
+ *       guid,             // 12 character...
+ *
+ *       // profile
+ *       organization,     // Company
+ *       streetAddress,    // (Multiline)
+ *       addressLevel2,    // City/Town
+ *       addressLevel1,    // Province (Standardized code if possible)
+ *       postalCode,
+ *       country,          // ISO 3166
+ *       tel,
+ *       email,
+ *
+ *       // metadata
+ *       timeCreated,      // in ms
+ *       timeLastUsed,     // in ms
+ *       timeLastModified, // in ms
+ *       timesUsed
+ *     },
+ *     {
+ *       // ...
+ *     }
+ *   ]
+ * }
+ */
+
+"use strict";
+
+const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
+
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/Task.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "JSONFile",
+                                  "resource://gre/modules/JSONFile.jsm");
+
+XPCOMUtils.defineLazyServiceGetter(this, "gUUIDGenerator",
+                                   "@mozilla.org/uuid-generator;1",
+                                   "nsIUUIDGenerator");
+
+const SCHEMA_VERSION = 1;
+
+// Name-related fields will be handled in follow-up bugs due to the complexity.
+const VALID_FIELDS = [
+  "organization",
+  "streetAddress",
+  "addressLevel2",
+  "addressLevel1",
+  "postalCode",
+  "country",
+  "tel",
+  "email",
+];
+
+function ProfileStorage(path) {
+  this._path = path;
+}
+
+ProfileStorage.prototype = {
+  /**
+   * Loads the profile data from file to memory.
+   *
+   * @returns {Promise}
+   * @resolves When the operation finished successfully.
+   * @rejects  JavaScript exception.
+   */
+  initialize() {
+    this._store = new JSONFile({
+      path: this._path,
+      dataPostProcessor: this._dataPostProcessor.bind(this),
+    });
+    return this._store.load();
+  },
+
+  /**
+   * Adds a new profile.
+   *
+   * @param {Profile} profile
+   *        The new profile for saving.
+   */
+  add(profile) {
+    this._store.ensureDataReady();
+
+    let profileToSave = this._normalizeProfile(profile);
+
+    profileToSave.guid = gUUIDGenerator.generateUUID().toString()
+                                       .replace(/[{}-]/g, "").substring(0, 12);
+
+    // Metadata
+    let now = Date.now();
+    profileToSave.timeCreated = now;
+    profileToSave.timeLastModified = now;
+    profileToSave.timeLastUsed = 0;
+    profileToSave.timesUsed = 0;
+
+    this._store.data.profiles.push(profileToSave);
+
+    this._store.saveSoon();
+  },
+
+  /**
+   * Update the specified profile.
+   *
+   * @param  {string} guid
+   *         Indicates which profile to update.
+   * @param  {Profile} profile
+   *         The new profile used to overwrite the old one.
+   */
+  update(guid, profile) {
+    this._store.ensureDataReady();
+
+    let profileFound = this._findByGUID(guid);
+    if (!profileFound) {
+      throw new Error("No matching profile.");
+    }
+
+    let profileToUpdate = this._normalizeProfile(profile);
+    for (let field of VALID_FIELDS) {
+      if (profileToUpdate[field] !== undefined) {
+        profileFound[field] = profileToUpdate[field];
+      } else {
+        delete profileFound[field];
+      }
+    }
+
+    profileFound.timeLastModified = Date.now();
+
+    this._store.saveSoon();
+  },
+
+  /**
+   * Notifies the stroage of the use of the specified profile, so we can update
+   * the metadata accordingly.
+   *
+   * @param  {string} guid
+   *         Indicates which profile to be notified.
+   */
+  notifyUsed(guid) {
+    this._store.ensureDataReady();
+
+    let profileFound = this._findByGUID(guid);
+    if (!profileFound) {
+      throw new Error("No matching profile.");
+    }
+
+    profileFound.timesUsed++;
+    profileFound.timeLastUsed = Date.now();
+
+    this._store.saveSoon();
+  },
+
+  /**
+   * Removes the specified profile. No error occurs if the profile isn't found.
+   *
+   * @param  {string} guid
+   *         Indicates which profile to remove.
+   */
+  remove(guid) {
+    this._store.ensureDataReady();
+
+    this._store.data.profiles =
+      this._store.data.profiles.filter(profile => profile.guid != guid);
+    this._store.saveSoon();
+  },
+
+  /**
+   * Returns the profile with the specified GUID.
+   *
+   * @param   {string} guid
+   *          Indicates which profile to retrieve.
+   * @returns {Profile}
+   *          A clone of the profile.
+   */
+  get(guid) {
+    this._store.ensureDataReady();
+
+    let profileFound = this._findByGUID(guid);
+    if (!profileFound) {
+      throw new Error("No matching profile.");
+    }
+
+    // Profile is cloned to avoid accidental modifications from outside.
+    return this._clone(profileFound);
+  },
+
+  /**
+   * Returns all profiles.
+   *
+   * @returns {Array.<Profile>}
+   *          An array containing clones of all profiles.
+   */
+  getAll() {
+    this._store.ensureDataReady();
+
+    // Profiles are cloned to avoid accidental modifications from outside.
+    return this._store.data.profiles.map(this._clone);
+  },
+
+  _clone(profile) {
+    return Object.assign({}, profile);
+  },
+
+  _findByGUID(guid) {
+    return this._store.data.profiles.find(profile => profile.guid == guid);
+  },
+
+  _normalizeProfile(profile) {
+    let result = {};
+    for (let key in profile) {
+      if (!VALID_FIELDS.includes(key)) {
+        throw new Error(`"${key}" is not a valid field.`);
+      }
+      if (typeof profile[key] !== "string" &&
+          typeof profile[key] !== "number") {
+        throw new Error(`"${key}" contains invalid data type.`);
+      }
+
+      result[key] = profile[key];
+    }
+    return result;
+  },
+
+  _dataPostProcessor(data) {
+    data.version = SCHEMA_VERSION;
+    if (!data.profiles) {
+      data.profiles = [];
+    }
+    return data;
+  },
+
+  // For test only.
+  _saveImmediately() {
+    return this._store._save();
+  },
+};
+
+this.EXPORTED_SYMBOLS = ["ProfileStorage"];
--- a/browser/extensions/formautofill/test/unit/.eslintrc
+++ b/browser/extensions/formautofill/test/unit/.eslintrc
@@ -1,5 +1,5 @@
 {
   "extends": [
-    "../../../../../testing/xpcshell/xpcshell.eslintrc"
+    "../../../../../testing/xpcshell/xpcshell.eslintrc.js"
   ],
 }
--- a/browser/extensions/formautofill/test/unit/head.js
+++ b/browser/extensions/formautofill/test/unit/head.js
@@ -1,26 +1,71 @@
 /**
  * Provides infrastructure for automated login components tests.
  */
 
- /* exported importAutofillModule */
+ /* exported importAutofillModule, getTempFile */
 
 "use strict";
 
 const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
 
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 Cu.import("resource://gre/modules/Services.jsm");
 Cu.import("resource://testing-common/MockDocument.jsm");
 
 // Load the module by Service newFileURI API for running extension's XPCShell test
 function importAutofillModule(module) {
   return Cu.import(Services.io.newFileURI(do_get_file(module)).spec);
 }
 
+XPCOMUtils.defineLazyModuleGetter(this, "DownloadPaths",
+                                  "resource://gre/modules/DownloadPaths.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "FileUtils",
+                                  "resource://gre/modules/FileUtils.jsm");
+
+// While the previous test file should have deleted all the temporary files it
+// used, on Windows these might still be pending deletion on the physical file
+// system.  Thus, start from a new base number every time, to make a collision
+// with a file that is still pending deletion highly unlikely.
+let gFileCounter = Math.floor(Math.random() * 1000000);
+
+/**
+ * Returns a reference to a temporary file, that is guaranteed not to exist, and
+ * to have never been created before.
+ *
+ * @param {string} leafName
+ *        Suggested leaf name for the file to be created.
+ *
+ * @returns {nsIFile} pointing to a non-existent file in a temporary directory.
+ *
+ * @note It is not enough to delete the file if it exists, or to delete the file
+ *       after calling nsIFile.createUnique, because on Windows the delete
+ *       operation in the file system may still be pending, preventing a new
+ *       file with the same name to be created.
+ */
+function getTempFile(leafName) {
+  // Prepend a serial number to the extension in the suggested leaf name.
+  let [base, ext] = DownloadPaths.splitBaseNameAndExtension(leafName);
+  let finalLeafName = base + "-" + gFileCounter + ext;
+  gFileCounter++;
+
+  // Get a file reference under the temporary directory for this test file.
+  let file = FileUtils.getFile("TmpD", [finalLeafName]);
+  do_check_false(file.exists());
+
+  do_register_cleanup(function() {
+    if (file.exists()) {
+      file.remove(false);
+    }
+  });
+
+  return file;
+}
+
 add_task(function* test_common_initialize() {
   Services.prefs.setBoolPref("dom.forms.autocomplete.experimental", true);
 
   // Clean up after every test.
   do_register_cleanup(() => {
     Services.prefs.setBoolPref("dom.forms.autocomplete.experimental", false);
   });
 });
new file mode 100644
--- /dev/null
+++ b/browser/extensions/formautofill/test/unit/test_profileStorage.js
@@ -0,0 +1,222 @@
+/**
+ * Tests ProfileStorage object.
+ */
+
+/* global ProfileStorage */
+
+"use strict";
+
+Cu.import("resource://gre/modules/Task.jsm");
+Cu.import(Services.io.newFileURI(do_get_file("ProfileStorage.jsm")).spec);
+
+const TEST_STORE_FILE_NAME = "test-profile.json";
+
+const TEST_PROFILE_1 = {
+  organization: "World Wide Web Consortium",
+  streetAddress: "32 Vassar Street\nMIT Room 32-G524",
+  addressLevel2: "Cambridge",
+  addressLevel1: "MA",
+  postalCode: "02139",
+  country: "US",
+  tel: "+1 617 253 5702",
+  email: "timbl@w3.org",
+};
+
+const TEST_PROFILE_2 = {
+  streetAddress: "Some Address",
+  country: "US",
+};
+
+const TEST_PROFILE_3 = {
+  streetAddress: "Other Address",
+  postalCode: "12345",
+};
+
+const TEST_PROFILE_WITH_INVALID_FIELD = {
+  streetAddress: "Another Address",
+  invalidField: "INVALID",
+};
+
+let prepareTestProfiles = Task.async(function* (path) {
+  let profileStorage = new ProfileStorage(path);
+  yield profileStorage.initialize();
+
+  profileStorage.add(TEST_PROFILE_1);
+  profileStorage.add(TEST_PROFILE_2);
+  yield profileStorage._saveImmediately();
+});
+
+let do_check_profile_matches = (profileWithMeta, profile) => {
+  for (let key in profile) {
+    do_check_eq(profileWithMeta[key], profile[key]);
+  }
+};
+
+add_task(function* test_initialize() {
+  let path = getTempFile(TEST_STORE_FILE_NAME).path;
+  let profileStorage = new ProfileStorage(path);
+  yield profileStorage.initialize();
+
+  do_check_eq(profileStorage._store.data.version, 1);
+  do_check_eq(profileStorage._store.data.profiles.length, 0);
+
+  let data = profileStorage._store.data;
+
+  yield profileStorage._saveImmediately();
+
+  profileStorage = new ProfileStorage(path);
+  yield profileStorage.initialize();
+
+  Assert.deepEqual(profileStorage._store.data, data);
+});
+
+add_task(function* test_getAll() {
+  let path = getTempFile(TEST_STORE_FILE_NAME).path;
+  yield prepareTestProfiles(path);
+
+  let profileStorage = new ProfileStorage(path);
+  yield profileStorage.initialize();
+
+  let profiles = profileStorage.getAll();
+
+  do_check_eq(profiles.length, 2);
+  do_check_profile_matches(profiles[0], TEST_PROFILE_1);
+  do_check_profile_matches(profiles[1], TEST_PROFILE_2);
+
+  // Modifying output shouldn't affect the storage.
+  profiles[0].organization = "test";
+  do_check_profile_matches(profileStorage.getAll()[0], TEST_PROFILE_1);
+});
+
+add_task(function* test_get() {
+  let path = getTempFile(TEST_STORE_FILE_NAME).path;
+  yield prepareTestProfiles(path);
+
+  let profileStorage = new ProfileStorage(path);
+  yield profileStorage.initialize();
+
+  let profiles = profileStorage.getAll();
+  let guid = profiles[0].guid;
+
+  let profile = profileStorage.get(guid);
+  do_check_profile_matches(profile, TEST_PROFILE_1);
+
+  // Modifying output shouldn't affect the storage.
+  profile.organization = "test";
+  do_check_profile_matches(profileStorage.get(guid), TEST_PROFILE_1);
+
+  Assert.throws(() => profileStorage.get("INVALID_GUID"),
+    /No matching profile\./);
+});
+
+add_task(function* test_add() {
+  let path = getTempFile(TEST_STORE_FILE_NAME).path;
+  yield prepareTestProfiles(path);
+
+  let profileStorage = new ProfileStorage(path);
+  yield profileStorage.initialize();
+
+  let profiles = profileStorage.getAll();
+
+  do_check_eq(profiles.length, 2);
+
+  do_check_profile_matches(profiles[0], TEST_PROFILE_1);
+  do_check_profile_matches(profiles[1], TEST_PROFILE_2);
+
+  do_check_neq(profiles[0].guid, undefined);
+  do_check_neq(profiles[0].timeCreated, undefined);
+  do_check_eq(profiles[0].timeLastModified, profiles[0].timeCreated);
+  do_check_eq(profiles[0].timeLastUsed, 0);
+  do_check_eq(profiles[0].timesUsed, 0);
+
+  Assert.throws(() => profileStorage.add(TEST_PROFILE_WITH_INVALID_FIELD),
+    /"invalidField" is not a valid field\./);
+});
+
+add_task(function* test_update() {
+  let path = getTempFile(TEST_STORE_FILE_NAME).path;
+  yield prepareTestProfiles(path);
+
+  let profileStorage = new ProfileStorage(path);
+  yield profileStorage.initialize();
+
+  let profiles = profileStorage.getAll();
+  let guid = profiles[1].guid;
+  let timeLastModified = profiles[1].timeLastModified;
+
+  do_check_neq(profiles[1].country, undefined);
+
+  profileStorage.update(guid, TEST_PROFILE_3);
+  yield profileStorage._saveImmediately();
+
+  profileStorage = new ProfileStorage(path);
+  yield profileStorage.initialize();
+
+  let profile = profileStorage.get(guid);
+
+  do_check_eq(profile.country, undefined);
+  do_check_neq(profile.timeLastModified, timeLastModified);
+  do_check_profile_matches(profile, TEST_PROFILE_3);
+
+  Assert.throws(
+    () => profileStorage.update("INVALID_GUID", TEST_PROFILE_3),
+    /No matching profile\./
+  );
+
+  Assert.throws(
+    () => profileStorage.update(guid, TEST_PROFILE_WITH_INVALID_FIELD),
+    /"invalidField" is not a valid field\./
+  );
+});
+
+add_task(function* test_notifyUsed() {
+  let path = getTempFile(TEST_STORE_FILE_NAME).path;
+  yield prepareTestProfiles(path);
+
+  let profileStorage = new ProfileStorage(path);
+  yield profileStorage.initialize();
+
+  let profiles = profileStorage.getAll();
+  let guid = profiles[1].guid;
+  let timeLastUsed = profiles[1].timeLastUsed;
+  let timesUsed = profiles[1].timesUsed;
+
+  profileStorage.notifyUsed(guid);
+  yield profileStorage._saveImmediately();
+
+  profileStorage = new ProfileStorage(path);
+  yield profileStorage.initialize();
+
+  let profile = profileStorage.get(guid);
+
+  do_check_eq(profile.timesUsed, timesUsed + 1);
+  do_check_neq(profile.timeLastUsed, timeLastUsed);
+
+  Assert.throws(() => profileStorage.notifyUsed("INVALID_GUID"),
+    /No matching profile\./);
+});
+
+add_task(function* test_remove() {
+  let path = getTempFile(TEST_STORE_FILE_NAME).path;
+  yield prepareTestProfiles(path);
+
+  let profileStorage = new ProfileStorage(path);
+  yield profileStorage.initialize();
+
+  let profiles = profileStorage.getAll();
+  let guid = profiles[1].guid;
+
+  do_check_eq(profiles.length, 2);
+
+  profileStorage.remove(guid);
+  yield profileStorage._saveImmediately();
+
+  profileStorage = new ProfileStorage(path);
+  yield profileStorage.initialize();
+
+  profiles = profileStorage.getAll();
+
+  do_check_eq(profiles.length, 1);
+
+  Assert.throws(() => profileStorage.get(guid), /No matching profile\./);
+});
--- a/browser/extensions/formautofill/test/unit/xpcshell.ini
+++ b/browser/extensions/formautofill/test/unit/xpcshell.ini
@@ -1,7 +1,10 @@
 [DEFAULT]
 head = head.js
 tail =
-support-files = ../../content/FormAutofillContent.jsm
+support-files =
+  ../../content/FormAutofillContent.jsm
+  ../../content/ProfileStorage.jsm
 
 [test_autofillFormFields.js]
 [test_collectFormFields.js]
+[test_profileStorage.js]