Bug 1016733 - Implement form auto-fill profile storage; r=MattN
MozReview-Commit-ID: CH7DkuWbRKU
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]