summaryrefslogtreecommitdiffstats
path: root/toolkit/components/passwordmgr/test/unit
diff options
context:
space:
mode:
Diffstat (limited to 'toolkit/components/passwordmgr/test/unit')
-rw-r--r--toolkit/components/passwordmgr/test/unit/.eslintrc.js7
-rw-r--r--toolkit/components/passwordmgr/test/unit/data/corruptDB.sqlitebin0 -> 32772 bytes
-rw-r--r--toolkit/components/passwordmgr/test/unit/data/key3.dbbin0 -> 16384 bytes
-rw-r--r--toolkit/components/passwordmgr/test/unit/data/signons-v1.sqlitebin0 -> 8192 bytes
-rw-r--r--toolkit/components/passwordmgr/test/unit/data/signons-v1v2.sqlitebin0 -> 10240 bytes
-rw-r--r--toolkit/components/passwordmgr/test/unit/data/signons-v2.sqlitebin0 -> 11264 bytes
-rw-r--r--toolkit/components/passwordmgr/test/unit/data/signons-v2v3.sqlitebin0 -> 12288 bytes
-rw-r--r--toolkit/components/passwordmgr/test/unit/data/signons-v3.sqlitebin0 -> 11264 bytes
-rw-r--r--toolkit/components/passwordmgr/test/unit/data/signons-v3v4.sqlitebin0 -> 11264 bytes
-rw-r--r--toolkit/components/passwordmgr/test/unit/data/signons-v4.sqlitebin0 -> 294912 bytes
-rw-r--r--toolkit/components/passwordmgr/test/unit/data/signons-v4v5.sqlitebin0 -> 327680 bytes
-rw-r--r--toolkit/components/passwordmgr/test/unit/data/signons-v5v6.sqlitebin0 -> 327680 bytes
-rw-r--r--toolkit/components/passwordmgr/test/unit/data/signons-v999-2.sqlitebin0 -> 8192 bytes
-rw-r--r--toolkit/components/passwordmgr/test/unit/data/signons-v999.sqlitebin0 -> 11264 bytes
-rw-r--r--toolkit/components/passwordmgr/test/unit/head.js135
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_OSCrypto_win.js75
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_context_menu.js165
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_dedupeLogins.js284
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_disabled_hosts.js196
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_getFormFields.js147
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_getPasswordFields.js156
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_getPasswordOrigin.js28
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_isOriginMatching.js40
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_legacy_empty_formSubmitURL.js107
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_legacy_validation.js76
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_logins_change.js384
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_logins_decrypt_failure.js77
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_logins_metainfo.js284
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_logins_search.js221
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_maybeImportLogin.js169
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_module_LoginImport.js243
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_module_LoginStore.js206
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_notifications.js172
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_recipes_add.js177
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_recipes_content.js39
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_removeLegacySignonFiles.js69
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_search_schemeUpgrades.js184
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_storage.js102
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_storage_mozStorage.js507
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_telemetry.js187
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_user_autocomplete_result.js488
-rw-r--r--toolkit/components/passwordmgr/test/unit/xpcshell.ini46
42 files changed, 4971 insertions, 0 deletions
diff --git a/toolkit/components/passwordmgr/test/unit/.eslintrc.js b/toolkit/components/passwordmgr/test/unit/.eslintrc.js
new file mode 100644
index 000000000..d35787cd2
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/.eslintrc.js
@@ -0,0 +1,7 @@
+"use strict";
+
+module.exports = {
+ "extends": [
+ "../../../../../testing/xpcshell/xpcshell.eslintrc.js"
+ ]
+};
diff --git a/toolkit/components/passwordmgr/test/unit/data/corruptDB.sqlite b/toolkit/components/passwordmgr/test/unit/data/corruptDB.sqlite
new file mode 100644
index 000000000..b234246ca
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/data/corruptDB.sqlite
Binary files differ
diff --git a/toolkit/components/passwordmgr/test/unit/data/key3.db b/toolkit/components/passwordmgr/test/unit/data/key3.db
new file mode 100644
index 000000000..a83a0a577
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/data/key3.db
Binary files differ
diff --git a/toolkit/components/passwordmgr/test/unit/data/signons-v1.sqlite b/toolkit/components/passwordmgr/test/unit/data/signons-v1.sqlite
new file mode 100644
index 000000000..fe030b61f
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/data/signons-v1.sqlite
Binary files differ
diff --git a/toolkit/components/passwordmgr/test/unit/data/signons-v1v2.sqlite b/toolkit/components/passwordmgr/test/unit/data/signons-v1v2.sqlite
new file mode 100644
index 000000000..729512a12
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/data/signons-v1v2.sqlite
Binary files differ
diff --git a/toolkit/components/passwordmgr/test/unit/data/signons-v2.sqlite b/toolkit/components/passwordmgr/test/unit/data/signons-v2.sqlite
new file mode 100644
index 000000000..a6c72b31e
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/data/signons-v2.sqlite
Binary files differ
diff --git a/toolkit/components/passwordmgr/test/unit/data/signons-v2v3.sqlite b/toolkit/components/passwordmgr/test/unit/data/signons-v2v3.sqlite
new file mode 100644
index 000000000..359df5d31
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/data/signons-v2v3.sqlite
Binary files differ
diff --git a/toolkit/components/passwordmgr/test/unit/data/signons-v3.sqlite b/toolkit/components/passwordmgr/test/unit/data/signons-v3.sqlite
new file mode 100644
index 000000000..918f4142f
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/data/signons-v3.sqlite
Binary files differ
diff --git a/toolkit/components/passwordmgr/test/unit/data/signons-v3v4.sqlite b/toolkit/components/passwordmgr/test/unit/data/signons-v3v4.sqlite
new file mode 100644
index 000000000..e06c33aae
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/data/signons-v3v4.sqlite
Binary files differ
diff --git a/toolkit/components/passwordmgr/test/unit/data/signons-v4.sqlite b/toolkit/components/passwordmgr/test/unit/data/signons-v4.sqlite
new file mode 100644
index 000000000..227c09c81
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/data/signons-v4.sqlite
Binary files differ
diff --git a/toolkit/components/passwordmgr/test/unit/data/signons-v4v5.sqlite b/toolkit/components/passwordmgr/test/unit/data/signons-v4v5.sqlite
new file mode 100644
index 000000000..4534cf255
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/data/signons-v4v5.sqlite
Binary files differ
diff --git a/toolkit/components/passwordmgr/test/unit/data/signons-v5v6.sqlite b/toolkit/components/passwordmgr/test/unit/data/signons-v5v6.sqlite
new file mode 100644
index 000000000..eb4ee6d01
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/data/signons-v5v6.sqlite
Binary files differ
diff --git a/toolkit/components/passwordmgr/test/unit/data/signons-v999-2.sqlite b/toolkit/components/passwordmgr/test/unit/data/signons-v999-2.sqlite
new file mode 100644
index 000000000..e09c4f710
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/data/signons-v999-2.sqlite
Binary files differ
diff --git a/toolkit/components/passwordmgr/test/unit/data/signons-v999.sqlite b/toolkit/components/passwordmgr/test/unit/data/signons-v999.sqlite
new file mode 100644
index 000000000..0328a1a02
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/data/signons-v999.sqlite
Binary files differ
diff --git a/toolkit/components/passwordmgr/test/unit/head.js b/toolkit/components/passwordmgr/test/unit/head.js
new file mode 100644
index 000000000..baf958ab4
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/head.js
@@ -0,0 +1,135 @@
+/**
+ * Provides infrastructure for automated login components tests.
+ */
+
+"use strict";
+
+// Globals
+
+let { 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/LoginRecipes.jsm");
+Cu.import("resource://gre/modules/LoginHelper.jsm");
+Cu.import("resource://testing-common/MockDocument.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "DownloadPaths",
+ "resource://gre/modules/DownloadPaths.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "FileUtils",
+ "resource://gre/modules/FileUtils.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "OS",
+ "resource://gre/modules/osfile.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Promise",
+ "resource://gre/modules/Promise.jsm");
+
+const LoginInfo =
+ Components.Constructor("@mozilla.org/login-manager/loginInfo;1",
+ "nsILoginInfo", "init");
+
+// Import LoginTestUtils.jsm as LoginTestUtils.
+XPCOMUtils.defineLazyModuleGetter(this, "LoginTestUtils",
+ "resource://testing-common/LoginTestUtils.jsm");
+LoginTestUtils.Assert = Assert;
+const TestData = LoginTestUtils.testData;
+const newPropertyBag = LoginHelper.newPropertyBag;
+
+/**
+ * All the tests are implemented with add_task, this starts them automatically.
+ */
+function run_test()
+{
+ do_get_profile();
+ run_next_test();
+}
+
+// Global helpers
+
+// Some of these functions are already implemented in other parts of the source
+// tree, see bug 946708 about sharing more code.
+
+// 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 aLeafName
+ * Suggested leaf name for the file to be created.
+ *
+ * @return 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(aLeafName)
+{
+ // Prepend a serial number to the extension in the suggested leaf name.
+ let [base, ext] = DownloadPaths.splitBaseNameAndExtension(aLeafName);
+ let leafName = base + "-" + gFileCounter + ext;
+ gFileCounter++;
+
+ // Get a file reference under the temporary directory for this test file.
+ let file = FileUtils.getFile("TmpD", [leafName]);
+ do_check_false(file.exists());
+
+ do_register_cleanup(function () {
+ if (file.exists()) {
+ file.remove(false);
+ }
+ });
+
+ return file;
+}
+
+const RecipeHelpers = {
+ initNewParent() {
+ return (new LoginRecipesParent({ defaults: null })).initializationPromise;
+ },
+};
+
+// Initialization functions common to all tests
+
+add_task(function* test_common_initialize()
+{
+ // Before initializing the service for the first time, we should copy the key
+ // file required to decrypt the logins contained in the SQLite databases used
+ // by migration tests. This file is not required for the other tests.
+ yield OS.File.copy(do_get_file("data/key3.db").path,
+ OS.Path.join(OS.Constants.Path.profileDir, "key3.db"));
+
+ // Ensure that the service and the storage module are initialized.
+ yield Services.logins.initializationPromise;
+
+ // Ensure that every test file starts with an empty database.
+ LoginTestUtils.clearData();
+
+ // Clean up after every test.
+ do_register_cleanup(() => LoginTestUtils.clearData());
+});
+
+/**
+ * Compare two FormLike to see if they represent the same information. Elements
+ * are compared using their @id attribute.
+ */
+function formLikeEqual(a, b) {
+ Assert.strictEqual(Object.keys(a).length, Object.keys(b).length,
+ "Check the formLikes have the same number of properties");
+
+ for (let propName of Object.keys(a)) {
+ if (propName == "elements") {
+ Assert.strictEqual(a.elements.length, b.elements.length, "Check element count");
+ for (let i = 0; i < a.elements.length; i++) {
+ Assert.strictEqual(a.elements[i].id, b.elements[i].id, "Check element " + i + " id");
+ }
+ continue;
+ }
+ Assert.strictEqual(a[propName], b[propName], "Compare formLike " + propName + " property");
+ }
+}
diff --git a/toolkit/components/passwordmgr/test/unit/test_OSCrypto_win.js b/toolkit/components/passwordmgr/test/unit/test_OSCrypto_win.js
new file mode 100644
index 000000000..94d2e50c0
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_OSCrypto_win.js
@@ -0,0 +1,75 @@
+/**
+ * Tests the OSCrypto object.
+ */
+
+"use strict";
+
+// Globals
+
+XPCOMUtils.defineLazyModuleGetter(this, "OSCrypto",
+ "resource://gre/modules/OSCrypto.jsm");
+
+var crypto = new OSCrypto();
+
+// Tests
+
+add_task(function test_getIELoginHash()
+{
+ do_check_eq(crypto.getIELoginHash("https://bugzilla.mozilla.org/page.cgi"),
+ "4A66FE96607885790F8E67B56EEE52AB539BAFB47D");
+
+ do_check_eq(crypto.getIELoginHash("https://github.com/login"),
+ "0112F7DCE67B8579EA01367678AA44AB9868B5A143");
+
+ do_check_eq(crypto.getIELoginHash("https://login.live.com/login.srf"),
+ "FBF92E5D804C82717A57856533B779676D92903688");
+
+ do_check_eq(crypto.getIELoginHash("https://preview.c9.io/riadh/w1/pass.1.html"),
+ "6935CF27628830605927F86AB53831016FC8973D1A");
+
+
+ do_check_eq(crypto.getIELoginHash("https://reviewboard.mozilla.org/account/login/"),
+ "09141FD287E2E59A8B1D3BB5671537FD3D6B61337A");
+
+ do_check_eq(crypto.getIELoginHash("https://www.facebook.com/"),
+ "EF44D3E034009CB0FD1B1D81A1FF3F3335213BD796");
+
+});
+
+add_task(function test_decryptData_encryptData()
+{
+ function decryptEncryptTest(key) {
+ do_check_eq(crypto.decryptData(crypto.encryptData("", key), key),
+ "");
+
+ do_check_eq(crypto.decryptData(crypto.encryptData("secret", key), key),
+ "secret");
+
+ do_check_eq(crypto.decryptData(crypto.encryptData("https://www.mozilla.org", key),
+ key),
+ "https://www.mozilla.org");
+
+ do_check_eq(crypto.decryptData(crypto.encryptData("https://reviewboard.mozilla.org", key),
+ key),
+ "https://reviewboard.mozilla.org");
+
+ do_check_eq(crypto.decryptData(crypto.encryptData("https://bugzilla.mozilla.org/page.cgi",
+ key),
+ key),
+ "https://bugzilla.mozilla.org/page.cgi");
+ }
+
+ let keys = [null, "a", "keys", "abcdedf", "pass", "https://bugzilla.mozilla.org/page.cgi",
+ "https://login.live.com/login.srf"];
+ for (let key of keys) {
+ decryptEncryptTest(key);
+ }
+ let url = "https://twitter.com/";
+ let value = [1, 0, 0, 0, 208, 140, 157, 223, 1, 21, 209, 17, 140, 122, 0, 192, 79, 194, 151, 235, 1, 0, 0, 0, 254, 58, 230, 75, 132, 228, 181, 79, 184, 160, 37, 106, 201, 29, 42, 152, 0, 0, 0, 0, 2, 0, 0, 0, 0, 0, 16, 102, 0, 0, 0, 1, 0, 0, 32, 0, 0, 0, 90, 136, 17, 124, 122, 57, 178, 24, 34, 86, 209, 198, 184, 107, 58, 58, 32, 98, 61, 239, 129, 101, 56, 239, 114, 159, 139, 165, 183, 40, 183, 85, 0, 0, 0, 0, 14, 128, 0, 0, 0, 2, 0, 0, 32, 0, 0, 0, 147, 170, 34, 21, 53, 227, 191, 6, 201, 84, 106, 31, 57, 227, 46, 127, 219, 199, 80, 142, 37, 104, 112, 223, 26, 165, 223, 55, 176, 89, 55, 37, 112, 0, 0, 0, 98, 70, 221, 109, 5, 152, 46, 11, 190, 213, 226, 58, 244, 20, 180, 217, 63, 155, 227, 132, 7, 151, 235, 6, 37, 232, 176, 182, 141, 191, 251, 50, 20, 123, 53, 11, 247, 233, 112, 121, 130, 27, 168, 68, 92, 144, 192, 7, 12, 239, 53, 217, 253, 155, 54, 109, 236, 216, 225, 245, 79, 234, 165, 225, 104, 36, 77, 13, 195, 237, 143, 165, 100, 107, 230, 70, 54, 19, 179, 35, 8, 101, 93, 202, 121, 210, 222, 28, 93, 122, 36, 84, 185, 249, 238, 3, 102, 149, 248, 94, 137, 16, 192, 22, 251, 220, 22, 223, 16, 58, 104, 187, 64, 0, 0, 0, 70, 72, 15, 119, 144, 66, 117, 203, 190, 82, 131, 46, 111, 130, 238, 191, 170, 63, 186, 117, 46, 88, 171, 3, 94, 146, 75, 86, 243, 159, 63, 195, 149, 25, 105, 141, 42, 217, 108, 18, 63, 62, 98, 182, 241, 195, 12, 216, 152, 230, 176, 253, 202, 129, 41, 185, 135, 111, 226, 92, 27, 78, 27, 198];
+
+ let arr1 = crypto.arrayToString(value);
+ let arr2 = crypto.stringToArray(crypto.decryptData(crypto.encryptData(arr1, url), url));
+ for (let i = 0; i < arr1.length; i++) {
+ do_check_eq(arr2[i], value[i]);
+ }
+});
diff --git a/toolkit/components/passwordmgr/test/unit/test_context_menu.js b/toolkit/components/passwordmgr/test/unit/test_context_menu.js
new file mode 100644
index 000000000..722c13e15
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_context_menu.js
@@ -0,0 +1,165 @@
+/*
+ * Test the password manager context menu.
+ */
+
+"use strict";
+
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/LoginManagerContextMenu.jsm");
+
+XPCOMUtils.defineLazyGetter(this, "_stringBundle", function() {
+ return Services.strings.
+ createBundle("chrome://passwordmgr/locale/passwordmgr.properties");
+});
+
+/**
+ * Prepare data for the following tests.
+ */
+add_task(function* test_initialize() {
+ for (let login of loginList()) {
+ Services.logins.addLogin(login);
+ }
+});
+
+/**
+ * Tests if the LoginManagerContextMenu returns the correct login items.
+ */
+add_task(function* test_contextMenuAddAndRemoveLogins() {
+ const DOCUMENT_CONTENT = "<form><input id='pw' type=password></form>";
+ const INPUT_QUERY = "input[type='password']";
+
+ let testHostnames = [
+ "http://www.example.com",
+ "http://www2.example.com",
+ "http://www3.example.com",
+ "http://empty.example.com",
+ ];
+
+ for (let hostname of testHostnames) {
+ do_print("test for hostname: " + hostname);
+ // Get expected logins for this test.
+ let logins = getExpectedLogins(hostname);
+
+ // Create the logins menuitems fragment.
+ let {fragment, document} = createLoginsFragment(hostname, DOCUMENT_CONTENT, INPUT_QUERY);
+
+ if (!logins.length) {
+ Assert.ok(fragment === null, "Null returned. No logins where found.");
+ continue;
+ }
+ let items = [...fragment.querySelectorAll("menuitem")];
+
+ // Check if the items are those expected to be listed.
+ Assert.ok(checkLoginItems(logins, items), "All expected logins found.");
+ document.body.appendChild(fragment);
+
+ // Try to clear the fragment.
+ LoginManagerContextMenu.clearLoginsFromMenu(document);
+ Assert.equal(fragment.querySelectorAll("menuitem").length, 0, "All items correctly cleared.");
+ }
+
+ Services.logins.removeAllLogins();
+});
+
+/**
+ * Create a fragment with a menuitem for each login.
+ */
+function createLoginsFragment(url, content, elementQuery) {
+ const CHROME_URL = "chrome://mock-chrome";
+
+ // Create a mock document.
+ let document = MockDocument.createTestDocument(CHROME_URL, content);
+ let inputElement = document.querySelector(elementQuery);
+ MockDocument.mockOwnerDocumentProperty(inputElement, document, url);
+
+ // We also need a simple mock Browser object for this test.
+ let browser = {
+ ownerDocument: document
+ };
+
+ let URI = Services.io.newURI(url, null, null);
+ return {
+ document,
+ fragment: LoginManagerContextMenu.addLoginsToMenu(inputElement, browser, URI),
+ };
+}
+
+/**
+ * Check if every login have it's corresponding menuitem.
+ * Duplicates and empty usernames have a date appended.
+ */
+function checkLoginItems(logins, items) {
+ function findDuplicates(unfilteredLoginList) {
+ var seen = new Set();
+ var duplicates = new Set();
+ for (let login of unfilteredLoginList) {
+ if (seen.has(login.username)) {
+ duplicates.add(login.username);
+ }
+ seen.add(login.username);
+ }
+ return duplicates;
+ }
+ let duplicates = findDuplicates(logins);
+
+ let dateAndTimeFormatter = new Intl.DateTimeFormat(undefined,
+ { day: "numeric", month: "short", year: "numeric" });
+ for (let login of logins) {
+ if (login.username && !duplicates.has(login.username)) {
+ // If login is not duplicate and we can't find an item for it, fail.
+ if (!items.find(item => item.label == login.username)) {
+ return false;
+ }
+ continue;
+ }
+
+ let meta = login.QueryInterface(Ci.nsILoginMetaInfo);
+ let time = dateAndTimeFormatter.format(new Date(meta.timePasswordChanged));
+ // If login is duplicate, check if we have a login item with appended date.
+ if (login.username && !items.find(item => item.label == login.username + " (" + time + ")")) {
+ return false;
+ }
+ // If login is empty, check if we have a login item with appended date.
+ if (!login.username &&
+ !items.find(item => item.label == _stringBundle.GetStringFromName("noUsername") + " (" + time + ")")) {
+ return false;
+ }
+ }
+ return true;
+}
+
+/**
+ * Gets the list of expected logins for a hostname.
+ */
+function getExpectedLogins(hostname) {
+ return Services.logins.getAllLogins().filter(entry => entry["hostname"] === hostname);
+}
+
+function loginList() {
+ return [
+ new LoginInfo("http://www.example.com", "http://www.example.com", null,
+ "username1", "password",
+ "form_field_username", "form_field_password"),
+
+ new LoginInfo("http://www.example.com", "http://www.example.com", null,
+ "username2", "password",
+ "form_field_username", "form_field_password"),
+
+ new LoginInfo("http://www2.example.com", "http://www.example.com", null,
+ "username", "password",
+ "form_field_username", "form_field_password"),
+ new LoginInfo("http://www2.example.com", "http://www2.example.com", null,
+ "username", "password2",
+ "form_field_username", "form_field_password"),
+ new LoginInfo("http://www2.example.com", "http://www2.example.com", null,
+ "username2", "password2",
+ "form_field_username", "form_field_password"),
+
+ new LoginInfo("http://www3.example.com", "http://www.example.com", null,
+ "", "password",
+ "form_field_username", "form_field_password"),
+ new LoginInfo("http://www3.example.com", "http://www3.example.com", null,
+ "", "password2",
+ "form_field_username", "form_field_password"),
+ ];
+}
diff --git a/toolkit/components/passwordmgr/test/unit/test_dedupeLogins.js b/toolkit/components/passwordmgr/test/unit/test_dedupeLogins.js
new file mode 100644
index 000000000..d688a6dbf
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_dedupeLogins.js
@@ -0,0 +1,284 @@
+/*
+ * Test LoginHelper.dedupeLogins
+ */
+
+"use strict";
+
+Cu.import("resource://gre/modules/LoginHelper.jsm");
+
+const DOMAIN1_HTTP_TO_HTTP_U1_P1 = TestData.formLogin({
+ timePasswordChanged: 3000,
+ timeLastUsed: 2000,
+});
+const DOMAIN1_HTTP_TO_HTTP_U1_P2 = TestData.formLogin({
+ password: "password two",
+});
+const DOMAIN1_HTTP_TO_HTTP_U2_P2 = TestData.formLogin({
+ password: "password two",
+ username: "username two",
+});
+const DOMAIN1_HTTPS_TO_HTTPS_U1_P1 = TestData.formLogin({
+ formSubmitURL: "http://www.example.com",
+ hostname: "https://www3.example.com",
+ timePasswordChanged: 4000,
+ timeLastUsed: 1000,
+});
+const DOMAIN1_HTTPS_TO_EMPTY_U1_P1 = TestData.formLogin({
+ formSubmitURL: "",
+ hostname: "https://www3.example.com",
+});
+const DOMAIN1_HTTPS_TO_EMPTYU_P1 = TestData.formLogin({
+ hostname: "https://www3.example.com",
+ username: "",
+});
+const DOMAIN1_HTTP_AUTH = TestData.authLogin({
+ hostname: "http://www3.example.com",
+});
+const DOMAIN1_HTTPS_AUTH = TestData.authLogin({
+ hostname: "https://www3.example.com",
+});
+
+
+add_task(function test_dedupeLogins() {
+ // [description, expectedOutput, dedupe arg. 0, dedupe arg 1, ...]
+ let testcases = [
+ [
+ "exact dupes",
+ [DOMAIN1_HTTP_TO_HTTP_U1_P1],
+ [DOMAIN1_HTTP_TO_HTTP_U1_P1, DOMAIN1_HTTP_TO_HTTP_U1_P1],
+ undefined,
+ [], // force no resolveBy logic to test behavior of preferring the first..
+ ],
+ [
+ "default uniqueKeys is un + pw",
+ [DOMAIN1_HTTP_TO_HTTP_U1_P1, DOMAIN1_HTTP_TO_HTTP_U1_P2],
+ [DOMAIN1_HTTP_TO_HTTP_U1_P1, DOMAIN1_HTTP_TO_HTTP_U1_P2],
+ undefined,
+ [],
+ ],
+ [
+ "same usernames, different passwords, dedupe username only",
+ [DOMAIN1_HTTP_TO_HTTP_U1_P1],
+ [DOMAIN1_HTTP_TO_HTTP_U1_P1, DOMAIN1_HTTP_TO_HTTP_U1_P2],
+ ["username"],
+ [],
+ ],
+ [
+ "same un+pw, different scheme",
+ [DOMAIN1_HTTP_TO_HTTP_U1_P1],
+ [DOMAIN1_HTTP_TO_HTTP_U1_P1, DOMAIN1_HTTPS_TO_HTTPS_U1_P1],
+ undefined,
+ [],
+ ],
+ [
+ "same un+pw, different scheme, reverse order",
+ [DOMAIN1_HTTPS_TO_HTTPS_U1_P1],
+ [DOMAIN1_HTTPS_TO_HTTPS_U1_P1, DOMAIN1_HTTP_TO_HTTP_U1_P1],
+ undefined,
+ [],
+ ],
+ [
+ "same un+pw, different scheme, include hostname",
+ [DOMAIN1_HTTP_TO_HTTP_U1_P1, DOMAIN1_HTTPS_TO_HTTPS_U1_P1],
+ [DOMAIN1_HTTP_TO_HTTP_U1_P1, DOMAIN1_HTTPS_TO_HTTPS_U1_P1],
+ ["hostname", "username", "password"],
+ [],
+ ],
+ [
+ "empty username is not deduped with non-empty",
+ [DOMAIN1_HTTP_TO_HTTP_U1_P1, DOMAIN1_HTTPS_TO_EMPTYU_P1],
+ [DOMAIN1_HTTP_TO_HTTP_U1_P1, DOMAIN1_HTTPS_TO_EMPTYU_P1],
+ undefined,
+ [],
+ ],
+ [
+ "empty username is deduped with same passwords",
+ [DOMAIN1_HTTPS_TO_EMPTYU_P1],
+ [DOMAIN1_HTTPS_TO_EMPTYU_P1, DOMAIN1_HTTP_TO_HTTP_U1_P1],
+ ["password"],
+ [],
+ ],
+ [
+ "mix of form and HTTP auth",
+ [DOMAIN1_HTTP_TO_HTTP_U1_P1],
+ [DOMAIN1_HTTP_TO_HTTP_U1_P1, DOMAIN1_HTTP_AUTH],
+ undefined,
+ [],
+ ],
+ ];
+
+ for (let tc of testcases) {
+ let description = tc.shift();
+ let expected = tc.shift();
+ let actual = LoginHelper.dedupeLogins(...tc);
+ Assert.strictEqual(actual.length, expected.length, `Check: ${description}`);
+ for (let [i, login] of expected.entries()) {
+ Assert.strictEqual(actual[i], login, `Check index ${i}`);
+ }
+ }
+});
+
+
+add_task(function* test_dedupeLogins_resolveBy() {
+ Assert.ok(DOMAIN1_HTTP_TO_HTTP_U1_P1.timeLastUsed > DOMAIN1_HTTPS_TO_HTTPS_U1_P1.timeLastUsed,
+ "Sanity check timeLastUsed difference");
+ Assert.ok(DOMAIN1_HTTP_TO_HTTP_U1_P1.timePasswordChanged < DOMAIN1_HTTPS_TO_HTTPS_U1_P1.timePasswordChanged,
+ "Sanity check timePasswordChanged difference");
+
+ let testcases = [
+ [
+ "default resolveBy is timeLastUsed",
+ [DOMAIN1_HTTP_TO_HTTP_U1_P1],
+ [DOMAIN1_HTTPS_TO_HTTPS_U1_P1, DOMAIN1_HTTP_TO_HTTP_U1_P1],
+ ],
+ [
+ "default resolveBy is timeLastUsed, reversed input",
+ [DOMAIN1_HTTP_TO_HTTP_U1_P1],
+ [DOMAIN1_HTTP_TO_HTTP_U1_P1, DOMAIN1_HTTPS_TO_HTTPS_U1_P1],
+ ],
+ [
+ "resolveBy timeLastUsed + timePasswordChanged",
+ [DOMAIN1_HTTP_TO_HTTP_U1_P1],
+ [DOMAIN1_HTTPS_TO_HTTPS_U1_P1, DOMAIN1_HTTP_TO_HTTP_U1_P1],
+ undefined,
+ ["timeLastUsed", "timePasswordChanged"],
+ ],
+ [
+ "resolveBy timeLastUsed + timePasswordChanged, reversed input",
+ [DOMAIN1_HTTP_TO_HTTP_U1_P1],
+ [DOMAIN1_HTTP_TO_HTTP_U1_P1, DOMAIN1_HTTPS_TO_HTTPS_U1_P1],
+ undefined,
+ ["timeLastUsed", "timePasswordChanged"],
+ ],
+ [
+ "resolveBy timePasswordChanged",
+ [DOMAIN1_HTTPS_TO_HTTPS_U1_P1],
+ [DOMAIN1_HTTPS_TO_HTTPS_U1_P1, DOMAIN1_HTTP_TO_HTTP_U1_P1],
+ undefined,
+ ["timePasswordChanged"],
+ ],
+ [
+ "resolveBy timePasswordChanged, reversed",
+ [DOMAIN1_HTTPS_TO_HTTPS_U1_P1],
+ [DOMAIN1_HTTP_TO_HTTP_U1_P1, DOMAIN1_HTTPS_TO_HTTPS_U1_P1],
+ undefined,
+ ["timePasswordChanged"],
+ ],
+ [
+ "resolveBy timePasswordChanged + timeLastUsed",
+ [DOMAIN1_HTTPS_TO_HTTPS_U1_P1],
+ [DOMAIN1_HTTPS_TO_HTTPS_U1_P1, DOMAIN1_HTTP_TO_HTTP_U1_P1],
+ undefined,
+ ["timePasswordChanged", "timeLastUsed"],
+ ],
+ [
+ "resolveBy timePasswordChanged + timeLastUsed, reversed",
+ [DOMAIN1_HTTPS_TO_HTTPS_U1_P1],
+ [DOMAIN1_HTTP_TO_HTTP_U1_P1, DOMAIN1_HTTPS_TO_HTTPS_U1_P1],
+ undefined,
+ ["timePasswordChanged", "timeLastUsed"],
+ ],
+ [
+ "resolveBy scheme + timePasswordChanged, prefer HTTP",
+ [DOMAIN1_HTTP_TO_HTTP_U1_P1],
+ [DOMAIN1_HTTPS_TO_HTTPS_U1_P1, DOMAIN1_HTTP_TO_HTTP_U1_P1],
+ undefined,
+ ["scheme", "timePasswordChanged"],
+ DOMAIN1_HTTP_TO_HTTP_U1_P1.hostname,
+ ],
+ [
+ "resolveBy scheme + timePasswordChanged, prefer HTTP, reversed input",
+ [DOMAIN1_HTTP_TO_HTTP_U1_P1],
+ [DOMAIN1_HTTP_TO_HTTP_U1_P1, DOMAIN1_HTTPS_TO_HTTPS_U1_P1],
+ undefined,
+ ["scheme", "timePasswordChanged"],
+ DOMAIN1_HTTP_TO_HTTP_U1_P1.hostname,
+ ],
+ [
+ "resolveBy scheme + timePasswordChanged, prefer HTTPS",
+ [DOMAIN1_HTTPS_TO_HTTPS_U1_P1],
+ [DOMAIN1_HTTPS_TO_HTTPS_U1_P1, DOMAIN1_HTTP_TO_HTTP_U1_P1],
+ undefined,
+ ["scheme", "timePasswordChanged"],
+ DOMAIN1_HTTPS_TO_HTTPS_U1_P1.hostname,
+ ],
+ [
+ "resolveBy scheme + timePasswordChanged, prefer HTTPS, reversed input",
+ [DOMAIN1_HTTPS_TO_HTTPS_U1_P1],
+ [DOMAIN1_HTTP_TO_HTTP_U1_P1, DOMAIN1_HTTPS_TO_HTTPS_U1_P1],
+ undefined,
+ ["scheme", "timePasswordChanged"],
+ DOMAIN1_HTTPS_TO_HTTPS_U1_P1.hostname,
+ ],
+ [
+ "resolveBy scheme HTTP auth",
+ [DOMAIN1_HTTPS_AUTH],
+ [DOMAIN1_HTTP_AUTH, DOMAIN1_HTTPS_AUTH],
+ undefined,
+ ["scheme"],
+ DOMAIN1_HTTPS_AUTH.hostname,
+ ],
+ [
+ "resolveBy scheme HTTP auth, reversed input",
+ [DOMAIN1_HTTPS_AUTH],
+ [DOMAIN1_HTTPS_AUTH, DOMAIN1_HTTP_AUTH],
+ undefined,
+ ["scheme"],
+ DOMAIN1_HTTPS_AUTH.hostname,
+ ],
+ [
+ "resolveBy scheme, empty form submit URL",
+ [DOMAIN1_HTTPS_TO_HTTPS_U1_P1],
+ [DOMAIN1_HTTPS_TO_HTTPS_U1_P1, DOMAIN1_HTTPS_TO_EMPTY_U1_P1],
+ undefined,
+ ["scheme"],
+ DOMAIN1_HTTPS_TO_HTTPS_U1_P1.hostname,
+ ],
+ ];
+
+ for (let tc of testcases) {
+ let description = tc.shift();
+ let expected = tc.shift();
+ let actual = LoginHelper.dedupeLogins(...tc);
+ Assert.strictEqual(actual.length, expected.length, `Check: ${description}`);
+ for (let [i, login] of expected.entries()) {
+ Assert.strictEqual(actual[i], login, `Check index ${i}`);
+ }
+ }
+
+});
+
+add_task(function* test_dedupeLogins_preferredOriginMissing() {
+ let testcases = [
+ [
+ "resolveBy scheme + timePasswordChanged, missing preferredOrigin",
+ /preferredOrigin/,
+ [DOMAIN1_HTTPS_TO_HTTPS_U1_P1, DOMAIN1_HTTP_TO_HTTP_U1_P1],
+ undefined,
+ ["scheme", "timePasswordChanged"],
+ ],
+ [
+ "resolveBy timePasswordChanged + scheme, missing preferredOrigin",
+ /preferredOrigin/,
+ [DOMAIN1_HTTPS_TO_HTTPS_U1_P1, DOMAIN1_HTTP_TO_HTTP_U1_P1],
+ undefined,
+ ["timePasswordChanged", "scheme"],
+ ],
+ [
+ "resolveBy scheme + timePasswordChanged, empty preferredOrigin",
+ /preferredOrigin/,
+ [DOMAIN1_HTTPS_TO_HTTPS_U1_P1, DOMAIN1_HTTP_TO_HTTP_U1_P1],
+ undefined,
+ ["scheme", "timePasswordChanged"],
+ "",
+ ],
+ ];
+
+ for (let tc of testcases) {
+ let description = tc.shift();
+ let expectedException = tc.shift();
+ Assert.throws(() => {
+ LoginHelper.dedupeLogins(...tc);
+ }, expectedException, `Check: ${description}`);
+ }
+});
diff --git a/toolkit/components/passwordmgr/test/unit/test_disabled_hosts.js b/toolkit/components/passwordmgr/test/unit/test_disabled_hosts.js
new file mode 100644
index 000000000..ff3b7e868
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_disabled_hosts.js
@@ -0,0 +1,196 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Tests getLoginSavingEnabled, setLoginSavingEnabled, and getAllDisabledHosts.
+ */
+
+"use strict";
+
+// Tests
+
+/**
+ * Tests setLoginSavingEnabled and getAllDisabledHosts.
+ */
+add_task(function test_setLoginSavingEnabled_getAllDisabledHosts()
+{
+ // Add some disabled hosts, and verify that different schemes for the same
+ // domain are considered different hosts.
+ let hostname1 = "http://disabled1.example.com";
+ let hostname2 = "http://disabled2.example.com";
+ let hostname3 = "https://disabled2.example.com";
+ Services.logins.setLoginSavingEnabled(hostname1, false);
+ Services.logins.setLoginSavingEnabled(hostname2, false);
+ Services.logins.setLoginSavingEnabled(hostname3, false);
+
+ LoginTestUtils.assertDisabledHostsEqual(Services.logins.getAllDisabledHosts(),
+ [hostname1, hostname2, hostname3]);
+
+ // Adding the same host twice should not result in an error.
+ Services.logins.setLoginSavingEnabled(hostname2, false);
+ LoginTestUtils.assertDisabledHostsEqual(Services.logins.getAllDisabledHosts(),
+ [hostname1, hostname2, hostname3]);
+
+ // Removing a disabled host should work.
+ Services.logins.setLoginSavingEnabled(hostname2, true);
+ LoginTestUtils.assertDisabledHostsEqual(Services.logins.getAllDisabledHosts(),
+ [hostname1, hostname3]);
+
+ // Removing the last disabled host should work.
+ Services.logins.setLoginSavingEnabled(hostname1, true);
+ Services.logins.setLoginSavingEnabled(hostname3, true);
+ LoginTestUtils.assertDisabledHostsEqual(Services.logins.getAllDisabledHosts(),
+ []);
+});
+
+/**
+ * Tests setLoginSavingEnabled and getLoginSavingEnabled.
+ */
+add_task(function test_setLoginSavingEnabled_getLoginSavingEnabled()
+{
+ let hostname1 = "http://disabled.example.com";
+ let hostname2 = "https://disabled.example.com";
+
+ // Hosts should not be disabled by default.
+ do_check_true(Services.logins.getLoginSavingEnabled(hostname1));
+ do_check_true(Services.logins.getLoginSavingEnabled(hostname2));
+
+ // Test setting initial values.
+ Services.logins.setLoginSavingEnabled(hostname1, false);
+ Services.logins.setLoginSavingEnabled(hostname2, true);
+ do_check_false(Services.logins.getLoginSavingEnabled(hostname1));
+ do_check_true(Services.logins.getLoginSavingEnabled(hostname2));
+
+ // Test changing values.
+ Services.logins.setLoginSavingEnabled(hostname1, true);
+ Services.logins.setLoginSavingEnabled(hostname2, false);
+ do_check_true(Services.logins.getLoginSavingEnabled(hostname1));
+ do_check_false(Services.logins.getLoginSavingEnabled(hostname2));
+
+ // Clean up.
+ Services.logins.setLoginSavingEnabled(hostname2, true);
+});
+
+/**
+ * Tests setLoginSavingEnabled with invalid NUL characters in the hostname.
+ */
+add_task(function test_setLoginSavingEnabled_invalid_characters()
+{
+ let hostname = "http://null\0X.example.com";
+ Assert.throws(() => Services.logins.setLoginSavingEnabled(hostname, false),
+ /Invalid hostname/);
+
+ // Verify that no data was stored by the previous call.
+ LoginTestUtils.assertDisabledHostsEqual(Services.logins.getAllDisabledHosts(),
+ []);
+});
+
+/**
+ * Tests different values of the "signon.rememberSignons" property.
+ */
+add_task(function test_rememberSignons()
+{
+ let hostname1 = "http://example.com";
+ let hostname2 = "http://localhost";
+
+ // The default value for the preference should be true.
+ do_check_true(Services.prefs.getBoolPref("signon.rememberSignons"));
+
+ // Hosts should not be disabled by default.
+ Services.logins.setLoginSavingEnabled(hostname1, false);
+ do_check_false(Services.logins.getLoginSavingEnabled(hostname1));
+ do_check_true(Services.logins.getLoginSavingEnabled(hostname2));
+
+ // Disable storage of saved passwords globally.
+ Services.prefs.setBoolPref("signon.rememberSignons", false);
+ do_register_cleanup(
+ () => Services.prefs.clearUserPref("signon.rememberSignons"));
+
+ // All hosts should now appear disabled.
+ do_check_false(Services.logins.getLoginSavingEnabled(hostname1));
+ do_check_false(Services.logins.getLoginSavingEnabled(hostname2));
+
+ // The list of disabled hosts should be unaltered.
+ LoginTestUtils.assertDisabledHostsEqual(Services.logins.getAllDisabledHosts(),
+ [hostname1]);
+
+ // Changing values with the preference set should work.
+ Services.logins.setLoginSavingEnabled(hostname1, true);
+ Services.logins.setLoginSavingEnabled(hostname2, false);
+
+ // All hosts should still appear disabled.
+ do_check_false(Services.logins.getLoginSavingEnabled(hostname1));
+ do_check_false(Services.logins.getLoginSavingEnabled(hostname2));
+
+ // The list of disabled hosts should have been changed.
+ LoginTestUtils.assertDisabledHostsEqual(Services.logins.getAllDisabledHosts(),
+ [hostname2]);
+
+ // Enable storage of saved passwords again.
+ Services.prefs.setBoolPref("signon.rememberSignons", true);
+
+ // Hosts should now appear enabled as requested.
+ do_check_true(Services.logins.getLoginSavingEnabled(hostname1));
+ do_check_false(Services.logins.getLoginSavingEnabled(hostname2));
+
+ // Clean up.
+ Services.logins.setLoginSavingEnabled(hostname2, true);
+ LoginTestUtils.assertDisabledHostsEqual(Services.logins.getAllDisabledHosts(),
+ []);
+});
+
+/**
+ * Tests storing disabled hosts with non-ASCII characters where IDN is supported.
+ */
+add_task(function* test_storage_setLoginSavingEnabled_nonascii_IDN_is_supported()
+{
+ let hostname = "http://大.net";
+ let encoding = "http://xn--pss.net";
+
+ // Test adding disabled host with nonascii URL (http://大.net).
+ Services.logins.setLoginSavingEnabled(hostname, false);
+ yield* LoginTestUtils.reloadData();
+ Assert.equal(Services.logins.getLoginSavingEnabled(hostname), false);
+ Assert.equal(Services.logins.getLoginSavingEnabled(encoding), false);
+ LoginTestUtils.assertDisabledHostsEqual(Services.logins.getAllDisabledHosts(), [hostname]);
+
+ LoginTestUtils.clearData();
+
+ // Test adding disabled host with IDN ("http://xn--pss.net").
+ Services.logins.setLoginSavingEnabled(encoding, false);
+ yield* LoginTestUtils.reloadData();
+ Assert.equal(Services.logins.getLoginSavingEnabled(hostname), false);
+ Assert.equal(Services.logins.getLoginSavingEnabled(encoding), false);
+ LoginTestUtils.assertDisabledHostsEqual(Services.logins.getAllDisabledHosts(), [hostname]);
+
+ LoginTestUtils.clearData();
+});
+
+/**
+ * Tests storing disabled hosts with non-ASCII characters where IDN is not supported.
+ */
+add_task(function* test_storage_setLoginSavingEnabled_nonascii_IDN_not_supported()
+{
+ let hostname = "http://√.com";
+ let encoding = "http://xn--19g.com";
+
+ // Test adding disabled host with nonascii URL (http://√.com).
+ Services.logins.setLoginSavingEnabled(hostname, false);
+ yield* LoginTestUtils.reloadData();
+ Assert.equal(Services.logins.getLoginSavingEnabled(hostname), false);
+ Assert.equal(Services.logins.getLoginSavingEnabled(encoding), false);
+ LoginTestUtils.assertDisabledHostsEqual(Services.logins.getAllDisabledHosts(), [encoding]);
+
+ LoginTestUtils.clearData();
+
+ // Test adding disabled host with IDN ("http://xn--19g.com").
+ Services.logins.setLoginSavingEnabled(encoding, false);
+ yield* LoginTestUtils.reloadData();
+ Assert.equal(Services.logins.getLoginSavingEnabled(hostname), false);
+ Assert.equal(Services.logins.getLoginSavingEnabled(encoding), false);
+ LoginTestUtils.assertDisabledHostsEqual(Services.logins.getAllDisabledHosts(), [encoding]);
+
+ LoginTestUtils.clearData();
+});
diff --git a/toolkit/components/passwordmgr/test/unit/test_getFormFields.js b/toolkit/components/passwordmgr/test/unit/test_getFormFields.js
new file mode 100644
index 000000000..46912ab8f
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_getFormFields.js
@@ -0,0 +1,147 @@
+/*
+ * Test for LoginManagerContent._getFormFields.
+ */
+
+"use strict";
+
+// Services.prefs.setBoolPref("signon.debug", true);
+
+Cu.importGlobalProperties(["URL"]);
+
+const LMCBackstagePass = Cu.import("resource://gre/modules/LoginManagerContent.jsm");
+const { LoginManagerContent, LoginFormFactory } = LMCBackstagePass;
+const TESTCASES = [
+ {
+ description: "1 password field outside of a <form>",
+ document: `<input id="pw1" type=password>`,
+ returnedFieldIDs: [null, "pw1", null],
+ skipEmptyFields: undefined,
+ },
+ {
+ description: "1 text field outside of a <form> without a password field",
+ document: `<input id="un1">`,
+ returnedFieldIDs: [null, null, null],
+ skipEmptyFields: undefined,
+ },
+ {
+ description: "1 username & password field outside of a <form>",
+ document: `<input id="un1">
+ <input id="pw1" type=password>`,
+ returnedFieldIDs: ["un1", "pw1", null],
+ skipEmptyFields: undefined,
+ },
+ {
+ description: "1 username & password field in a <form>",
+ document: `<form>
+ <input id="un1">
+ <input id="pw1" type=password>
+ </form>`,
+ returnedFieldIDs: ["un1", "pw1", null],
+ skipEmptyFields: undefined,
+ },
+ {
+ description: "4 empty password fields outside of a <form>",
+ document: `<input id="pw1" type=password>
+ <input id="pw2" type=password>
+ <input id="pw3" type=password>
+ <input id="pw4" type=password>`,
+ returnedFieldIDs: [null, null, null],
+ skipEmptyFields: undefined,
+ },
+ {
+ description: "4 password fields outside of a <form> (1 empty, 3 full) with skipEmpty",
+ document: `<input id="pw1" type=password>
+ <input id="pw2" type=password value="pass2">
+ <input id="pw3" type=password value="pass3">
+ <input id="pw4" type=password value="pass4">`,
+ returnedFieldIDs: [null, null, null],
+ skipEmptyFields: true,
+ },
+ {
+ description: "Form with 1 password field",
+ document: `<form><input id="pw1" type=password></form>`,
+ returnedFieldIDs: [null, "pw1", null],
+ skipEmptyFields: undefined,
+ },
+ {
+ description: "Form with 2 password fields",
+ document: `<form><input id="pw1" type=password><input id='pw2' type=password></form>`,
+ returnedFieldIDs: [null, "pw1", null],
+ skipEmptyFields: undefined,
+ },
+ {
+ description: "1 password field in a form, 1 outside (not processed)",
+ document: `<form><input id="pw1" type=password></form><input id="pw2" type=password>`,
+ returnedFieldIDs: [null, "pw1", null],
+ skipEmptyFields: undefined,
+ },
+ {
+ description: "1 password field in a form, 1 text field outside (not processed)",
+ document: `<form><input id="pw1" type=password></form><input>`,
+ returnedFieldIDs: [null, "pw1", null],
+ skipEmptyFields: undefined,
+ },
+ {
+ description: "1 text field in a form, 1 password field outside (not processed)",
+ document: `<form><input></form><input id="pw1" type=password>`,
+ returnedFieldIDs: [null, null, null],
+ skipEmptyFields: undefined,
+ },
+ {
+ description: "2 password fields outside of a <form> with 1 linked via @form",
+ document: `<input id="pw1" type=password><input id="pw2" type=password form='form1'>
+ <form id="form1"></form>`,
+ returnedFieldIDs: [null, "pw1", null],
+ skipEmptyFields: undefined,
+ },
+ {
+ description: "2 password fields outside of a <form> with 1 linked via @form + skipEmpty",
+ document: `<input id="pw1" type=password><input id="pw2" type=password form="form1">
+ <form id="form1"></form>`,
+ returnedFieldIDs: [null, null, null],
+ skipEmptyFields: true,
+ },
+ {
+ description: "2 password fields outside of a <form> with 1 linked via @form + skipEmpty with 1 empty",
+ document: `<input id="pw1" type=password value="pass1"><input id="pw2" type=password form="form1">
+ <form id="form1"></form>`,
+ returnedFieldIDs: [null, "pw1", null],
+ skipEmptyFields: true,
+ },
+];
+
+for (let tc of TESTCASES) {
+ do_print("Sanity checking the testcase: " + tc.description);
+
+ (function() {
+ let testcase = tc;
+ add_task(function*() {
+ do_print("Starting testcase: " + testcase.description);
+ let document = MockDocument.createTestDocument("http://localhost:8080/test/",
+ testcase.document);
+
+ let input = document.querySelector("input");
+ MockDocument.mockOwnerDocumentProperty(input, document, "http://localhost:8080/test/");
+
+ let formLike = LoginFormFactory.createFromField(input);
+
+ let actual = LoginManagerContent._getFormFields(formLike,
+ testcase.skipEmptyFields,
+ new Set());
+
+ Assert.strictEqual(testcase.returnedFieldIDs.length, 3,
+ "_getFormFields returns 3 elements");
+
+ for (let i = 0; i < testcase.returnedFieldIDs.length; i++) {
+ let expectedID = testcase.returnedFieldIDs[i];
+ if (expectedID === null) {
+ Assert.strictEqual(actual[i], expectedID,
+ "Check returned field " + i + " is null");
+ } else {
+ Assert.strictEqual(actual[i].id, expectedID,
+ "Check returned field " + i + " ID");
+ }
+ }
+ });
+ })();
+}
diff --git a/toolkit/components/passwordmgr/test/unit/test_getPasswordFields.js b/toolkit/components/passwordmgr/test/unit/test_getPasswordFields.js
new file mode 100644
index 000000000..08fa422ab
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_getPasswordFields.js
@@ -0,0 +1,156 @@
+/*
+ * Test for LoginManagerContent._getPasswordFields using LoginFormFactory.
+ */
+
+"use strict";
+
+const LMCBackstagePass = Cu.import("resource://gre/modules/LoginManagerContent.jsm");
+const { LoginManagerContent, LoginFormFactory } = LMCBackstagePass;
+const TESTCASES = [
+ {
+ description: "Empty document",
+ document: ``,
+ returnedFieldIDsByFormLike: [],
+ skipEmptyFields: undefined,
+ },
+ {
+ description: "Non-password input with no <form> present",
+ document: `<input>`,
+ // Only the IDs of password fields should be in this array
+ returnedFieldIDsByFormLike: [[]],
+ skipEmptyFields: undefined,
+ },
+ {
+ description: "1 password field outside of a <form>",
+ document: `<input id="pw1" type=password>`,
+ returnedFieldIDsByFormLike: [["pw1"]],
+ skipEmptyFields: undefined,
+ },
+ {
+ description: "4 empty password fields outside of a <form>",
+ document: `<input id="pw1" type=password>
+ <input id="pw2" type=password>
+ <input id="pw3" type=password>
+ <input id="pw4" type=password>`,
+ returnedFieldIDsByFormLike: [[]],
+ skipEmptyFields: undefined,
+ },
+ {
+ description: "4 password fields outside of a <form> (1 empty, 3 full) with skipEmpty",
+ document: `<input id="pw1" type=password>
+ <input id="pw2" type=password value="pass2">
+ <input id="pw3" type=password value="pass3">
+ <input id="pw4" type=password value="pass4">`,
+ returnedFieldIDsByFormLike: [["pw2", "pw3", "pw4"]],
+ skipEmptyFields: true,
+ },
+ {
+ description: "Form with 1 password field",
+ document: `<form><input id="pw1" type=password></form>`,
+ returnedFieldIDsByFormLike: [["pw1"]],
+ skipEmptyFields: undefined,
+ },
+ {
+ description: "Form with 2 password fields",
+ document: `<form><input id="pw1" type=password><input id='pw2' type=password></form>`,
+ returnedFieldIDsByFormLike: [["pw1", "pw2"]],
+ skipEmptyFields: undefined,
+ },
+ {
+ description: "1 password field in a form, 1 outside",
+ document: `<form><input id="pw1" type=password></form><input id="pw2" type=password>`,
+ returnedFieldIDsByFormLike: [["pw1"], ["pw2"]],
+ skipEmptyFields: undefined,
+ },
+ {
+ description: "2 password fields outside of a <form> with 1 linked via @form",
+ document: `<input id="pw1" type=password><input id="pw2" type=password form='form1'>
+ <form id="form1"></form>`,
+ returnedFieldIDsByFormLike: [["pw1"], ["pw2"]],
+ skipEmptyFields: undefined,
+ },
+ {
+ description: "2 password fields outside of a <form> with 1 linked via @form + skipEmpty",
+ document: `<input id="pw1" type=password><input id="pw2" type=password form="form1">
+ <form id="form1"></form>`,
+ returnedFieldIDsByFormLike: [[], []],
+ skipEmptyFields: true,
+ },
+ {
+ description: "skipEmptyFields should also skip white-space only fields",
+ document: `<input id="pw-space" type=password value=" ">
+ <input id="pw-tab" type=password value=" ">
+ <input id="pw-newline" type=password form="form1" value="
+">
+ <form id="form1"></form>`,
+ returnedFieldIDsByFormLike: [[], []],
+ skipEmptyFields: true,
+ },
+ {
+ description: "2 password fields outside of a <form> with 1 linked via @form + skipEmpty with 1 empty",
+ document: `<input id="pw1" type=password value=" pass1 "><input id="pw2" type=password form="form1">
+ <form id="form1"></form>`,
+ returnedFieldIDsByFormLike: [["pw1"], []],
+ skipEmptyFields: true,
+ },
+];
+
+for (let tc of TESTCASES) {
+ do_print("Sanity checking the testcase: " + tc.description);
+
+ (function() {
+ let testcase = tc;
+ add_task(function*() {
+ do_print("Starting testcase: " + testcase.description);
+ let document = MockDocument.createTestDocument("http://localhost:8080/test/",
+ testcase.document);
+
+ let mapRootElementToFormLike = new Map();
+ for (let input of document.querySelectorAll("input")) {
+ let formLike = LoginFormFactory.createFromField(input);
+ let existingFormLike = mapRootElementToFormLike.get(formLike.rootElement);
+ if (!existingFormLike) {
+ mapRootElementToFormLike.set(formLike.rootElement, formLike);
+ continue;
+ }
+
+ // If the formLike is already present, ensure that the properties are the same.
+ do_print("Checking if the new FormLike for the same root has the same properties");
+ formLikeEqual(formLike, existingFormLike);
+ }
+
+ Assert.strictEqual(mapRootElementToFormLike.size, testcase.returnedFieldIDsByFormLike.length,
+ "Check the correct number of different formLikes were returned");
+
+ let formLikeIndex = -1;
+ for (let formLikeFromInput of mapRootElementToFormLike.values()) {
+ formLikeIndex++;
+ let pwFields = LoginManagerContent._getPasswordFields(formLikeFromInput,
+ testcase.skipEmptyFields);
+
+ if (formLikeFromInput.rootElement instanceof Ci.nsIDOMHTMLFormElement) {
+ let formLikeFromForm = LoginFormFactory.createFromForm(formLikeFromInput.rootElement);
+ do_print("Checking that the FormLike created for the <form> matches" +
+ " the one from a password field");
+ formLikeEqual(formLikeFromInput, formLikeFromForm);
+ }
+
+
+ if (testcase.returnedFieldIDsByFormLike[formLikeIndex].length === 0) {
+ Assert.strictEqual(pwFields, null,
+ "If no password fields were found null should be returned");
+ } else {
+ Assert.strictEqual(pwFields.length,
+ testcase.returnedFieldIDsByFormLike[formLikeIndex].length,
+ "Check the # of password fields for formLike #" + formLikeIndex);
+ }
+
+ for (let i = 0; i < testcase.returnedFieldIDsByFormLike[formLikeIndex].length; i++) {
+ let expectedID = testcase.returnedFieldIDsByFormLike[formLikeIndex][i];
+ Assert.strictEqual(pwFields[i].element.id, expectedID,
+ "Check password field " + i + " ID");
+ }
+ }
+ });
+ })();
+}
diff --git a/toolkit/components/passwordmgr/test/unit/test_getPasswordOrigin.js b/toolkit/components/passwordmgr/test/unit/test_getPasswordOrigin.js
new file mode 100644
index 000000000..f2773ec62
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_getPasswordOrigin.js
@@ -0,0 +1,28 @@
+/*
+ * Test for LoginUtils._getPasswordOrigin
+ */
+
+"use strict";
+
+const LMCBackstagePass = Cu.import("resource://gre/modules/LoginManagerContent.jsm");
+const TESTCASES = [
+ ["javascript:void(0);", null],
+ ["javascript:void(0);", "javascript:", true],
+ ["chrome://MyAccount", null],
+ ["data:text/html,example", null],
+ ["http://username:password@example.com:80/foo?bar=baz#fragment", "http://example.com", true],
+ ["http://127.0.0.1:80/foo", "http://127.0.0.1"],
+ ["http://[::1]:80/foo", "http://[::1]"],
+ ["http://example.com:8080/foo", "http://example.com:8080"],
+ ["http://127.0.0.1:8080/foo", "http://127.0.0.1:8080", true],
+ ["http://[::1]:8080/foo", "http://[::1]:8080"],
+ ["https://example.com:443/foo", "https://example.com"],
+ ["https://[::1]:443/foo", "https://[::1]"],
+ ["https://[::1]:8443/foo", "https://[::1]:8443"],
+ ["ftp://username:password@[::1]:2121/foo", "ftp://[::1]:2121"],
+];
+
+for (let [input, expected, allowJS] of TESTCASES) {
+ let actual = LMCBackstagePass.LoginUtils._getPasswordOrigin(input, allowJS);
+ Assert.strictEqual(actual, expected, "Checking: " + input);
+}
diff --git a/toolkit/components/passwordmgr/test/unit/test_isOriginMatching.js b/toolkit/components/passwordmgr/test/unit/test_isOriginMatching.js
new file mode 100644
index 000000000..660910dff
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_isOriginMatching.js
@@ -0,0 +1,40 @@
+/*
+ * Test LoginHelper.isOriginMatching
+ */
+
+"use strict";
+
+Cu.import("resource://gre/modules/LoginHelper.jsm");
+
+add_task(function test_isOriginMatching() {
+ let testcases = [
+ // Index 0 holds the expected return value followed by arguments to isOriginMatching.
+ [true, "http://example.com", "http://example.com"],
+ [true, "http://example.com:8080", "http://example.com:8080"],
+ [true, "https://example.com", "https://example.com"],
+ [true, "https://example.com:8443", "https://example.com:8443"],
+ [false, "http://example.com", "http://mozilla.org"],
+ [false, "http://example.com", "http://example.com:8080"],
+ [false, "https://example.com", "http://example.com"],
+ [false, "https://example.com", "https://mozilla.org"],
+ [false, "http://example.com", "http://sub.example.com"],
+ [false, "https://example.com", "https://sub.example.com"],
+ [false, "http://example.com", "https://example.com:8443"],
+ [false, "http://example.com:8080", "http://example.com:8081"],
+ [false, "http://example.com", ""],
+ [false, "", "http://example.com"],
+ [true, "http://example.com", "https://example.com", { schemeUpgrades: true }],
+ [true, "https://example.com", "https://example.com", { schemeUpgrades: true }],
+ [true, "http://example.com:8080", "http://example.com:8080", { schemeUpgrades: true }],
+ [true, "https://example.com:8443", "https://example.com:8443", { schemeUpgrades: true }],
+ [false, "https://example.com", "http://example.com", { schemeUpgrades: true }], // downgrade
+ [false, "http://example.com:8080", "https://example.com", { schemeUpgrades: true }], // port mismatch
+ [false, "http://example.com", "https://example.com:8443", { schemeUpgrades: true }], // port mismatch
+ [false, "http://sub.example.com", "http://example.com", { schemeUpgrades: true }],
+ ];
+ for (let tc of testcases) {
+ let expected = tc.shift();
+ Assert.strictEqual(LoginHelper.isOriginMatching(...tc), expected,
+ "Check " + JSON.stringify(tc));
+ }
+});
diff --git a/toolkit/components/passwordmgr/test/unit/test_legacy_empty_formSubmitURL.js b/toolkit/components/passwordmgr/test/unit/test_legacy_empty_formSubmitURL.js
new file mode 100644
index 000000000..4e16aa267
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_legacy_empty_formSubmitURL.js
@@ -0,0 +1,107 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Tests the legacy case of a login store containing entries that have an empty
+ * string in the formSubmitURL field.
+ *
+ * In normal conditions, for the purpose of login autocomplete, HTML forms are
+ * identified using both the prePath of the URI on which they are located, and
+ * the prePath of the URI where the data will be submitted. This is represented
+ * by the hostname and formSubmitURL properties of the stored nsILoginInfo.
+ *
+ * When a new login for use in forms is saved (after the user replies to the
+ * password prompt), it is always stored with both the hostname and the
+ * formSubmitURL (that will be equal to the hostname when the form has no
+ * "action" attribute).
+ *
+ * When the same form is displayed again, the password is autocompleted. If
+ * there is another form on the same site that submits to a different site, it
+ * is considered a different form, so the password is not autocompleted, but a
+ * new password can be stored for the other form.
+ *
+ * However, the login database might contain data for an nsILoginInfo that has a
+ * valid hostname, but an empty formSubmitURL. This means that the login
+ * applies to all forms on the site, regardless of where they submit data to.
+ *
+ * A site can have at most one such login, and in case it is present, then it is
+ * not possible to store separate logins for forms on the same site that submit
+ * data to different sites.
+ *
+ * The only way to have such condition is to be using logins that were initially
+ * saved by a very old version of the browser, or because of data manually added
+ * by an extension in an old version.
+ */
+
+"use strict";
+
+// Tests
+
+/**
+ * Adds a login with an empty formSubmitURL, then it verifies that no other
+ * form logins can be added for the same host.
+ */
+add_task(function test_addLogin_wildcard()
+{
+ let loginInfo = TestData.formLogin({ hostname: "http://any.example.com",
+ formSubmitURL: "" });
+ Services.logins.addLogin(loginInfo);
+
+ // Normal form logins cannot be added anymore.
+ loginInfo = TestData.formLogin({ hostname: "http://any.example.com" });
+ Assert.throws(() => Services.logins.addLogin(loginInfo), /already exists/);
+
+ // Authentication logins can still be added.
+ loginInfo = TestData.authLogin({ hostname: "http://any.example.com" });
+ Services.logins.addLogin(loginInfo);
+
+ // Form logins can be added for other hosts.
+ loginInfo = TestData.formLogin({ hostname: "http://other.example.com" });
+ Services.logins.addLogin(loginInfo);
+});
+
+/**
+ * Verifies that findLogins, searchLogins, and countLogins include all logins
+ * that have an empty formSubmitURL in the store, even when a formSubmitURL is
+ * specified.
+ */
+add_task(function test_search_all_wildcard()
+{
+ // Search a given formSubmitURL on any host.
+ let matchData = newPropertyBag({ formSubmitURL: "http://www.example.com" });
+ do_check_eq(Services.logins.searchLogins({}, matchData).length, 2);
+
+ do_check_eq(Services.logins.findLogins({}, "", "http://www.example.com",
+ null).length, 2);
+
+ do_check_eq(Services.logins.countLogins("", "http://www.example.com",
+ null), 2);
+
+ // Restrict the search to one host.
+ matchData.setProperty("hostname", "http://any.example.com");
+ do_check_eq(Services.logins.searchLogins({}, matchData).length, 1);
+
+ do_check_eq(Services.logins.findLogins({}, "http://any.example.com",
+ "http://www.example.com",
+ null).length, 1);
+
+ do_check_eq(Services.logins.countLogins("http://any.example.com",
+ "http://www.example.com",
+ null), 1);
+});
+
+/**
+ * Verifies that specifying an empty string for formSubmitURL in searchLogins
+ * includes only logins that have an empty formSubmitURL in the store.
+ */
+add_task(function test_searchLogins_wildcard()
+{
+ let logins = Services.logins.searchLogins({},
+ newPropertyBag({ formSubmitURL: "" }));
+
+ let loginInfo = TestData.formLogin({ hostname: "http://any.example.com",
+ formSubmitURL: "" });
+ LoginTestUtils.assertLoginListsEqual(logins, [loginInfo]);
+});
diff --git a/toolkit/components/passwordmgr/test/unit/test_legacy_validation.js b/toolkit/components/passwordmgr/test/unit/test_legacy_validation.js
new file mode 100644
index 000000000..709bc9818
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_legacy_validation.js
@@ -0,0 +1,76 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Tests the legacy validation made when storing nsILoginInfo or disabled hosts.
+ *
+ * These rules exist because of limitations of the "signons.txt" storage file,
+ * that is not used anymore. They are still enforced by the Login Manager
+ * service, despite these values can now be safely stored in the back-end.
+ */
+
+"use strict";
+
+// Tests
+
+/**
+ * Tests legacy validation with addLogin.
+ */
+add_task(function test_addLogin_invalid_characters_legacy()
+{
+ // Test newlines and carriage returns in properties that contain URLs.
+ for (let testValue of ["http://newline\n.example.com",
+ "http://carriagereturn.example.com\r"]) {
+ let loginInfo = TestData.formLogin({ hostname: testValue });
+ Assert.throws(() => Services.logins.addLogin(loginInfo),
+ /login values can't contain newlines/);
+
+ loginInfo = TestData.formLogin({ formSubmitURL: testValue });
+ Assert.throws(() => Services.logins.addLogin(loginInfo),
+ /login values can't contain newlines/);
+
+ loginInfo = TestData.authLogin({ httpRealm: testValue });
+ Assert.throws(() => Services.logins.addLogin(loginInfo),
+ /login values can't contain newlines/);
+ }
+
+ // Test newlines and carriage returns in form field names.
+ for (let testValue of ["newline_field\n", "carriagereturn\r_field"]) {
+ let loginInfo = TestData.formLogin({ usernameField: testValue });
+ Assert.throws(() => Services.logins.addLogin(loginInfo),
+ /login values can't contain newlines/);
+
+ loginInfo = TestData.formLogin({ passwordField: testValue });
+ Assert.throws(() => Services.logins.addLogin(loginInfo),
+ /login values can't contain newlines/);
+ }
+
+ // Test a single dot as the value of usernameField and formSubmitURL.
+ let loginInfo = TestData.formLogin({ usernameField: "." });
+ Assert.throws(() => Services.logins.addLogin(loginInfo),
+ /login values can't be periods/);
+
+ loginInfo = TestData.formLogin({ formSubmitURL: "." });
+ Assert.throws(() => Services.logins.addLogin(loginInfo),
+ /login values can't be periods/);
+
+ // Test the sequence " (" inside the value of the "hostname" property.
+ loginInfo = TestData.formLogin({ hostname: "http://parens (.example.com" });
+ Assert.throws(() => Services.logins.addLogin(loginInfo),
+ /bad parens in hostname/);
+});
+
+/**
+ * Tests legacy validation with setLoginSavingEnabled.
+ */
+add_task(function test_setLoginSavingEnabled_invalid_characters_legacy()
+{
+ for (let hostname of ["http://newline\n.example.com",
+ "http://carriagereturn.example.com\r",
+ "."]) {
+ Assert.throws(() => Services.logins.setLoginSavingEnabled(hostname, false),
+ /Invalid hostname/);
+ }
+});
diff --git a/toolkit/components/passwordmgr/test/unit/test_logins_change.js b/toolkit/components/passwordmgr/test/unit/test_logins_change.js
new file mode 100644
index 000000000..79c6d2f54
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_logins_change.js
@@ -0,0 +1,384 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Tests methods that add, remove, and modify logins.
+ */
+
+"use strict";
+
+// Globals
+
+/**
+ * Verifies that the specified login is considered invalid by addLogin and by
+ * modifyLogin with both nsILoginInfo and nsIPropertyBag arguments.
+ *
+ * This test requires that the login store is empty.
+ *
+ * @param aLoginInfo
+ * nsILoginInfo corresponding to an invalid login.
+ * @param aExpectedError
+ * This argument is passed to the "Assert.throws" test to determine which
+ * error is expected from the modification functions.
+ */
+function checkLoginInvalid(aLoginInfo, aExpectedError)
+{
+ // Try to add the new login, and verify that no data is stored.
+ Assert.throws(() => Services.logins.addLogin(aLoginInfo), aExpectedError);
+ LoginTestUtils.checkLogins([]);
+
+ // Add a login for the modification tests.
+ let testLogin = TestData.formLogin({ hostname: "http://modify.example.com" });
+ Services.logins.addLogin(testLogin);
+
+ // Try to modify the existing login using nsILoginInfo and nsIPropertyBag.
+ Assert.throws(() => Services.logins.modifyLogin(testLogin, aLoginInfo),
+ aExpectedError);
+ Assert.throws(() => Services.logins.modifyLogin(testLogin, newPropertyBag({
+ hostname: aLoginInfo.hostname,
+ formSubmitURL: aLoginInfo.formSubmitURL,
+ httpRealm: aLoginInfo.httpRealm,
+ username: aLoginInfo.username,
+ password: aLoginInfo.password,
+ usernameField: aLoginInfo.usernameField,
+ passwordField: aLoginInfo.passwordField,
+ })), aExpectedError);
+
+ // Verify that no data was stored by the previous calls.
+ LoginTestUtils.checkLogins([testLogin]);
+ Services.logins.removeLogin(testLogin);
+}
+
+/**
+ * Verifies that two objects are not the same instance
+ * but have equal attributes.
+ *
+ * @param {Object} objectA
+ * An object to compare.
+ *
+ * @param {Object} objectB
+ * Another object to compare.
+ *
+ * @param {string[]} attributes
+ * Attributes to compare.
+ *
+ * @return true if all passed attributes are equal for both objects, false otherwise.
+ */
+function compareAttributes(objectA, objectB, attributes) {
+ // If it's the same object, we want to return false.
+ if (objectA == objectB) {
+ return false;
+ }
+ return attributes.every(attr => objectA[attr] == objectB[attr]);
+}
+
+// Tests
+
+/**
+ * Tests that adding logins to the database works.
+ */
+add_task(function test_addLogin_removeLogin()
+{
+ // Each login from the test data should be valid and added to the list.
+ for (let loginInfo of TestData.loginList()) {
+ Services.logins.addLogin(loginInfo);
+ }
+ LoginTestUtils.checkLogins(TestData.loginList());
+
+ // Trying to add each login again should result in an error.
+ for (let loginInfo of TestData.loginList()) {
+ Assert.throws(() => Services.logins.addLogin(loginInfo), /already exists/);
+ }
+
+ // Removing each login should succeed.
+ for (let loginInfo of TestData.loginList()) {
+ Services.logins.removeLogin(loginInfo);
+ }
+
+ LoginTestUtils.checkLogins([]);
+});
+
+/**
+ * Tests invalid combinations of httpRealm and formSubmitURL.
+ *
+ * For an nsILoginInfo to be valid for storage, one of the two properties should
+ * be strictly equal to null, and the other must not be null or an empty string.
+ *
+ * The legacy case of an empty string in formSubmitURL and a null value in
+ * httpRealm is also supported for storage at the moment.
+ */
+add_task(function test_invalid_httpRealm_formSubmitURL()
+{
+ // httpRealm === null, formSubmitURL === null
+ checkLoginInvalid(TestData.formLogin({ formSubmitURL: null }),
+ /without a httpRealm or formSubmitURL/);
+
+ // httpRealm === "", formSubmitURL === null
+ checkLoginInvalid(TestData.authLogin({ httpRealm: "" }),
+ /without a httpRealm or formSubmitURL/);
+
+ // httpRealm === null, formSubmitURL === ""
+ // This is not enforced for now.
+ // checkLoginInvalid(TestData.formLogin({ formSubmitURL: "" }),
+ // /without a httpRealm or formSubmitURL/);
+
+ // httpRealm === "", formSubmitURL === ""
+ checkLoginInvalid(TestData.formLogin({ formSubmitURL: "", httpRealm: "" }),
+ /both a httpRealm and formSubmitURL/);
+
+ // !!httpRealm, !!formSubmitURL
+ checkLoginInvalid(TestData.formLogin({ httpRealm: "The HTTP Realm" }),
+ /both a httpRealm and formSubmitURL/);
+
+ // httpRealm === "", !!formSubmitURL
+ checkLoginInvalid(TestData.formLogin({ httpRealm: "" }),
+ /both a httpRealm and formSubmitURL/);
+
+ // !!httpRealm, formSubmitURL === ""
+ checkLoginInvalid(TestData.authLogin({ formSubmitURL: "" }),
+ /both a httpRealm and formSubmitURL/);
+});
+
+/**
+ * Tests null or empty values in required login properties.
+ */
+add_task(function test_missing_properties()
+{
+ checkLoginInvalid(TestData.formLogin({ hostname: null }),
+ /null or empty hostname/);
+
+ checkLoginInvalid(TestData.formLogin({ hostname: "" }),
+ /null or empty hostname/);
+
+ checkLoginInvalid(TestData.formLogin({ username: null }),
+ /null username/);
+
+ checkLoginInvalid(TestData.formLogin({ password: null }),
+ /null or empty password/);
+
+ checkLoginInvalid(TestData.formLogin({ password: "" }),
+ /null or empty password/);
+});
+
+/**
+ * Tests invalid NUL characters in nsILoginInfo properties.
+ */
+add_task(function test_invalid_characters()
+{
+ let loginList = [
+ TestData.authLogin({ hostname: "http://null\0X.example.com" }),
+ TestData.authLogin({ httpRealm: "realm\0" }),
+ TestData.formLogin({ formSubmitURL: "http://null\0X.example.com" }),
+ TestData.formLogin({ usernameField: "field\0_null" }),
+ TestData.formLogin({ usernameField: ".\0" }), // Special single dot case
+ TestData.formLogin({ passwordField: "field\0_null" }),
+ TestData.formLogin({ username: "user\0name" }),
+ TestData.formLogin({ password: "pass\0word" }),
+ ];
+ for (let loginInfo of loginList) {
+ checkLoginInvalid(loginInfo, /login values can't contain nulls/);
+ }
+});
+
+/**
+ * Tests removing a login that does not exists.
+ */
+add_task(function test_removeLogin_nonexisting()
+{
+ Assert.throws(() => Services.logins.removeLogin(TestData.formLogin()),
+ /No matching logins/);
+});
+
+/**
+ * Tests removing all logins at once.
+ */
+add_task(function test_removeAllLogins()
+{
+ for (let loginInfo of TestData.loginList()) {
+ Services.logins.addLogin(loginInfo);
+ }
+ Services.logins.removeAllLogins();
+ LoginTestUtils.checkLogins([]);
+
+ // The function should also work when there are no logins to delete.
+ Services.logins.removeAllLogins();
+});
+
+/**
+ * Tests the modifyLogin function with an nsILoginInfo argument.
+ */
+add_task(function test_modifyLogin_nsILoginInfo()
+{
+ let loginInfo = TestData.formLogin();
+ let updatedLoginInfo = TestData.formLogin({
+ username: "new username",
+ password: "new password",
+ usernameField: "new_form_field_username",
+ passwordField: "new_form_field_password",
+ });
+ let differentLoginInfo = TestData.authLogin();
+
+ // Trying to modify a login that does not exist should throw.
+ Assert.throws(() => Services.logins.modifyLogin(loginInfo, updatedLoginInfo),
+ /No matching logins/);
+
+ // Add the first form login, then modify it to match the second.
+ Services.logins.addLogin(loginInfo);
+ Services.logins.modifyLogin(loginInfo, updatedLoginInfo);
+
+ // The data should now match the second login.
+ LoginTestUtils.checkLogins([updatedLoginInfo]);
+ Assert.throws(() => Services.logins.modifyLogin(loginInfo, updatedLoginInfo),
+ /No matching logins/);
+
+ // The login can be changed to have a different type and hostname.
+ Services.logins.modifyLogin(updatedLoginInfo, differentLoginInfo);
+ LoginTestUtils.checkLogins([differentLoginInfo]);
+
+ // It is now possible to add a login with the old type and hostname.
+ Services.logins.addLogin(loginInfo);
+ LoginTestUtils.checkLogins([loginInfo, differentLoginInfo]);
+
+ // Modifying a login to match an existing one should not be possible.
+ Assert.throws(
+ () => Services.logins.modifyLogin(loginInfo, differentLoginInfo),
+ /already exists/);
+ LoginTestUtils.checkLogins([loginInfo, differentLoginInfo]);
+
+ LoginTestUtils.clearData();
+});
+
+/**
+ * Tests the modifyLogin function with an nsIPropertyBag argument.
+ */
+add_task(function test_modifyLogin_nsIProperyBag()
+{
+ let loginInfo = TestData.formLogin();
+ let updatedLoginInfo = TestData.formLogin({
+ username: "new username",
+ password: "new password",
+ usernameField: "",
+ passwordField: "new_form_field_password",
+ });
+ let differentLoginInfo = TestData.authLogin();
+ let differentLoginProperties = newPropertyBag({
+ hostname: differentLoginInfo.hostname,
+ formSubmitURL: differentLoginInfo.formSubmitURL,
+ httpRealm: differentLoginInfo.httpRealm,
+ username: differentLoginInfo.username,
+ password: differentLoginInfo.password,
+ usernameField: differentLoginInfo.usernameField,
+ passwordField: differentLoginInfo.passwordField,
+ });
+
+ // Trying to modify a login that does not exist should throw.
+ Assert.throws(() => Services.logins.modifyLogin(loginInfo, newPropertyBag()),
+ /No matching logins/);
+
+ // Add the first form login, then modify it to match the second, changing
+ // only some of its properties and checking the behavior with an empty string.
+ Services.logins.addLogin(loginInfo);
+ Services.logins.modifyLogin(loginInfo, newPropertyBag({
+ username: "new username",
+ password: "new password",
+ usernameField: "",
+ passwordField: "new_form_field_password",
+ }));
+
+ // The data should now match the second login.
+ LoginTestUtils.checkLogins([updatedLoginInfo]);
+ Assert.throws(() => Services.logins.modifyLogin(loginInfo, newPropertyBag()),
+ /No matching logins/);
+
+ // It is also possible to provide no properties to be modified.
+ Services.logins.modifyLogin(updatedLoginInfo, newPropertyBag());
+
+ // Specifying a null property for a required value should throw.
+ Assert.throws(() => Services.logins.modifyLogin(loginInfo, newPropertyBag({
+ usernameField: null,
+ })));
+
+ // The login can be changed to have a different type and hostname.
+ Services.logins.modifyLogin(updatedLoginInfo, differentLoginProperties);
+ LoginTestUtils.checkLogins([differentLoginInfo]);
+
+ // It is now possible to add a login with the old type and hostname.
+ Services.logins.addLogin(loginInfo);
+ LoginTestUtils.checkLogins([loginInfo, differentLoginInfo]);
+
+ // Modifying a login to match an existing one should not be possible.
+ Assert.throws(
+ () => Services.logins.modifyLogin(loginInfo, differentLoginProperties),
+ /already exists/);
+ LoginTestUtils.checkLogins([loginInfo, differentLoginInfo]);
+
+ LoginTestUtils.clearData();
+});
+
+/**
+ * Tests the login deduplication function.
+ */
+add_task(function test_deduplicate_logins() {
+ // Different key attributes combinations and the amount of unique
+ // results expected for the TestData login list.
+ let keyCombinations = [
+ {
+ keyset: ["username", "password"],
+ results: 13,
+ },
+ {
+ keyset: ["hostname", "username"],
+ results: 17,
+ },
+ {
+ keyset: ["hostname", "username", "password"],
+ results: 18,
+ },
+ {
+ keyset: ["hostname", "username", "password", "formSubmitURL"],
+ results: 23,
+ },
+ ];
+
+ let logins = TestData.loginList();
+
+ for (let testCase of keyCombinations) {
+ // Deduplicate the logins using the current testcase keyset.
+ let deduped = LoginHelper.dedupeLogins(logins, testCase.keyset);
+ Assert.equal(deduped.length, testCase.results, "Correct amount of results.");
+
+ // Checks that every login after deduping is unique.
+ Assert.ok(deduped.every(loginA =>
+ deduped.every(loginB => !compareAttributes(loginA, loginB, testCase.keyset))
+ ), "Every login is unique.");
+ }
+});
+
+/**
+ * Ensure that the login deduplication function keeps the most recent login.
+ */
+add_task(function test_deduplicate_keeps_most_recent() {
+ // Logins to deduplicate.
+ let logins = [
+ TestData.formLogin({timeLastUsed: Date.UTC(2004, 11, 4, 0, 0, 0)}),
+ TestData.formLogin({formSubmitURL: "http://example.com", timeLastUsed: Date.UTC(2015, 11, 4, 0, 0, 0)}),
+ ];
+
+ // Deduplicate the logins.
+ let deduped = LoginHelper.dedupeLogins(logins);
+ Assert.equal(deduped.length, 1, "Deduplicated the logins array.");
+
+ // Verify that the remaining login have the most recent date.
+ let loginTimeLastUsed = deduped[0].QueryInterface(Ci.nsILoginMetaInfo).timeLastUsed;
+ Assert.equal(loginTimeLastUsed, Date.UTC(2015, 11, 4, 0, 0, 0), "Most recent login was kept.");
+
+ // Deduplicate the reverse logins array.
+ deduped = LoginHelper.dedupeLogins(logins.reverse());
+ Assert.equal(deduped.length, 1, "Deduplicated the reversed logins array.");
+
+ // Verify that the remaining login have the most recent date.
+ loginTimeLastUsed = deduped[0].QueryInterface(Ci.nsILoginMetaInfo).timeLastUsed;
+ Assert.equal(loginTimeLastUsed, Date.UTC(2015, 11, 4, 0, 0, 0), "Most recent login was kept.");
+});
diff --git a/toolkit/components/passwordmgr/test/unit/test_logins_decrypt_failure.js b/toolkit/components/passwordmgr/test/unit/test_logins_decrypt_failure.js
new file mode 100644
index 000000000..ffbedb4de
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_logins_decrypt_failure.js
@@ -0,0 +1,77 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Tests the case where there are logins that cannot be decrypted.
+ */
+
+"use strict";
+
+// Globals
+
+/**
+ * Resets the token used to decrypt logins. This is equivalent to resetting the
+ * master password when it is not known.
+ */
+function resetMasterPassword()
+{
+ let token = Cc["@mozilla.org/security/pk11tokendb;1"]
+ .getService(Ci.nsIPK11TokenDB).getInternalKeyToken();
+ token.reset();
+ token.changePassword("", "");
+}
+
+// Tests
+
+/**
+ * Resets the master password after some logins were added to the database.
+ */
+add_task(function test_logins_decrypt_failure()
+{
+ let logins = TestData.loginList();
+ for (let loginInfo of logins) {
+ Services.logins.addLogin(loginInfo);
+ }
+
+ // This makes the existing logins non-decryptable.
+ resetMasterPassword();
+
+ // These functions don't see the non-decryptable entries anymore.
+ do_check_eq(Services.logins.getAllLogins().length, 0);
+ do_check_eq(Services.logins.findLogins({}, "", "", "").length, 0);
+ do_check_eq(Services.logins.searchLogins({}, newPropertyBag()).length, 0);
+ Assert.throws(() => Services.logins.modifyLogin(logins[0], newPropertyBag()),
+ /No matching logins/);
+ Assert.throws(() => Services.logins.removeLogin(logins[0]),
+ /No matching logins/);
+
+ // The function that counts logins sees the non-decryptable entries also.
+ do_check_eq(Services.logins.countLogins("", "", ""), logins.length);
+
+ // Equivalent logins can be added.
+ for (let loginInfo of logins) {
+ Services.logins.addLogin(loginInfo);
+ }
+ LoginTestUtils.checkLogins(logins);
+ do_check_eq(Services.logins.countLogins("", "", ""), logins.length * 2);
+
+ // Finding logins doesn't return the non-decryptable duplicates.
+ do_check_eq(Services.logins.findLogins({}, "http://www.example.com",
+ "", "").length, 1);
+ let matchData = newPropertyBag({ hostname: "http://www.example.com" });
+ do_check_eq(Services.logins.searchLogins({}, matchData).length, 1);
+
+ // Removing single logins does not remove non-decryptable logins.
+ for (let loginInfo of TestData.loginList()) {
+ Services.logins.removeLogin(loginInfo);
+ }
+ do_check_eq(Services.logins.getAllLogins().length, 0);
+ do_check_eq(Services.logins.countLogins("", "", ""), logins.length);
+
+ // Removing all logins removes the non-decryptable entries also.
+ Services.logins.removeAllLogins();
+ do_check_eq(Services.logins.getAllLogins().length, 0);
+ do_check_eq(Services.logins.countLogins("", "", ""), 0);
+});
diff --git a/toolkit/components/passwordmgr/test/unit/test_logins_metainfo.js b/toolkit/components/passwordmgr/test/unit/test_logins_metainfo.js
new file mode 100644
index 000000000..38344aa7d
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_logins_metainfo.js
@@ -0,0 +1,284 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Tests the handling of nsILoginMetaInfo by methods that add, remove, modify,
+ * and find logins.
+ */
+
+"use strict";
+
+// Globals
+
+XPCOMUtils.defineLazyServiceGetter(this, "gUUIDGenerator",
+ "@mozilla.org/uuid-generator;1",
+ "nsIUUIDGenerator");
+
+var gLooksLikeUUIDRegex = /^\{\w{8}-\w{4}-\w{4}-\w{4}-\w{12}\}$/;
+
+/**
+ * Retrieves the only login among the current data that matches the hostname of
+ * the given nsILoginInfo. In case there is more than one login for the
+ * hostname, the test fails.
+ */
+function retrieveLoginMatching(aLoginInfo)
+{
+ let logins = Services.logins.findLogins({}, aLoginInfo.hostname, "", "");
+ do_check_eq(logins.length, 1);
+ return logins[0].QueryInterface(Ci.nsILoginMetaInfo);
+}
+
+/**
+ * Checks that the nsILoginInfo and nsILoginMetaInfo properties of two different
+ * login instances are equal.
+ */
+function assertMetaInfoEqual(aActual, aExpected)
+{
+ do_check_neq(aActual, aExpected);
+
+ // Check the nsILoginInfo properties.
+ do_check_true(aActual.equals(aExpected));
+
+ // Check the nsILoginMetaInfo properties.
+ do_check_eq(aActual.guid, aExpected.guid);
+ do_check_eq(aActual.timeCreated, aExpected.timeCreated);
+ do_check_eq(aActual.timeLastUsed, aExpected.timeLastUsed);
+ do_check_eq(aActual.timePasswordChanged, aExpected.timePasswordChanged);
+ do_check_eq(aActual.timesUsed, aExpected.timesUsed);
+}
+
+/**
+ * nsILoginInfo instances with or without nsILoginMetaInfo properties.
+ */
+var gLoginInfo1;
+var gLoginInfo2;
+var gLoginInfo3;
+
+/**
+ * nsILoginInfo instances reloaded with all the nsILoginMetaInfo properties.
+ * These are often used to provide the reference values to test against.
+ */
+var gLoginMetaInfo1;
+var gLoginMetaInfo2;
+var gLoginMetaInfo3;
+
+// Tests
+
+/**
+ * Prepare the test objects that will be used by the following tests.
+ */
+add_task(function test_initialize()
+{
+ // Use a reference time from ten minutes ago to initialize one instance of
+ // nsILoginMetaInfo, to test that reference times are updated when needed.
+ let baseTimeMs = Date.now() - 600000;
+
+ gLoginInfo1 = TestData.formLogin();
+ gLoginInfo2 = TestData.formLogin({
+ hostname: "http://other.example.com",
+ guid: gUUIDGenerator.generateUUID().toString(),
+ timeCreated: baseTimeMs,
+ timeLastUsed: baseTimeMs + 2,
+ timePasswordChanged: baseTimeMs + 1,
+ timesUsed: 2,
+ });
+ gLoginInfo3 = TestData.authLogin();
+});
+
+/**
+ * Tests the behavior of addLogin with regard to metadata. The logins added
+ * here are also used by the following tests.
+ */
+add_task(function test_addLogin_metainfo()
+{
+ // Add a login without metadata to the database.
+ Services.logins.addLogin(gLoginInfo1);
+
+ // The object provided to addLogin should not have been modified.
+ do_check_eq(gLoginInfo1.guid, null);
+ do_check_eq(gLoginInfo1.timeCreated, 0);
+ do_check_eq(gLoginInfo1.timeLastUsed, 0);
+ do_check_eq(gLoginInfo1.timePasswordChanged, 0);
+ do_check_eq(gLoginInfo1.timesUsed, 0);
+
+ // A login with valid metadata should have been stored.
+ gLoginMetaInfo1 = retrieveLoginMatching(gLoginInfo1);
+ do_check_true(gLooksLikeUUIDRegex.test(gLoginMetaInfo1.guid));
+ let creationTime = gLoginMetaInfo1.timeCreated;
+ LoginTestUtils.assertTimeIsAboutNow(creationTime);
+ do_check_eq(gLoginMetaInfo1.timeLastUsed, creationTime);
+ do_check_eq(gLoginMetaInfo1.timePasswordChanged, creationTime);
+ do_check_eq(gLoginMetaInfo1.timesUsed, 1);
+
+ // Add a login without metadata to the database.
+ let originalLogin = gLoginInfo2.clone().QueryInterface(Ci.nsILoginMetaInfo);
+ Services.logins.addLogin(gLoginInfo2);
+
+ // The object provided to addLogin should not have been modified.
+ assertMetaInfoEqual(gLoginInfo2, originalLogin);
+
+ // A login with the provided metadata should have been stored.
+ gLoginMetaInfo2 = retrieveLoginMatching(gLoginInfo2);
+ assertMetaInfoEqual(gLoginMetaInfo2, gLoginInfo2);
+
+ // Add an authentication login to the database before continuing.
+ Services.logins.addLogin(gLoginInfo3);
+ gLoginMetaInfo3 = retrieveLoginMatching(gLoginInfo3);
+ LoginTestUtils.checkLogins([gLoginInfo1, gLoginInfo2, gLoginInfo3]);
+});
+
+/**
+ * Tests that adding a login with a duplicate GUID throws an exception.
+ */
+add_task(function test_addLogin_metainfo_duplicate()
+{
+ let loginInfo = TestData.formLogin({
+ hostname: "http://duplicate.example.com",
+ guid: gLoginMetaInfo2.guid,
+ });
+ Assert.throws(() => Services.logins.addLogin(loginInfo),
+ /specified GUID already exists/);
+
+ // Verify that no data was stored by the previous call.
+ LoginTestUtils.checkLogins([gLoginInfo1, gLoginInfo2, gLoginInfo3]);
+});
+
+/**
+ * Tests that the existing metadata is not changed when modifyLogin is called
+ * with an nsILoginInfo argument.
+ */
+add_task(function test_modifyLogin_nsILoginInfo_metainfo_ignored()
+{
+ let newLoginInfo = gLoginInfo1.clone().QueryInterface(Ci.nsILoginMetaInfo);
+ newLoginInfo.guid = gUUIDGenerator.generateUUID().toString();
+ newLoginInfo.timeCreated = Date.now();
+ newLoginInfo.timeLastUsed = Date.now();
+ newLoginInfo.timePasswordChanged = Date.now();
+ newLoginInfo.timesUsed = 12;
+ Services.logins.modifyLogin(gLoginInfo1, newLoginInfo);
+
+ newLoginInfo = retrieveLoginMatching(gLoginInfo1);
+ assertMetaInfoEqual(newLoginInfo, gLoginMetaInfo1);
+});
+
+/**
+ * Tests the modifyLogin function with an nsIProperyBag argument.
+ */
+add_task(function test_modifyLogin_nsIProperyBag_metainfo()
+{
+ // Use a new reference time that is two minutes from now.
+ let newTimeMs = Date.now() + 120000;
+ let newUUIDValue = gUUIDGenerator.generateUUID().toString();
+
+ // Check that properties are changed as requested.
+ Services.logins.modifyLogin(gLoginInfo1, newPropertyBag({
+ guid: newUUIDValue,
+ timeCreated: newTimeMs,
+ timeLastUsed: newTimeMs + 2,
+ timePasswordChanged: newTimeMs + 1,
+ timesUsed: 2,
+ }));
+
+ gLoginMetaInfo1 = retrieveLoginMatching(gLoginInfo1);
+ do_check_eq(gLoginMetaInfo1.guid, newUUIDValue);
+ do_check_eq(gLoginMetaInfo1.timeCreated, newTimeMs);
+ do_check_eq(gLoginMetaInfo1.timeLastUsed, newTimeMs + 2);
+ do_check_eq(gLoginMetaInfo1.timePasswordChanged, newTimeMs + 1);
+ do_check_eq(gLoginMetaInfo1.timesUsed, 2);
+
+ // Check that timePasswordChanged is updated when changing the password.
+ let originalLogin = gLoginInfo2.clone().QueryInterface(Ci.nsILoginMetaInfo);
+ Services.logins.modifyLogin(gLoginInfo2, newPropertyBag({
+ password: "new password",
+ }));
+ gLoginInfo2.password = "new password";
+
+ gLoginMetaInfo2 = retrieveLoginMatching(gLoginInfo2);
+ do_check_eq(gLoginMetaInfo2.password, gLoginInfo2.password);
+ do_check_eq(gLoginMetaInfo2.timeCreated, originalLogin.timeCreated);
+ do_check_eq(gLoginMetaInfo2.timeLastUsed, originalLogin.timeLastUsed);
+ LoginTestUtils.assertTimeIsAboutNow(gLoginMetaInfo2.timePasswordChanged);
+
+ // Check that timePasswordChanged is not set to the current time when changing
+ // the password and specifying a new value for the property at the same time.
+ Services.logins.modifyLogin(gLoginInfo2, newPropertyBag({
+ password: "other password",
+ timePasswordChanged: newTimeMs,
+ }));
+ gLoginInfo2.password = "other password";
+
+ gLoginMetaInfo2 = retrieveLoginMatching(gLoginInfo2);
+ do_check_eq(gLoginMetaInfo2.password, gLoginInfo2.password);
+ do_check_eq(gLoginMetaInfo2.timeCreated, originalLogin.timeCreated);
+ do_check_eq(gLoginMetaInfo2.timeLastUsed, originalLogin.timeLastUsed);
+ do_check_eq(gLoginMetaInfo2.timePasswordChanged, newTimeMs);
+
+ // Check the special timesUsedIncrement property.
+ Services.logins.modifyLogin(gLoginInfo2, newPropertyBag({
+ timesUsedIncrement: 2,
+ }));
+
+ gLoginMetaInfo2 = retrieveLoginMatching(gLoginInfo2);
+ do_check_eq(gLoginMetaInfo2.timeCreated, originalLogin.timeCreated);
+ do_check_eq(gLoginMetaInfo2.timeLastUsed, originalLogin.timeLastUsed);
+ do_check_eq(gLoginMetaInfo2.timePasswordChanged, newTimeMs);
+ do_check_eq(gLoginMetaInfo2.timesUsed, 4);
+});
+
+/**
+ * Tests that modifying a login to a duplicate GUID throws an exception.
+ */
+add_task(function test_modifyLogin_nsIProperyBag_metainfo_duplicate()
+{
+ Assert.throws(() => Services.logins.modifyLogin(gLoginInfo1, newPropertyBag({
+ guid: gLoginInfo2.guid,
+ })), /specified GUID already exists/);
+ LoginTestUtils.checkLogins([gLoginInfo1, gLoginInfo2, gLoginInfo3]);
+});
+
+/**
+ * Tests searching logins using nsILoginMetaInfo properties.
+ */
+add_task(function test_searchLogins_metainfo()
+{
+ // Find by GUID.
+ let logins = Services.logins.searchLogins({}, newPropertyBag({
+ guid: gLoginMetaInfo1.guid,
+ }));
+ do_check_eq(logins.length, 1);
+ let foundLogin = logins[0].QueryInterface(Ci.nsILoginMetaInfo);
+ assertMetaInfoEqual(foundLogin, gLoginMetaInfo1);
+
+ // Find by timestamp.
+ logins = Services.logins.searchLogins({}, newPropertyBag({
+ timePasswordChanged: gLoginMetaInfo2.timePasswordChanged,
+ }));
+ do_check_eq(logins.length, 1);
+ foundLogin = logins[0].QueryInterface(Ci.nsILoginMetaInfo);
+ assertMetaInfoEqual(foundLogin, gLoginMetaInfo2);
+
+ // Find using two properties at the same time.
+ logins = Services.logins.searchLogins({}, newPropertyBag({
+ guid: gLoginMetaInfo3.guid,
+ timePasswordChanged: gLoginMetaInfo3.timePasswordChanged,
+ }));
+ do_check_eq(logins.length, 1);
+ foundLogin = logins[0].QueryInterface(Ci.nsILoginMetaInfo);
+ assertMetaInfoEqual(foundLogin, gLoginMetaInfo3);
+});
+
+/**
+ * Tests that the default nsILoginManagerStorage module attached to the Login
+ * Manager service is able to save and reload nsILoginMetaInfo properties.
+ */
+add_task(function* test_storage_metainfo()
+{
+ yield* LoginTestUtils.reloadData();
+ LoginTestUtils.checkLogins([gLoginInfo1, gLoginInfo2, gLoginInfo3]);
+
+ assertMetaInfoEqual(retrieveLoginMatching(gLoginInfo1), gLoginMetaInfo1);
+ assertMetaInfoEqual(retrieveLoginMatching(gLoginInfo2), gLoginMetaInfo2);
+ assertMetaInfoEqual(retrieveLoginMatching(gLoginInfo3), gLoginMetaInfo3);
+});
diff --git a/toolkit/components/passwordmgr/test/unit/test_logins_search.js b/toolkit/components/passwordmgr/test/unit/test_logins_search.js
new file mode 100644
index 000000000..188c75039
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_logins_search.js
@@ -0,0 +1,221 @@
+/*
+ * Tests methods that find specific logins in the store (findLogins,
+ * searchLogins, and countLogins).
+ *
+ * The getAllLogins method is not tested explicitly here, because it is used by
+ * all tests to verify additions, removals and modifications to the login store.
+ */
+
+"use strict";
+
+// Globals
+
+/**
+ * Returns a list of new nsILoginInfo objects that are a subset of the test
+ * data, built to match the specified query.
+ *
+ * @param aQuery
+ * Each property and value of this object restricts the search to those
+ * entries from the test data that match the property exactly.
+ */
+function buildExpectedLogins(aQuery)
+{
+ return TestData.loginList().filter(
+ entry => Object.keys(aQuery).every(name => entry[name] === aQuery[name]));
+}
+
+/**
+ * Tests the searchLogins function.
+ *
+ * @param aQuery
+ * Each property and value of this object is translated to an entry in
+ * the nsIPropertyBag parameter of searchLogins.
+ * @param aExpectedCount
+ * Number of logins from the test data that should be found. The actual
+ * list of logins is obtained using the buildExpectedLogins helper, and
+ * this value is just used to verify that modifications to the test data
+ * don't make the current test meaningless.
+ */
+function checkSearchLogins(aQuery, aExpectedCount)
+{
+ do_print("Testing searchLogins for " + JSON.stringify(aQuery));
+
+ let expectedLogins = buildExpectedLogins(aQuery);
+ do_check_eq(expectedLogins.length, aExpectedCount);
+
+ let outCount = {};
+ let logins = Services.logins.searchLogins(outCount, newPropertyBag(aQuery));
+ do_check_eq(outCount.value, expectedLogins.length);
+ LoginTestUtils.assertLoginListsEqual(logins, expectedLogins);
+}
+
+/**
+ * Tests findLogins, searchLogins, and countLogins with the same query.
+ *
+ * @param aQuery
+ * The "hostname", "formSubmitURL", and "httpRealm" properties of this
+ * object are passed as parameters to findLogins and countLogins. The
+ * same object is then passed to the checkSearchLogins function.
+ * @param aExpectedCount
+ * Number of logins from the test data that should be found. The actual
+ * list of logins is obtained using the buildExpectedLogins helper, and
+ * this value is just used to verify that modifications to the test data
+ * don't make the current test meaningless.
+ */
+function checkAllSearches(aQuery, aExpectedCount)
+{
+ do_print("Testing all search functions for " + JSON.stringify(aQuery));
+
+ let expectedLogins = buildExpectedLogins(aQuery);
+ do_check_eq(expectedLogins.length, aExpectedCount);
+
+ // The findLogins and countLogins functions support wildcard matches by
+ // specifying empty strings as parameters, while searchLogins requires
+ // omitting the property entirely.
+ let hostname = ("hostname" in aQuery) ? aQuery.hostname : "";
+ let formSubmitURL = ("formSubmitURL" in aQuery) ? aQuery.formSubmitURL : "";
+ let httpRealm = ("httpRealm" in aQuery) ? aQuery.httpRealm : "";
+
+ // Test findLogins.
+ let outCount = {};
+ let logins = Services.logins.findLogins(outCount, hostname, formSubmitURL,
+ httpRealm);
+ do_check_eq(outCount.value, expectedLogins.length);
+ LoginTestUtils.assertLoginListsEqual(logins, expectedLogins);
+
+ // Test countLogins.
+ let count = Services.logins.countLogins(hostname, formSubmitURL, httpRealm);
+ do_check_eq(count, expectedLogins.length);
+
+ // Test searchLogins.
+ checkSearchLogins(aQuery, aExpectedCount);
+}
+
+// Tests
+
+/**
+ * Prepare data for the following tests.
+ */
+add_task(function test_initialize()
+{
+ for (let login of TestData.loginList()) {
+ Services.logins.addLogin(login);
+ }
+});
+
+/**
+ * Tests findLogins, searchLogins, and countLogins with basic queries.
+ */
+add_task(function test_search_all_basic()
+{
+ // Find all logins, using no filters in the search functions.
+ checkAllSearches({}, 23);
+
+ // Find all form logins, then all authentication logins.
+ checkAllSearches({ httpRealm: null }, 14);
+ checkAllSearches({ formSubmitURL: null }, 9);
+
+ // Find all form logins on one host, then all authentication logins.
+ checkAllSearches({ hostname: "http://www4.example.com",
+ httpRealm: null }, 3);
+ checkAllSearches({ hostname: "http://www2.example.org",
+ formSubmitURL: null }, 2);
+
+ // Verify that scheme and subdomain are distinct in the hostname.
+ checkAllSearches({ hostname: "http://www.example.com" }, 1);
+ checkAllSearches({ hostname: "https://www.example.com" }, 1);
+ checkAllSearches({ hostname: "https://example.com" }, 1);
+ checkAllSearches({ hostname: "http://www3.example.com" }, 3);
+
+ // Verify that scheme and subdomain are distinct in formSubmitURL.
+ checkAllSearches({ formSubmitURL: "http://www.example.com" }, 2);
+ checkAllSearches({ formSubmitURL: "https://www.example.com" }, 2);
+ checkAllSearches({ formSubmitURL: "http://example.com" }, 1);
+
+ // Find by formSubmitURL on a single host.
+ checkAllSearches({ hostname: "http://www3.example.com",
+ formSubmitURL: "http://www.example.com" }, 1);
+ checkAllSearches({ hostname: "http://www3.example.com",
+ formSubmitURL: "https://www.example.com" }, 1);
+ checkAllSearches({ hostname: "http://www3.example.com",
+ formSubmitURL: "http://example.com" }, 1);
+
+ // Find by httpRealm on all hosts.
+ checkAllSearches({ httpRealm: "The HTTP Realm" }, 3);
+ checkAllSearches({ httpRealm: "ftp://ftp.example.org" }, 1);
+ checkAllSearches({ httpRealm: "The HTTP Realm Other" }, 2);
+
+ // Find by httpRealm on a single host.
+ checkAllSearches({ hostname: "http://example.net",
+ httpRealm: "The HTTP Realm" }, 1);
+ checkAllSearches({ hostname: "http://example.net",
+ httpRealm: "The HTTP Realm Other" }, 1);
+ checkAllSearches({ hostname: "ftp://example.net",
+ httpRealm: "ftp://example.net" }, 1);
+});
+
+/**
+ * Tests searchLogins with advanced queries.
+ */
+add_task(function test_searchLogins()
+{
+ checkSearchLogins({ usernameField: "form_field_username" }, 12);
+ checkSearchLogins({ passwordField: "form_field_password" }, 13);
+
+ // Find all logins with an empty usernameField, including for authentication.
+ checkSearchLogins({ usernameField: "" }, 11);
+
+ // Find form logins with an empty usernameField.
+ checkSearchLogins({ httpRealm: null,
+ usernameField: "" }, 2);
+
+ // Find logins with an empty usernameField on one host.
+ checkSearchLogins({ hostname: "http://www6.example.com",
+ usernameField: "" }, 1);
+});
+
+/**
+ * Tests searchLogins with invalid arguments.
+ */
+add_task(function test_searchLogins_invalid()
+{
+ Assert.throws(() => Services.logins.searchLogins({},
+ newPropertyBag({ username: "value" })),
+ /Unexpected field/);
+});
+
+/**
+ * Tests that matches are case-sensitive, compare the full field value, and are
+ * strict when interpreting the prePath of URIs.
+ */
+add_task(function test_search_all_full_case_sensitive()
+{
+ checkAllSearches({ hostname: "http://www.example.com" }, 1);
+ checkAllSearches({ hostname: "http://www.example.com/" }, 0);
+ checkAllSearches({ hostname: "http://" }, 0);
+ checkAllSearches({ hostname: "example.com" }, 0);
+
+ checkAllSearches({ formSubmitURL: "http://www.example.com" }, 2);
+ checkAllSearches({ formSubmitURL: "http://www.example.com/" }, 0);
+ checkAllSearches({ formSubmitURL: "http://" }, 0);
+ checkAllSearches({ formSubmitURL: "example.com" }, 0);
+
+ checkAllSearches({ httpRealm: "The HTTP Realm" }, 3);
+ checkAllSearches({ httpRealm: "The http Realm" }, 0);
+ checkAllSearches({ httpRealm: "The HTTP" }, 0);
+ checkAllSearches({ httpRealm: "Realm" }, 0);
+});
+
+/**
+ * Tests findLogins, searchLogins, and countLogins with queries that should
+ * return no values.
+ */
+add_task(function test_search_all_empty()
+{
+ checkAllSearches({ hostname: "http://nonexistent.example.com" }, 0);
+ checkAllSearches({ formSubmitURL: "http://www.example.com",
+ httpRealm: "The HTTP Realm" }, 0);
+
+ checkSearchLogins({ hostname: "" }, 0);
+ checkSearchLogins({ id: "1000" }, 0);
+});
diff --git a/toolkit/components/passwordmgr/test/unit/test_maybeImportLogin.js b/toolkit/components/passwordmgr/test/unit/test_maybeImportLogin.js
new file mode 100644
index 000000000..19175df59
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_maybeImportLogin.js
@@ -0,0 +1,169 @@
+"use strict";
+
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/LoginHelper.jsm");
+
+const HOST1 = "https://www.example.com/";
+const HOST2 = "https://www.mozilla.org/";
+
+const USER1 = "myuser";
+const USER2 = "anotheruser";
+
+const PASS1 = "mypass";
+const PASS2 = "anotherpass";
+const PASS3 = "yetanotherpass";
+
+add_task(function test_new_logins() {
+ let importedLogin = LoginHelper.maybeImportLogin({
+ username: USER1,
+ password: PASS1,
+ hostname: HOST1,
+ formSubmitURL: HOST1,
+ });
+ Assert.ok(importedLogin, "Return value should indicate imported login.");
+ let matchingLogins = LoginHelper.searchLoginsWithObject({hostname: HOST1});
+ Assert.equal(matchingLogins.length, 1, `There should be 1 login for ${HOST1}`);
+
+ importedLogin = LoginHelper.maybeImportLogin({
+ username: USER1,
+ password: PASS1,
+ hostname: HOST2,
+ formSubmitURL: HOST2,
+ });
+
+ Assert.ok(importedLogin, "Return value should indicate another imported login.");
+ matchingLogins = LoginHelper.searchLoginsWithObject({hostname: HOST1});
+ Assert.equal(matchingLogins.length, 1, `There should still be 1 login for ${HOST1}`);
+
+ matchingLogins = LoginHelper.searchLoginsWithObject({hostname: HOST2});
+ Assert.equal(matchingLogins.length, 1, `There should also be 1 login for ${HOST2}`);
+ Assert.equal(Services.logins.getAllLogins().length, 2, "There should be 2 logins in total");
+ Services.logins.removeAllLogins();
+});
+
+add_task(function test_duplicate_logins() {
+ let importedLogin = LoginHelper.maybeImportLogin({
+ username: USER1,
+ password: PASS1,
+ hostname: HOST1,
+ formSubmitURL: HOST1,
+ });
+ Assert.ok(importedLogin, "Return value should indicate imported login.");
+ let matchingLogins = LoginHelper.searchLoginsWithObject({hostname: HOST1});
+ Assert.equal(matchingLogins.length, 1, `There should be 1 login for ${HOST1}`);
+
+ importedLogin = LoginHelper.maybeImportLogin({
+ username: USER1,
+ password: PASS1,
+ hostname: HOST1,
+ formSubmitURL: HOST1,
+ });
+ Assert.ok(!importedLogin, "Return value should indicate no new login was imported.");
+ matchingLogins = LoginHelper.searchLoginsWithObject({hostname: HOST1});
+ Assert.equal(matchingLogins.length, 1, `There should still be 1 login for ${HOST1}`);
+ Services.logins.removeAllLogins();
+});
+
+add_task(function test_different_passwords() {
+ let importedLogin = LoginHelper.maybeImportLogin({
+ username: USER1,
+ password: PASS1,
+ hostname: HOST1,
+ formSubmitURL: HOST1,
+ timeCreated: new Date(Date.now() - 1000),
+ });
+ Assert.ok(importedLogin, "Return value should indicate imported login.");
+ let matchingLogins = LoginHelper.searchLoginsWithObject({hostname: HOST1});
+ Assert.equal(matchingLogins.length, 1, `There should be 1 login for ${HOST1}`);
+
+ // This item will be newer, so its password should take precedence.
+ importedLogin = LoginHelper.maybeImportLogin({
+ username: USER1,
+ password: PASS2,
+ hostname: HOST1,
+ formSubmitURL: HOST1,
+ timeCreated: new Date(),
+ });
+ Assert.ok(!importedLogin, "Return value should not indicate imported login (as we updated an existing one).");
+ matchingLogins = LoginHelper.searchLoginsWithObject({hostname: HOST1});
+ Assert.equal(matchingLogins.length, 1, `There should still be 1 login for ${HOST1}`);
+ Assert.equal(matchingLogins[0].password, PASS2, "We should have updated the password for this login.");
+
+ // Now try to update with an older password:
+ importedLogin = LoginHelper.maybeImportLogin({
+ username: USER1,
+ password: PASS3,
+ hostname: HOST1,
+ formSubmitURL: HOST1,
+ timeCreated: new Date(Date.now() - 1000000),
+ });
+ Assert.ok(!importedLogin, "Return value should not indicate imported login (as we didn't update anything).");
+ matchingLogins = LoginHelper.searchLoginsWithObject({hostname: HOST1});
+ Assert.equal(matchingLogins.length, 1, `There should still be 1 login for ${HOST1}`);
+ Assert.equal(matchingLogins[0].password, PASS2, "We should NOT have updated the password for this login.");
+
+ Services.logins.removeAllLogins();
+});
+
+add_task(function test_different_usernames() {
+ let importedLogin = LoginHelper.maybeImportLogin({
+ username: USER1,
+ password: PASS1,
+ hostname: HOST1,
+ formSubmitURL: HOST1,
+ });
+ Assert.ok(importedLogin, "Return value should indicate imported login.");
+ let matchingLogins = LoginHelper.searchLoginsWithObject({hostname: HOST1});
+ Assert.equal(matchingLogins.length, 1, `There should be 1 login for ${HOST1}`);
+
+ importedLogin = LoginHelper.maybeImportLogin({
+ username: USER2,
+ password: PASS1,
+ hostname: HOST1,
+ formSubmitURL: HOST1,
+ });
+ Assert.ok(importedLogin, "Return value should indicate another imported login.");
+ matchingLogins = LoginHelper.searchLoginsWithObject({hostname: HOST1});
+ Assert.equal(matchingLogins.length, 2, `There should now be 2 logins for ${HOST1}`);
+
+ Services.logins.removeAllLogins();
+});
+
+add_task(function test_different_targets() {
+ let importedLogin = LoginHelper.maybeImportLogin({
+ username: USER1,
+ password: PASS1,
+ hostname: HOST1,
+ formSubmitURL: HOST1,
+ });
+ Assert.ok(importedLogin, "Return value should indicate imported login.");
+ let matchingLogins = LoginHelper.searchLoginsWithObject({hostname: HOST1});
+ Assert.equal(matchingLogins.length, 1, `There should be 1 login for ${HOST1}`);
+
+ // Not passing either a formSubmitURL or a httpRealm should be treated as
+ // the same as the previous login
+ importedLogin = LoginHelper.maybeImportLogin({
+ username: USER1,
+ password: PASS1,
+ hostname: HOST1,
+ });
+ Assert.ok(!importedLogin, "Return value should NOT indicate imported login " +
+ "(because a missing formSubmitURL and httpRealm should be duped to the existing login).");
+ matchingLogins = LoginHelper.searchLoginsWithObject({hostname: HOST1});
+ Assert.equal(matchingLogins.length, 1, `There should still be 1 login for ${HOST1}`);
+ Assert.equal(matchingLogins[0].formSubmitURL, HOST1, "The form submission URL should have been kept.");
+
+ importedLogin = LoginHelper.maybeImportLogin({
+ username: USER1,
+ password: PASS1,
+ hostname: HOST1,
+ httpRealm: HOST1,
+ });
+ Assert.ok(importedLogin, "Return value should indicate another imported login " +
+ "as an httpRealm login shouldn't be duped.");
+ matchingLogins = LoginHelper.searchLoginsWithObject({hostname: HOST1});
+ Assert.equal(matchingLogins.length, 2, `There should now be 2 logins for ${HOST1}`);
+
+ Services.logins.removeAllLogins();
+});
+
diff --git a/toolkit/components/passwordmgr/test/unit/test_module_LoginImport.js b/toolkit/components/passwordmgr/test/unit/test_module_LoginImport.js
new file mode 100644
index 000000000..b8793e1bd
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_module_LoginImport.js
@@ -0,0 +1,243 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Tests the LoginImport object.
+ */
+
+"use strict";
+
+// Globals
+
+Cu.import("resource://gre/modules/Task.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "LoginHelper",
+ "resource://gre/modules/LoginHelper.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "LoginImport",
+ "resource://gre/modules/LoginImport.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "LoginStore",
+ "resource://gre/modules/LoginStore.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Sqlite",
+ "resource://gre/modules/Sqlite.jsm");
+
+XPCOMUtils.defineLazyServiceGetter(this, "gLoginManagerCrypto",
+ "@mozilla.org/login-manager/crypto/SDR;1",
+ "nsILoginManagerCrypto");
+XPCOMUtils.defineLazyServiceGetter(this, "gUUIDGenerator",
+ "@mozilla.org/uuid-generator;1",
+ "nsIUUIDGenerator");
+
+/**
+ * Creates empty login data tables in the given SQLite connection, resembling
+ * the most recent schema version (excluding indices).
+ */
+function promiseCreateDatabaseSchema(aConnection)
+{
+ return Task.spawn(function* () {
+ yield aConnection.setSchemaVersion(5);
+ yield aConnection.execute("CREATE TABLE moz_logins (" +
+ "id INTEGER PRIMARY KEY," +
+ "hostname TEXT NOT NULL," +
+ "httpRealm TEXT," +
+ "formSubmitURL TEXT," +
+ "usernameField TEXT NOT NULL," +
+ "passwordField TEXT NOT NULL," +
+ "encryptedUsername TEXT NOT NULL," +
+ "encryptedPassword TEXT NOT NULL," +
+ "guid TEXT," +
+ "encType INTEGER," +
+ "timeCreated INTEGER," +
+ "timeLastUsed INTEGER," +
+ "timePasswordChanged INTEGER," +
+ "timesUsed INTEGER)");
+ yield aConnection.execute("CREATE TABLE moz_disabledHosts (" +
+ "id INTEGER PRIMARY KEY," +
+ "hostname TEXT UNIQUE)");
+ yield aConnection.execute("CREATE TABLE moz_deleted_logins (" +
+ "id INTEGER PRIMARY KEY," +
+ "guid TEXT," +
+ "timeDeleted INTEGER)");
+ });
+}
+
+/**
+ * Inserts a new entry in the database resembling the given nsILoginInfo object.
+ */
+function promiseInsertLoginInfo(aConnection, aLoginInfo)
+{
+ aLoginInfo.QueryInterface(Ci.nsILoginMetaInfo);
+
+ // We can't use the aLoginInfo object directly in the execute statement
+ // because the bind code in Sqlite.jsm doesn't allow objects with extra
+ // properties beyond those being binded. So we might as well use an array as
+ // it is simpler.
+ let values = [
+ aLoginInfo.hostname,
+ aLoginInfo.httpRealm,
+ aLoginInfo.formSubmitURL,
+ aLoginInfo.usernameField,
+ aLoginInfo.passwordField,
+ gLoginManagerCrypto.encrypt(aLoginInfo.username),
+ gLoginManagerCrypto.encrypt(aLoginInfo.password),
+ aLoginInfo.guid,
+ aLoginInfo.encType,
+ aLoginInfo.timeCreated,
+ aLoginInfo.timeLastUsed,
+ aLoginInfo.timePasswordChanged,
+ aLoginInfo.timesUsed,
+ ];
+
+ return aConnection.execute("INSERT INTO moz_logins (hostname, " +
+ "httpRealm, formSubmitURL, usernameField, " +
+ "passwordField, encryptedUsername, " +
+ "encryptedPassword, guid, encType, timeCreated, " +
+ "timeLastUsed, timePasswordChanged, timesUsed) " +
+ "VALUES (?" + ",?".repeat(12) + ")", values);
+}
+
+/**
+ * Inserts a new disabled host entry in the database.
+ */
+function promiseInsertDisabledHost(aConnection, aHostname)
+{
+ return aConnection.execute("INSERT INTO moz_disabledHosts (hostname) " +
+ "VALUES (?)", [aHostname]);
+}
+
+// Tests
+
+/**
+ * Imports login data from a SQLite file constructed using the test data.
+ */
+add_task(function* test_import()
+{
+ let store = new LoginStore(getTempFile("test-import.json").path);
+ let loginsSqlite = getTempFile("test-logins.sqlite").path;
+
+ // Prepare the logins to be imported, including the nsILoginMetaInfo data.
+ let loginList = TestData.loginList();
+ for (let loginInfo of loginList) {
+ loginInfo.QueryInterface(Ci.nsILoginMetaInfo);
+ loginInfo.guid = gUUIDGenerator.generateUUID().toString();
+ loginInfo.timeCreated = Date.now();
+ loginInfo.timeLastUsed = Date.now();
+ loginInfo.timePasswordChanged = Date.now();
+ loginInfo.timesUsed = 1;
+ }
+
+ // Create and populate the SQLite database first.
+ let connection = yield Sqlite.openConnection({ path: loginsSqlite });
+ try {
+ yield promiseCreateDatabaseSchema(connection);
+ for (let loginInfo of loginList) {
+ yield promiseInsertLoginInfo(connection, loginInfo);
+ }
+ yield promiseInsertDisabledHost(connection, "http://www.example.com");
+ yield promiseInsertDisabledHost(connection, "https://www.example.org");
+ } finally {
+ yield connection.close();
+ }
+
+ // The "load" method must be called before importing data.
+ yield store.load();
+ yield new LoginImport(store, loginsSqlite).import();
+
+ // Verify that every login in the test data has a matching imported row.
+ do_check_eq(loginList.length, store.data.logins.length);
+ do_check_true(loginList.every(function (loginInfo) {
+ return store.data.logins.some(function (loginDataItem) {
+ let username = gLoginManagerCrypto.decrypt(loginDataItem.encryptedUsername);
+ let password = gLoginManagerCrypto.decrypt(loginDataItem.encryptedPassword);
+ return loginDataItem.hostname == loginInfo.hostname &&
+ loginDataItem.httpRealm == loginInfo.httpRealm &&
+ loginDataItem.formSubmitURL == loginInfo.formSubmitURL &&
+ loginDataItem.usernameField == loginInfo.usernameField &&
+ loginDataItem.passwordField == loginInfo.passwordField &&
+ username == loginInfo.username &&
+ password == loginInfo.password &&
+ loginDataItem.guid == loginInfo.guid &&
+ loginDataItem.encType == loginInfo.encType &&
+ loginDataItem.timeCreated == loginInfo.timeCreated &&
+ loginDataItem.timeLastUsed == loginInfo.timeLastUsed &&
+ loginDataItem.timePasswordChanged == loginInfo.timePasswordChanged &&
+ loginDataItem.timesUsed == loginInfo.timesUsed;
+ });
+ }));
+
+ // Verify that disabled hosts have been imported.
+ do_check_eq(store.data.disabledHosts.length, 2);
+ do_check_true(store.data.disabledHosts.indexOf("http://www.example.com") != -1);
+ do_check_true(store.data.disabledHosts.indexOf("https://www.example.org") != -1);
+});
+
+/**
+ * Tests imports of NULL values due to a downgraded database.
+ */
+add_task(function* test_import_downgraded()
+{
+ let store = new LoginStore(getTempFile("test-import-downgraded.json").path);
+ let loginsSqlite = getTempFile("test-logins-downgraded.sqlite").path;
+
+ // Create and populate the SQLite database first.
+ let connection = yield Sqlite.openConnection({ path: loginsSqlite });
+ try {
+ yield promiseCreateDatabaseSchema(connection);
+ yield connection.setSchemaVersion(3);
+ yield promiseInsertLoginInfo(connection, TestData.formLogin({
+ guid: gUUIDGenerator.generateUUID().toString(),
+ timeCreated: null,
+ timeLastUsed: null,
+ timePasswordChanged: null,
+ timesUsed: 0,
+ }));
+ } finally {
+ yield connection.close();
+ }
+
+ // The "load" method must be called before importing data.
+ yield store.load();
+ yield new LoginImport(store, loginsSqlite).import();
+
+ // Verify that the missing metadata was generated correctly.
+ let loginItem = store.data.logins[0];
+ let creationTime = loginItem.timeCreated;
+ LoginTestUtils.assertTimeIsAboutNow(creationTime);
+ do_check_eq(loginItem.timeLastUsed, creationTime);
+ do_check_eq(loginItem.timePasswordChanged, creationTime);
+ do_check_eq(loginItem.timesUsed, 1);
+});
+
+/**
+ * Verifies that importing from a SQLite file with database version 2 fails.
+ */
+add_task(function* test_import_v2()
+{
+ let store = new LoginStore(getTempFile("test-import-v2.json").path);
+ let loginsSqlite = do_get_file("data/signons-v2.sqlite").path;
+
+ // The "load" method must be called before importing data.
+ yield store.load();
+ try {
+ yield new LoginImport(store, loginsSqlite).import();
+ do_throw("The operation should have failed.");
+ } catch (ex) { }
+});
+
+/**
+ * Imports login data from a SQLite file, with database version 3.
+ */
+add_task(function* test_import_v3()
+{
+ let store = new LoginStore(getTempFile("test-import-v3.json").path);
+ let loginsSqlite = do_get_file("data/signons-v3.sqlite").path;
+
+ // The "load" method must be called before importing data.
+ yield store.load();
+ yield new LoginImport(store, loginsSqlite).import();
+
+ // We only execute basic integrity checks.
+ do_check_eq(store.data.logins[0].usernameField, "u1");
+ do_check_eq(store.data.disabledHosts.length, 0);
+});
diff --git a/toolkit/components/passwordmgr/test/unit/test_module_LoginStore.js b/toolkit/components/passwordmgr/test/unit/test_module_LoginStore.js
new file mode 100644
index 000000000..335eb601b
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_module_LoginStore.js
@@ -0,0 +1,206 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Tests the LoginStore object.
+ */
+
+"use strict";
+
+// Globals
+
+XPCOMUtils.defineLazyModuleGetter(this, "LoginStore",
+ "resource://gre/modules/LoginStore.jsm");
+
+const TEST_STORE_FILE_NAME = "test-logins.json";
+
+// Tests
+
+/**
+ * Saves login data to a file, then reloads it.
+ */
+add_task(function* test_save_reload()
+{
+ let storeForSave = new LoginStore(getTempFile(TEST_STORE_FILE_NAME).path);
+
+ // The "load" method must be called before preparing the data to be saved.
+ yield storeForSave.load();
+
+ let rawLoginData = {
+ id: storeForSave.data.nextId++,
+ hostname: "http://www.example.com",
+ httpRealm: null,
+ formSubmitURL: "http://www.example.com/submit-url",
+ usernameField: "field_" + String.fromCharCode(533, 537, 7570, 345),
+ passwordField: "field_" + String.fromCharCode(421, 259, 349, 537),
+ encryptedUsername: "(test)",
+ encryptedPassword: "(test)",
+ guid: "(test)",
+ encType: Ci.nsILoginManagerCrypto.ENCTYPE_SDR,
+ timeCreated: Date.now(),
+ timeLastUsed: Date.now(),
+ timePasswordChanged: Date.now(),
+ timesUsed: 1,
+ };
+ storeForSave.data.logins.push(rawLoginData);
+
+ storeForSave.data.disabledHosts.push("http://www.example.org");
+
+ yield storeForSave._save();
+
+ // Test the asynchronous initialization path.
+ let storeForLoad = new LoginStore(storeForSave.path);
+ yield storeForLoad.load();
+
+ do_check_eq(storeForLoad.data.logins.length, 1);
+ do_check_matches(storeForLoad.data.logins[0], rawLoginData);
+ do_check_eq(storeForLoad.data.disabledHosts.length, 1);
+ do_check_eq(storeForLoad.data.disabledHosts[0], "http://www.example.org");
+
+ // Test the synchronous initialization path.
+ storeForLoad = new LoginStore(storeForSave.path);
+ storeForLoad.ensureDataReady();
+
+ do_check_eq(storeForLoad.data.logins.length, 1);
+ do_check_matches(storeForLoad.data.logins[0], rawLoginData);
+ do_check_eq(storeForLoad.data.disabledHosts.length, 1);
+ do_check_eq(storeForLoad.data.disabledHosts[0], "http://www.example.org");
+});
+
+/**
+ * Checks that loading from a missing file results in empty arrays.
+ */
+add_task(function* test_load_empty()
+{
+ let store = new LoginStore(getTempFile(TEST_STORE_FILE_NAME).path);
+
+ do_check_false(yield OS.File.exists(store.path));
+
+ yield store.load();
+
+ do_check_false(yield OS.File.exists(store.path));
+
+ do_check_eq(store.data.logins.length, 0);
+ do_check_eq(store.data.disabledHosts.length, 0);
+});
+
+/**
+ * Checks that saving empty data still overwrites any existing file.
+ */
+add_task(function* test_save_empty()
+{
+ let store = new LoginStore(getTempFile(TEST_STORE_FILE_NAME).path);
+
+ yield store.load();
+
+ let createdFile = yield OS.File.open(store.path, { create: true });
+ yield createdFile.close();
+
+ yield store._save();
+
+ do_check_true(yield OS.File.exists(store.path));
+});
+
+/**
+ * Loads data from a string in a predefined format. The purpose of this test is
+ * to verify that the JSON format used in previous versions can be loaded.
+ */
+add_task(function* test_load_string_predefined()
+{
+ let store = new LoginStore(getTempFile(TEST_STORE_FILE_NAME).path);
+
+ let string = "{\"logins\":[{" +
+ "\"id\":1," +
+ "\"hostname\":\"http://www.example.com\"," +
+ "\"httpRealm\":null," +
+ "\"formSubmitURL\":\"http://www.example.com/submit-url\"," +
+ "\"usernameField\":\"usernameField\"," +
+ "\"passwordField\":\"passwordField\"," +
+ "\"encryptedUsername\":\"(test)\"," +
+ "\"encryptedPassword\":\"(test)\"," +
+ "\"guid\":\"(test)\"," +
+ "\"encType\":1," +
+ "\"timeCreated\":1262304000000," +
+ "\"timeLastUsed\":1262390400000," +
+ "\"timePasswordChanged\":1262476800000," +
+ "\"timesUsed\":1}],\"disabledHosts\":[" +
+ "\"http://www.example.org\"]}";
+
+ yield OS.File.writeAtomic(store.path,
+ new TextEncoder().encode(string),
+ { tmpPath: store.path + ".tmp" });
+
+ yield store.load();
+
+ do_check_eq(store.data.logins.length, 1);
+ do_check_matches(store.data.logins[0], {
+ id: 1,
+ hostname: "http://www.example.com",
+ httpRealm: null,
+ formSubmitURL: "http://www.example.com/submit-url",
+ usernameField: "usernameField",
+ passwordField: "passwordField",
+ encryptedUsername: "(test)",
+ encryptedPassword: "(test)",
+ guid: "(test)",
+ encType: Ci.nsILoginManagerCrypto.ENCTYPE_SDR,
+ timeCreated: 1262304000000,
+ timeLastUsed: 1262390400000,
+ timePasswordChanged: 1262476800000,
+ timesUsed: 1,
+ });
+
+ do_check_eq(store.data.disabledHosts.length, 1);
+ do_check_eq(store.data.disabledHosts[0], "http://www.example.org");
+});
+
+/**
+ * Loads login data from a malformed JSON string.
+ */
+add_task(function* test_load_string_malformed()
+{
+ let store = new LoginStore(getTempFile(TEST_STORE_FILE_NAME).path);
+
+ let string = "{\"logins\":[{\"hostname\":\"http://www.example.com\"," +
+ "\"id\":1,";
+
+ yield OS.File.writeAtomic(store.path, new TextEncoder().encode(string),
+ { tmpPath: store.path + ".tmp" });
+
+ yield store.load();
+
+ // A backup file should have been created.
+ do_check_true(yield OS.File.exists(store.path + ".corrupt"));
+ yield OS.File.remove(store.path + ".corrupt");
+
+ // The store should be ready to accept new data.
+ do_check_eq(store.data.logins.length, 0);
+ do_check_eq(store.data.disabledHosts.length, 0);
+});
+
+/**
+ * Loads login data from a malformed JSON string, using the synchronous
+ * initialization path.
+ */
+add_task(function* test_load_string_malformed_sync()
+{
+ let store = new LoginStore(getTempFile(TEST_STORE_FILE_NAME).path);
+
+ let string = "{\"logins\":[{\"hostname\":\"http://www.example.com\"," +
+ "\"id\":1,";
+
+ yield OS.File.writeAtomic(store.path, new TextEncoder().encode(string),
+ { tmpPath: store.path + ".tmp" });
+
+ store.ensureDataReady();
+
+ // A backup file should have been created.
+ do_check_true(yield OS.File.exists(store.path + ".corrupt"));
+ yield OS.File.remove(store.path + ".corrupt");
+
+ // The store should be ready to accept new data.
+ do_check_eq(store.data.logins.length, 0);
+ do_check_eq(store.data.disabledHosts.length, 0);
+});
diff --git a/toolkit/components/passwordmgr/test/unit/test_notifications.js b/toolkit/components/passwordmgr/test/unit/test_notifications.js
new file mode 100644
index 000000000..41caa2c1b
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_notifications.js
@@ -0,0 +1,172 @@
+/*
+ * Tests notifications dispatched when modifying stored logins.
+ */
+
+var expectedNotification;
+var expectedData;
+
+var TestObserver = {
+ QueryInterface : XPCOMUtils.generateQI([Ci.nsIObserver, Ci.nsISupportsWeakReference]),
+
+ observe : function (subject, topic, data) {
+ do_check_eq(topic, "passwordmgr-storage-changed");
+ do_check_eq(data, expectedNotification);
+
+ switch (data) {
+ case "addLogin":
+ do_check_true(subject instanceof Ci.nsILoginInfo);
+ do_check_true(subject instanceof Ci.nsILoginMetaInfo);
+ do_check_true(expectedData.equals(subject)); // nsILoginInfo.equals()
+ break;
+ case "modifyLogin":
+ do_check_true(subject instanceof Ci.nsIArray);
+ do_check_eq(subject.length, 2);
+ var oldLogin = subject.queryElementAt(0, Ci.nsILoginInfo);
+ var newLogin = subject.queryElementAt(1, Ci.nsILoginInfo);
+ do_check_true(expectedData[0].equals(oldLogin)); // nsILoginInfo.equals()
+ do_check_true(expectedData[1].equals(newLogin));
+ break;
+ case "removeLogin":
+ do_check_true(subject instanceof Ci.nsILoginInfo);
+ do_check_true(subject instanceof Ci.nsILoginMetaInfo);
+ do_check_true(expectedData.equals(subject)); // nsILoginInfo.equals()
+ break;
+ case "removeAllLogins":
+ do_check_eq(subject, null);
+ break;
+ case "hostSavingEnabled":
+ case "hostSavingDisabled":
+ do_check_true(subject instanceof Ci.nsISupportsString);
+ do_check_eq(subject.data, expectedData);
+ break;
+ default:
+ do_throw("Unhandled notification: " + data + " / " + topic);
+ }
+
+ expectedNotification = null; // ensure a duplicate is flagged as unexpected.
+ expectedData = null;
+ }
+};
+
+add_task(function test_notifications()
+{
+
+try {
+
+var testnum = 0;
+var testdesc = "Setup of nsLoginInfo test-users";
+
+var testuser1 = new LoginInfo("http://testhost1", "", null,
+ "dummydude", "itsasecret", "put_user_here", "put_pw_here");
+
+var testuser2 = new LoginInfo("http://testhost2", "", null,
+ "dummydude2", "itsasecret2", "put_user2_here", "put_pw2_here");
+
+Services.obs.addObserver(TestObserver, "passwordmgr-storage-changed", false);
+
+
+/* ========== 1 ========== */
+testnum = 1;
+testdesc = "Initial connection to storage module";
+
+/* ========== 2 ========== */
+testnum++;
+testdesc = "addLogin";
+
+expectedNotification = "addLogin";
+expectedData = testuser1;
+Services.logins.addLogin(testuser1);
+LoginTestUtils.checkLogins([testuser1]);
+do_check_eq(expectedNotification, null); // check that observer got a notification
+
+/* ========== 3 ========== */
+testnum++;
+testdesc = "modifyLogin";
+
+expectedNotification = "modifyLogin";
+expectedData = [testuser1, testuser2];
+Services.logins.modifyLogin(testuser1, testuser2);
+do_check_eq(expectedNotification, null);
+LoginTestUtils.checkLogins([testuser2]);
+
+/* ========== 4 ========== */
+testnum++;
+testdesc = "removeLogin";
+
+expectedNotification = "removeLogin";
+expectedData = testuser2;
+Services.logins.removeLogin(testuser2);
+do_check_eq(expectedNotification, null);
+LoginTestUtils.checkLogins([]);
+
+/* ========== 5 ========== */
+testnum++;
+testdesc = "removeAllLogins";
+
+expectedNotification = "removeAllLogins";
+expectedData = null;
+Services.logins.removeAllLogins();
+do_check_eq(expectedNotification, null);
+LoginTestUtils.checkLogins([]);
+
+/* ========== 6 ========== */
+testnum++;
+testdesc = "removeAllLogins (again)";
+
+expectedNotification = "removeAllLogins";
+expectedData = null;
+Services.logins.removeAllLogins();
+do_check_eq(expectedNotification, null);
+LoginTestUtils.checkLogins([]);
+
+/* ========== 7 ========== */
+testnum++;
+testdesc = "setLoginSavingEnabled / false";
+
+expectedNotification = "hostSavingDisabled";
+expectedData = "http://site.com";
+Services.logins.setLoginSavingEnabled("http://site.com", false);
+do_check_eq(expectedNotification, null);
+LoginTestUtils.assertDisabledHostsEqual(Services.logins.getAllDisabledHosts(),
+ ["http://site.com"]);
+
+/* ========== 8 ========== */
+testnum++;
+testdesc = "setLoginSavingEnabled / false (again)";
+
+expectedNotification = "hostSavingDisabled";
+expectedData = "http://site.com";
+Services.logins.setLoginSavingEnabled("http://site.com", false);
+do_check_eq(expectedNotification, null);
+LoginTestUtils.assertDisabledHostsEqual(Services.logins.getAllDisabledHosts(),
+ ["http://site.com"]);
+
+/* ========== 9 ========== */
+testnum++;
+testdesc = "setLoginSavingEnabled / true";
+
+expectedNotification = "hostSavingEnabled";
+expectedData = "http://site.com";
+Services.logins.setLoginSavingEnabled("http://site.com", true);
+do_check_eq(expectedNotification, null);
+LoginTestUtils.checkLogins([]);
+
+/* ========== 10 ========== */
+testnum++;
+testdesc = "setLoginSavingEnabled / true (again)";
+
+expectedNotification = "hostSavingEnabled";
+expectedData = "http://site.com";
+Services.logins.setLoginSavingEnabled("http://site.com", true);
+do_check_eq(expectedNotification, null);
+LoginTestUtils.checkLogins([]);
+
+Services.obs.removeObserver(TestObserver, "passwordmgr-storage-changed");
+
+LoginTestUtils.clearData();
+
+} catch (e) {
+ throw new Error("FAILED in test #" + testnum + " -- " + testdesc + ": " + e);
+}
+
+});
diff --git a/toolkit/components/passwordmgr/test/unit/test_recipes_add.js b/toolkit/components/passwordmgr/test/unit/test_recipes_add.js
new file mode 100644
index 000000000..ef5086c3b
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_recipes_add.js
@@ -0,0 +1,177 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Tests adding and retrieving LoginRecipes in the parent process.
+ */
+
+"use strict";
+
+add_task(function* test_init() {
+ let parent = new LoginRecipesParent({ defaults: null });
+ let initPromise1 = parent.initializationPromise;
+ let initPromise2 = parent.initializationPromise;
+ Assert.strictEqual(initPromise1, initPromise2, "Check that the same promise is returned");
+
+ let recipesParent = yield initPromise1;
+ Assert.ok(recipesParent instanceof LoginRecipesParent, "Check init return value");
+ Assert.strictEqual(recipesParent._recipesByHost.size, 0, "Initially 0 recipes");
+});
+
+add_task(function* test_get_missing_host() {
+ let recipesParent = yield RecipeHelpers.initNewParent();
+ let exampleRecipes = recipesParent.getRecipesForHost("example.invalid");
+ Assert.strictEqual(exampleRecipes.size, 0, "Check recipe count for example.invalid");
+
+});
+
+add_task(function* test_add_get_simple_host() {
+ let recipesParent = yield RecipeHelpers.initNewParent();
+ Assert.strictEqual(recipesParent._recipesByHost.size, 0, "Initially 0 recipes");
+ recipesParent.add({
+ hosts: ["example.com"],
+ });
+ Assert.strictEqual(recipesParent._recipesByHost.size, 1,
+ "Check number of hosts after the addition");
+
+ let exampleRecipes = recipesParent.getRecipesForHost("example.com");
+ Assert.strictEqual(exampleRecipes.size, 1, "Check recipe count for example.com");
+ let recipe = [...exampleRecipes][0];
+ Assert.strictEqual(typeof(recipe), "object", "Check recipe type");
+ Assert.strictEqual(recipe.hosts.length, 1, "Check that one host is present");
+ Assert.strictEqual(recipe.hosts[0], "example.com", "Check the one host");
+});
+
+add_task(function* test_add_get_non_standard_port_host() {
+ let recipesParent = yield RecipeHelpers.initNewParent();
+ recipesParent.add({
+ hosts: ["example.com:8080"],
+ });
+ Assert.strictEqual(recipesParent._recipesByHost.size, 1,
+ "Check number of hosts after the addition");
+
+ let exampleRecipes = recipesParent.getRecipesForHost("example.com:8080");
+ Assert.strictEqual(exampleRecipes.size, 1, "Check recipe count for example.com:8080");
+ let recipe = [...exampleRecipes][0];
+ Assert.strictEqual(typeof(recipe), "object", "Check recipe type");
+ Assert.strictEqual(recipe.hosts.length, 1, "Check that one host is present");
+ Assert.strictEqual(recipe.hosts[0], "example.com:8080", "Check the one host");
+});
+
+add_task(function* test_add_multiple_hosts() {
+ let recipesParent = yield RecipeHelpers.initNewParent();
+ recipesParent.add({
+ hosts: ["example.com", "foo.invalid"],
+ });
+ Assert.strictEqual(recipesParent._recipesByHost.size, 2,
+ "Check number of hosts after the addition");
+
+ let exampleRecipes = recipesParent.getRecipesForHost("example.com");
+ Assert.strictEqual(exampleRecipes.size, 1, "Check recipe count for example.com");
+ let recipe = [...exampleRecipes][0];
+ Assert.strictEqual(typeof(recipe), "object", "Check recipe type");
+ Assert.strictEqual(recipe.hosts.length, 2, "Check that two hosts are present");
+ Assert.strictEqual(recipe.hosts[0], "example.com", "Check the first host");
+ Assert.strictEqual(recipe.hosts[1], "foo.invalid", "Check the second host");
+
+ let fooRecipes = recipesParent.getRecipesForHost("foo.invalid");
+ Assert.strictEqual(fooRecipes.size, 1, "Check recipe count for foo.invalid");
+ let fooRecipe = [...fooRecipes][0];
+ Assert.strictEqual(fooRecipe, recipe, "Check that the recipe is shared");
+ Assert.strictEqual(typeof(fooRecipe), "object", "Check recipe type");
+ Assert.strictEqual(fooRecipe.hosts.length, 2, "Check that two hosts are present");
+ Assert.strictEqual(fooRecipe.hosts[0], "example.com", "Check the first host");
+ Assert.strictEqual(fooRecipe.hosts[1], "foo.invalid", "Check the second host");
+});
+
+add_task(function* test_add_pathRegex() {
+ let recipesParent = yield RecipeHelpers.initNewParent();
+ recipesParent.add({
+ hosts: ["example.com"],
+ pathRegex: /^\/mypath\//,
+ });
+ Assert.strictEqual(recipesParent._recipesByHost.size, 1,
+ "Check number of hosts after the addition");
+
+ let exampleRecipes = recipesParent.getRecipesForHost("example.com");
+ Assert.strictEqual(exampleRecipes.size, 1, "Check recipe count for example.com");
+ let recipe = [...exampleRecipes][0];
+ Assert.strictEqual(typeof(recipe), "object", "Check recipe type");
+ Assert.strictEqual(recipe.hosts.length, 1, "Check that one host is present");
+ Assert.strictEqual(recipe.hosts[0], "example.com", "Check the one host");
+ Assert.strictEqual(recipe.pathRegex.toString(), "/^\\/mypath\\//", "Check the pathRegex");
+});
+
+add_task(function* test_add_selectors() {
+ let recipesParent = yield RecipeHelpers.initNewParent();
+ recipesParent.add({
+ hosts: ["example.com"],
+ usernameSelector: "#my-username",
+ passwordSelector: "#my-form > input.password",
+ });
+ Assert.strictEqual(recipesParent._recipesByHost.size, 1,
+ "Check number of hosts after the addition");
+
+ let exampleRecipes = recipesParent.getRecipesForHost("example.com");
+ Assert.strictEqual(exampleRecipes.size, 1, "Check recipe count for example.com");
+ let recipe = [...exampleRecipes][0];
+ Assert.strictEqual(typeof(recipe), "object", "Check recipe type");
+ Assert.strictEqual(recipe.hosts.length, 1, "Check that one host is present");
+ Assert.strictEqual(recipe.hosts[0], "example.com", "Check the one host");
+ Assert.strictEqual(recipe.usernameSelector, "#my-username", "Check the usernameSelector");
+ Assert.strictEqual(recipe.passwordSelector, "#my-form > input.password", "Check the passwordSelector");
+});
+
+/* Begin checking errors with add */
+
+add_task(function* test_add_missing_prop() {
+ let recipesParent = yield RecipeHelpers.initNewParent();
+ Assert.throws(() => recipesParent.add({}), /required/, "Some properties are required");
+});
+
+add_task(function* test_add_unknown_prop() {
+ let recipesParent = yield RecipeHelpers.initNewParent();
+ Assert.throws(() => recipesParent.add({
+ unknownProp: true,
+ }), /supported/, "Unknown properties should cause an error to help with typos");
+});
+
+add_task(function* test_add_invalid_hosts() {
+ let recipesParent = yield RecipeHelpers.initNewParent();
+ Assert.throws(() => recipesParent.add({
+ hosts: 404,
+ }), /array/, "hosts should be an array");
+});
+
+add_task(function* test_add_empty_host_array() {
+ let recipesParent = yield RecipeHelpers.initNewParent();
+ Assert.throws(() => recipesParent.add({
+ hosts: [],
+ }), /array/, "hosts should be a non-empty array");
+});
+
+add_task(function* test_add_pathRegex_non_regexp() {
+ let recipesParent = yield RecipeHelpers.initNewParent();
+ Assert.throws(() => recipesParent.add({
+ hosts: ["example.com"],
+ pathRegex: "foo",
+ }), /regular expression/, "pathRegex should be a RegExp");
+});
+
+add_task(function* test_add_usernameSelector_non_string() {
+ let recipesParent = yield RecipeHelpers.initNewParent();
+ Assert.throws(() => recipesParent.add({
+ hosts: ["example.com"],
+ usernameSelector: 404,
+ }), /string/, "usernameSelector should be a string");
+});
+
+add_task(function* test_add_passwordSelector_non_string() {
+ let recipesParent = yield RecipeHelpers.initNewParent();
+ Assert.throws(() => recipesParent.add({
+ hosts: ["example.com"],
+ passwordSelector: 404,
+ }), /string/, "passwordSelector should be a string");
+});
+
+/* End checking errors with add */
diff --git a/toolkit/components/passwordmgr/test/unit/test_recipes_content.js b/toolkit/components/passwordmgr/test/unit/test_recipes_content.js
new file mode 100644
index 000000000..3d3751452
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_recipes_content.js
@@ -0,0 +1,39 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Test filtering recipes in LoginRecipesContent.
+ */
+
+"use strict";
+
+Cu.importGlobalProperties(["URL"]);
+
+add_task(function* test_getFieldOverrides() {
+ let recipes = new Set([
+ { // path doesn't match but otherwise good
+ hosts: ["example.com:8080"],
+ passwordSelector: "#password",
+ pathRegex: /^\/$/,
+ usernameSelector: ".username",
+ },
+ { // match with no field overrides
+ hosts: ["example.com:8080"],
+ },
+ { // best match (field selectors + path match)
+ description: "best match",
+ hosts: ["a.invalid", "example.com:8080", "other.invalid"],
+ passwordSelector: "#password",
+ pathRegex: /^\/first\/second\/$/,
+ usernameSelector: ".username",
+ },
+ ]);
+
+ let form = MockDocument.createTestDocument("http://localhost:8080/first/second/", "<form>").
+ forms[0];
+ let override = LoginRecipesContent.getFieldOverrides(recipes, form);
+ Assert.strictEqual(override.description, "best match",
+ "Check the best field override recipe was returned");
+ Assert.strictEqual(override.usernameSelector, ".username", "Check usernameSelector");
+ Assert.strictEqual(override.passwordSelector, "#password", "Check passwordSelector");
+});
diff --git a/toolkit/components/passwordmgr/test/unit/test_removeLegacySignonFiles.js b/toolkit/components/passwordmgr/test/unit/test_removeLegacySignonFiles.js
new file mode 100644
index 000000000..51a107170
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_removeLegacySignonFiles.js
@@ -0,0 +1,69 @@
+/**
+ * Tests the LoginHelper object.
+ */
+
+"use strict";
+
+
+Cu.import("resource://gre/modules/Task.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "LoginHelper",
+ "resource://gre/modules/LoginHelper.jsm");
+
+
+function* createSignonFile(singon) {
+ let {file, pref} = singon;
+
+ if (pref) {
+ Services.prefs.setCharPref(pref, file);
+ }
+
+ yield OS.File.writeAtomic(
+ OS.Path.join(OS.Constants.Path.profileDir, file), new Uint8Array(1));
+}
+
+function* isSignonClear(singon) {
+ const {file, pref} = singon;
+ const fileExists = yield OS.File.exists(
+ OS.Path.join(OS.Constants.Path.profileDir, file));
+
+ if (pref) {
+ try {
+ Services.prefs.getCharPref(pref);
+ return false;
+ } catch (e) {}
+ }
+
+ return !fileExists;
+}
+
+add_task(function* test_remove_lagecy_signonfile() {
+ // In the last test case, signons3.txt being deleted even when
+ // it doesn't exist.
+ const signonsSettings = [[
+ { file: "signons.txt" },
+ { file: "signons2.txt" },
+ { file: "signons3.txt" }
+ ], [
+ { file: "signons.txt", pref: "signon.SignonFileName" },
+ { file: "signons2.txt", pref: "signon.SignonFileName2" },
+ { file: "signons3.txt", pref: "signon.SignonFileName3" }
+ ], [
+ { file: "signons2.txt" },
+ { file: "singons.txt", pref: "signon.SignonFileName" },
+ { file: "customized2.txt", pref: "signon.SignonFileName2" },
+ { file: "customized3.txt", pref: "signon.SignonFileName3" }
+ ]];
+
+ for (let setting of signonsSettings) {
+ for (let singon of setting) {
+ yield createSignonFile(singon);
+ }
+
+ LoginHelper.removeLegacySignonFiles();
+
+ for (let singon of setting) {
+ equal(yield isSignonClear(singon), true);
+ }
+ }
+});
diff --git a/toolkit/components/passwordmgr/test/unit/test_search_schemeUpgrades.js b/toolkit/components/passwordmgr/test/unit/test_search_schemeUpgrades.js
new file mode 100644
index 000000000..3406becff
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_search_schemeUpgrades.js
@@ -0,0 +1,184 @@
+/*
+ * Test Services.logins.searchLogins with the `schemeUpgrades` property.
+ */
+
+const HTTP3_ORIGIN = "http://www3.example.com";
+const HTTPS_ORIGIN = "https://www.example.com";
+const HTTP_ORIGIN = "http://www.example.com";
+
+/**
+ * Returns a list of new nsILoginInfo objects that are a subset of the test
+ * data, built to match the specified query.
+ *
+ * @param {Object} aQuery
+ * Each property and value of this object restricts the search to those
+ * entries from the test data that match the property exactly.
+ */
+function buildExpectedLogins(aQuery) {
+ return TestData.loginList().filter(
+ entry => Object.keys(aQuery).every(name => {
+ if (name == "schemeUpgrades") {
+ return true;
+ }
+ if (["hostname", "formSubmitURL"].includes(name)) {
+ return LoginHelper.isOriginMatching(entry[name], aQuery[name], {
+ schemeUpgrades: aQuery.schemeUpgrades,
+ });
+ }
+ return entry[name] === aQuery[name];
+ }));
+}
+
+/**
+ * Tests the searchLogins function.
+ *
+ * @param {Object} aQuery
+ * Each property and value of this object is translated to an entry in
+ * the nsIPropertyBag parameter of searchLogins.
+ * @param {Number} aExpectedCount
+ * Number of logins from the test data that should be found. The actual
+ * list of logins is obtained using the buildExpectedLogins helper, and
+ * this value is just used to verify that modifications to the test data
+ * don't make the current test meaningless.
+ */
+function checkSearch(aQuery, aExpectedCount) {
+ do_print("Testing searchLogins for " + JSON.stringify(aQuery));
+
+ let expectedLogins = buildExpectedLogins(aQuery);
+ do_check_eq(expectedLogins.length, aExpectedCount);
+
+ let outCount = {};
+ let logins = Services.logins.searchLogins(outCount, newPropertyBag(aQuery));
+ do_check_eq(outCount.value, expectedLogins.length);
+ LoginTestUtils.assertLoginListsEqual(logins, expectedLogins);
+}
+
+/**
+ * Prepare data for the following tests.
+ */
+add_task(function test_initialize() {
+ for (let login of TestData.loginList()) {
+ Services.logins.addLogin(login);
+ }
+});
+
+/**
+ * Tests searchLogins with the `schemeUpgrades` property
+ */
+add_task(function test_search_schemeUpgrades_hostname() {
+ // Hostname-only
+ checkSearch({
+ hostname: HTTPS_ORIGIN,
+ }, 1);
+ checkSearch({
+ hostname: HTTPS_ORIGIN,
+ schemeUpgrades: false,
+ }, 1);
+ checkSearch({
+ hostname: HTTPS_ORIGIN,
+ schemeUpgrades: undefined,
+ }, 1);
+ checkSearch({
+ hostname: HTTPS_ORIGIN,
+ schemeUpgrades: true,
+ }, 2);
+});
+
+/**
+ * Same as above but replacing hostname with formSubmitURL.
+ */
+add_task(function test_search_schemeUpgrades_formSubmitURL() {
+ checkSearch({
+ formSubmitURL: HTTPS_ORIGIN,
+ }, 2);
+ checkSearch({
+ formSubmitURL: HTTPS_ORIGIN,
+ schemeUpgrades: false,
+ }, 2);
+ checkSearch({
+ formSubmitURL: HTTPS_ORIGIN,
+ schemeUpgrades: undefined,
+ }, 2);
+ checkSearch({
+ formSubmitURL: HTTPS_ORIGIN,
+ schemeUpgrades: true,
+ }, 4);
+});
+
+
+add_task(function test_search_schemeUpgrades_hostname_formSubmitURL() {
+ checkSearch({
+ formSubmitURL: HTTPS_ORIGIN,
+ hostname: HTTPS_ORIGIN,
+ }, 1);
+ checkSearch({
+ formSubmitURL: HTTPS_ORIGIN,
+ hostname: HTTPS_ORIGIN,
+ schemeUpgrades: false,
+ }, 1);
+ checkSearch({
+ formSubmitURL: HTTPS_ORIGIN,
+ hostname: HTTPS_ORIGIN,
+ schemeUpgrades: undefined,
+ }, 1);
+ checkSearch({
+ formSubmitURL: HTTPS_ORIGIN,
+ hostname: HTTPS_ORIGIN,
+ schemeUpgrades: true,
+ }, 2);
+ checkSearch({
+ formSubmitURL: HTTPS_ORIGIN,
+ hostname: HTTPS_ORIGIN,
+ schemeUpgrades: true,
+ usernameField: "form_field_username",
+ }, 2);
+ checkSearch({
+ formSubmitURL: HTTPS_ORIGIN,
+ hostname: HTTPS_ORIGIN,
+ passwordField: "form_field_password",
+ schemeUpgrades: true,
+ usernameField: "form_field_username",
+ }, 2);
+ checkSearch({
+ formSubmitURL: HTTPS_ORIGIN,
+ hostname: HTTPS_ORIGIN,
+ httpRealm: null,
+ passwordField: "form_field_password",
+ schemeUpgrades: true,
+ usernameField: "form_field_username",
+ }, 2);
+});
+
+/**
+ * HTTP submitting to HTTPS
+ */
+add_task(function test_http_to_https() {
+ checkSearch({
+ formSubmitURL: HTTPS_ORIGIN,
+ hostname: HTTP3_ORIGIN,
+ httpRealm: null,
+ schemeUpgrades: false,
+ }, 1);
+ checkSearch({
+ formSubmitURL: HTTPS_ORIGIN,
+ hostname: HTTP3_ORIGIN,
+ httpRealm: null,
+ schemeUpgrades: true,
+ }, 2);
+});
+
+/**
+ * schemeUpgrades shouldn't cause downgrades
+ */
+add_task(function test_search_schemeUpgrades_downgrade() {
+ checkSearch({
+ formSubmitURL: HTTP_ORIGIN,
+ hostname: HTTP_ORIGIN,
+ }, 1);
+ do_print("The same number should be found with schemeUpgrades since we're searching for HTTP");
+ checkSearch({
+ formSubmitURL: HTTP_ORIGIN,
+ hostname: HTTP_ORIGIN,
+ schemeUpgrades: true,
+ }, 1);
+});
diff --git a/toolkit/components/passwordmgr/test/unit/test_storage.js b/toolkit/components/passwordmgr/test/unit/test_storage.js
new file mode 100644
index 000000000..d65516d9b
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_storage.js
@@ -0,0 +1,102 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Tests that the default nsILoginManagerStorage module attached to the Login
+ * Manager service is able to save and reload nsILoginInfo properties correctly,
+ * even when they include special characters.
+ */
+
+"use strict";
+
+// Globals
+
+function* reloadAndCheckLoginsGen(aExpectedLogins)
+{
+ yield LoginTestUtils.reloadData();
+ LoginTestUtils.checkLogins(aExpectedLogins);
+ LoginTestUtils.clearData();
+}
+
+// Tests
+
+/**
+ * Tests addLogin with valid non-ASCII characters.
+ */
+add_task(function* test_storage_addLogin_nonascii()
+{
+ let hostname = "http://" + String.fromCharCode(355) + ".example.com";
+
+ // Store the strings "user" and "pass" using similarly looking glyphs.
+ let loginInfo = TestData.formLogin({
+ hostname: hostname,
+ formSubmitURL: hostname,
+ username: String.fromCharCode(533, 537, 7570, 345),
+ password: String.fromCharCode(421, 259, 349, 537),
+ usernameField: "field_" + String.fromCharCode(533, 537, 7570, 345),
+ passwordField: "field_" + String.fromCharCode(421, 259, 349, 537),
+ });
+ Services.logins.addLogin(loginInfo);
+ yield* reloadAndCheckLoginsGen([loginInfo]);
+
+ // Store the string "test" using similarly looking glyphs.
+ loginInfo = TestData.authLogin({
+ httpRealm: String.fromCharCode(355, 277, 349, 357),
+ });
+ Services.logins.addLogin(loginInfo);
+ yield* reloadAndCheckLoginsGen([loginInfo]);
+});
+
+/**
+ * Tests addLogin with newline characters in the username and password.
+ */
+add_task(function* test_storage_addLogin_newlines()
+{
+ let loginInfo = TestData.formLogin({
+ username: "user\r\nname",
+ password: "password\r\n",
+ });
+ Services.logins.addLogin(loginInfo);
+ yield* reloadAndCheckLoginsGen([loginInfo]);
+});
+
+/**
+ * Tests addLogin with a single dot in fields where it is allowed.
+ *
+ * These tests exist to verify the legacy "signons.txt" storage format.
+ */
+add_task(function* test_storage_addLogin_dot()
+{
+ let loginInfo = TestData.formLogin({ hostname: ".", passwordField: "." });
+ Services.logins.addLogin(loginInfo);
+ yield* reloadAndCheckLoginsGen([loginInfo]);
+
+ loginInfo = TestData.authLogin({ httpRealm: "." });
+ Services.logins.addLogin(loginInfo);
+ yield* reloadAndCheckLoginsGen([loginInfo]);
+});
+
+/**
+ * Tests addLogin with parentheses in hostnames.
+ *
+ * These tests exist to verify the legacy "signons.txt" storage format.
+ */
+add_task(function* test_storage_addLogin_parentheses()
+{
+ let loginList = [
+ TestData.authLogin({ httpRealm: "(realm" }),
+ TestData.authLogin({ httpRealm: "realm)" }),
+ TestData.authLogin({ httpRealm: "(realm)" }),
+ TestData.authLogin({ httpRealm: ")realm(" }),
+ TestData.authLogin({ hostname: "http://parens(.example.com" }),
+ TestData.authLogin({ hostname: "http://parens).example.com" }),
+ TestData.authLogin({ hostname: "http://parens(example).example.com" }),
+ TestData.authLogin({ hostname: "http://parens)example(.example.com" }),
+ ];
+ for (let loginInfo of loginList) {
+ Services.logins.addLogin(loginInfo);
+ }
+ yield* reloadAndCheckLoginsGen(loginList);
+});
diff --git a/toolkit/components/passwordmgr/test/unit/test_storage_mozStorage.js b/toolkit/components/passwordmgr/test/unit/test_storage_mozStorage.js
new file mode 100644
index 000000000..8eab6efe5
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_storage_mozStorage.js
@@ -0,0 +1,507 @@
+/*
+ * This test interfaces directly with the mozStorage password storage module,
+ * bypassing the normal password manager usage.
+ */
+
+
+const ENCTYPE_BASE64 = 0;
+const ENCTYPE_SDR = 1;
+const PERMISSION_SAVE_LOGINS = "login-saving";
+
+// Current schema version used by storage-mozStorage.js. This will need to be
+// kept in sync with the version there (or else the tests fail).
+const CURRENT_SCHEMA = 6;
+
+function* copyFile(aLeafName)
+{
+ yield OS.File.copy(OS.Path.join(do_get_file("data").path, aLeafName),
+ OS.Path.join(OS.Constants.Path.profileDir, aLeafName));
+}
+
+function openDB(aLeafName)
+{
+ var dbFile = new FileUtils.File(OS.Constants.Path.profileDir);
+ dbFile.append(aLeafName);
+
+ return Services.storage.openDatabase(dbFile);
+}
+
+function deleteFile(pathname, filename)
+{
+ var file = new FileUtils.File(pathname);
+ file.append(filename);
+
+ // Suppress failures, this happens in the mozstorage tests on Windows
+ // because the module may still be holding onto the DB. (We don't
+ // have a way to explicitly shutdown/GC the module).
+ try {
+ if (file.exists())
+ file.remove(false);
+ } catch (e) {}
+}
+
+function reloadStorage(aInputPathName, aInputFileName)
+{
+ var inputFile = null;
+ if (aInputFileName) {
+ inputFile = Cc["@mozilla.org/file/local;1"].
+ createInstance(Ci.nsILocalFile);
+ inputFile.initWithPath(aInputPathName);
+ inputFile.append(aInputFileName);
+ }
+
+ let storage = Cc["@mozilla.org/login-manager/storage/mozStorage;1"]
+ .createInstance(Ci.nsILoginManagerStorage);
+ storage.QueryInterface(Ci.nsIInterfaceRequestor)
+ .getInterface(Ci.nsIVariant)
+ .initWithFile(inputFile);
+
+ return storage;
+}
+
+function checkStorageData(storage, ref_disabledHosts, ref_logins)
+{
+ LoginTestUtils.assertLoginListsEqual(storage.getAllLogins(), ref_logins);
+ LoginTestUtils.assertDisabledHostsEqual(getAllDisabledHostsFromPermissionManager(),
+ ref_disabledHosts);
+}
+
+function getAllDisabledHostsFromPermissionManager() {
+ let disabledHosts = [];
+ let enumerator = Services.perms.enumerator;
+
+ while (enumerator.hasMoreElements()) {
+ let perm = enumerator.getNext();
+ if (perm.type == PERMISSION_SAVE_LOGINS && perm.capability == Services.perms.DENY_ACTION) {
+ disabledHosts.push(perm.principal.URI.prePath);
+ }
+ }
+
+ return disabledHosts;
+}
+
+function setLoginSavingEnabled(origin, enabled) {
+ let uri = Services.io.newURI(origin, null, null);
+
+ if (enabled) {
+ Services.perms.remove(uri, PERMISSION_SAVE_LOGINS);
+ } else {
+ Services.perms.add(uri, PERMISSION_SAVE_LOGINS, Services.perms.DENY_ACTION);
+ }
+}
+
+add_task(function* test_execute()
+{
+
+const OUTDIR = OS.Constants.Path.profileDir;
+
+try {
+
+var isGUID = /^\{[0-9a-f\d]{8}-[0-9a-f\d]{4}-[0-9a-f\d]{4}-[0-9a-f\d]{4}-[0-9a-f\d]{12}\}$/;
+function getGUIDforID(conn, id) {
+ var stmt = conn.createStatement("SELECT guid from moz_logins WHERE id = " + id);
+ stmt.executeStep();
+ var guid = stmt.getString(0);
+ stmt.finalize();
+ return guid;
+}
+
+function getEncTypeForID(conn, id) {
+ var stmt = conn.createStatement("SELECT encType from moz_logins WHERE id = " + id);
+ stmt.executeStep();
+ var encType = stmt.row.encType;
+ stmt.finalize();
+ return encType;
+}
+
+function getAllDisabledHostsFromMozStorage(conn) {
+ let disabledHosts = [];
+ let stmt = conn.createStatement("SELECT hostname from moz_disabledHosts");
+
+ while (stmt.executeStep()) {
+ disabledHosts.push(stmt.row.hostname);
+ }
+
+ return disabledHosts;
+}
+
+var storage;
+var dbConnection;
+var testnum = 0;
+var testdesc = "Setup of nsLoginInfo test-users";
+var nsLoginInfo = new Components.Constructor(
+ "@mozilla.org/login-manager/loginInfo;1",
+ Components.interfaces.nsILoginInfo);
+do_check_true(nsLoginInfo != null);
+
+var testuser1 = new nsLoginInfo;
+testuser1.init("http://test.com", "http://test.com", null,
+ "testuser1", "testpass1", "u1", "p1");
+var testuser1B = new nsLoginInfo;
+testuser1B.init("http://test.com", "http://test.com", null,
+ "testuser1B", "testpass1B", "u1", "p1");
+var testuser2 = new nsLoginInfo;
+testuser2.init("http://test.org", "http://test.org", null,
+ "testuser2", "testpass2", "u2", "p2");
+var testuser3 = new nsLoginInfo;
+testuser3.init("http://test.gov", "http://test.gov", null,
+ "testuser3", "testpass3", "u3", "p3");
+var testuser4 = new nsLoginInfo;
+testuser4.init("http://test.gov", "http://test.gov", null,
+ "testuser1", "testpass2", "u4", "p4");
+var testuser5 = new nsLoginInfo;
+testuser5.init("http://test.gov", "http://test.gov", null,
+ "testuser2", "testpass1", "u5", "p5");
+
+
+/* ========== 1 ========== */
+testnum++;
+testdesc = "Test downgrade from v999 storage";
+
+yield* copyFile("signons-v999.sqlite");
+// Verify the schema version in the test file.
+dbConnection = openDB("signons-v999.sqlite");
+do_check_eq(999, dbConnection.schemaVersion);
+dbConnection.close();
+
+storage = reloadStorage(OUTDIR, "signons-v999.sqlite");
+setLoginSavingEnabled("https://disabled.net", false);
+checkStorageData(storage, ["https://disabled.net"], [testuser1]);
+
+// Check to make sure we downgraded the schema version.
+dbConnection = openDB("signons-v999.sqlite");
+do_check_eq(CURRENT_SCHEMA, dbConnection.schemaVersion);
+dbConnection.close();
+
+deleteFile(OUTDIR, "signons-v999.sqlite");
+
+/* ========== 2 ========== */
+testnum++;
+testdesc = "Test downgrade from incompat v999 storage";
+// This file has a testuser999/testpass999, but is missing an expected column
+
+var origFile = OS.Path.join(OUTDIR, "signons-v999-2.sqlite");
+var failFile = OS.Path.join(OUTDIR, "signons-v999-2.sqlite.corrupt");
+
+// Make sure we always start clean in a clean state.
+yield* copyFile("signons-v999-2.sqlite");
+yield OS.File.remove(failFile);
+
+Assert.throws(() => reloadStorage(OUTDIR, "signons-v999-2.sqlite"),
+ /Initialization failed/);
+
+// Check to ensure the DB file was renamed to .corrupt.
+do_check_false(yield OS.File.exists(origFile));
+do_check_true(yield OS.File.exists(failFile));
+
+yield OS.File.remove(failFile);
+
+/* ========== 3 ========== */
+testnum++;
+testdesc = "Test upgrade from v1->v2 storage";
+
+yield* copyFile("signons-v1.sqlite");
+// Sanity check the test file.
+dbConnection = openDB("signons-v1.sqlite");
+do_check_eq(1, dbConnection.schemaVersion);
+dbConnection.close();
+
+storage = reloadStorage(OUTDIR, "signons-v1.sqlite");
+checkStorageData(storage, ["https://disabled.net"], [testuser1, testuser2]);
+
+// Check to see that we added a GUIDs to the logins.
+dbConnection = openDB("signons-v1.sqlite");
+do_check_eq(CURRENT_SCHEMA, dbConnection.schemaVersion);
+var guid = getGUIDforID(dbConnection, 1);
+do_check_true(isGUID.test(guid));
+guid = getGUIDforID(dbConnection, 2);
+do_check_true(isGUID.test(guid));
+dbConnection.close();
+
+deleteFile(OUTDIR, "signons-v1.sqlite");
+
+/* ========== 4 ========== */
+testnum++;
+testdesc = "Test upgrade v2->v1 storage";
+// This is the case where a v2 DB has been accessed with v1 code, and now we
+// are upgrading it again. Any logins added by the v1 code must be properly
+// upgraded.
+
+yield* copyFile("signons-v1v2.sqlite");
+// Sanity check the test file.
+dbConnection = openDB("signons-v1v2.sqlite");
+do_check_eq(1, dbConnection.schemaVersion);
+dbConnection.close();
+
+storage = reloadStorage(OUTDIR, "signons-v1v2.sqlite");
+checkStorageData(storage, ["https://disabled.net"], [testuser1, testuser2, testuser3]);
+
+// While we're here, try modifying a login, to ensure that doing so doesn't
+// change the existing GUID.
+storage.modifyLogin(testuser1, testuser1B);
+checkStorageData(storage, ["https://disabled.net"], [testuser1B, testuser2, testuser3]);
+
+// Check the GUIDs. Logins 1 and 2 should retain their original GUID, login 3
+// should have one created (because it didn't have one previously).
+dbConnection = openDB("signons-v1v2.sqlite");
+do_check_eq(CURRENT_SCHEMA, dbConnection.schemaVersion);
+guid = getGUIDforID(dbConnection, 1);
+do_check_eq("{655c7358-f1d6-6446-adab-53f98ac5d80f}", guid);
+guid = getGUIDforID(dbConnection, 2);
+do_check_eq("{13d9bfdc-572a-4d4e-9436-68e9803e84c1}", guid);
+guid = getGUIDforID(dbConnection, 3);
+do_check_true(isGUID.test(guid));
+dbConnection.close();
+
+deleteFile(OUTDIR, "signons-v1v2.sqlite");
+
+/* ========== 5 ========== */
+testnum++;
+testdesc = "Test upgrade from v2->v3 storage";
+
+yield* copyFile("signons-v2.sqlite");
+// Sanity check the test file.
+dbConnection = openDB("signons-v2.sqlite");
+do_check_eq(2, dbConnection.schemaVersion);
+
+storage = reloadStorage(OUTDIR, "signons-v2.sqlite");
+
+// Check to see that we added the correct encType to the logins.
+do_check_eq(CURRENT_SCHEMA, dbConnection.schemaVersion);
+var encTypes = [ENCTYPE_BASE64, ENCTYPE_SDR, ENCTYPE_BASE64, ENCTYPE_BASE64];
+for (let i = 0; i < encTypes.length; i++)
+ do_check_eq(encTypes[i], getEncTypeForID(dbConnection, i + 1));
+dbConnection.close();
+
+// There are 4 logins, but 3 will be invalid because we can no longer decrypt
+// base64-encoded items. (testuser1/4/5)
+checkStorageData(storage, ["https://disabled.net"],
+ [testuser2]);
+
+deleteFile(OUTDIR, "signons-v2.sqlite");
+
+/* ========== 6 ========== */
+testnum++;
+testdesc = "Test upgrade v3->v2 storage";
+// This is the case where a v3 DB has been accessed with v2 code, and now we
+// are upgrading it again. Any logins added by the v2 code must be properly
+// upgraded.
+
+yield* copyFile("signons-v2v3.sqlite");
+// Sanity check the test file.
+dbConnection = openDB("signons-v2v3.sqlite");
+do_check_eq(2, dbConnection.schemaVersion);
+encTypes = [ENCTYPE_BASE64, ENCTYPE_SDR, ENCTYPE_BASE64, ENCTYPE_BASE64, null];
+for (let i = 0; i < encTypes.length; i++)
+ do_check_eq(encTypes[i], getEncTypeForID(dbConnection, i + 1));
+
+// Reload storage, check that the new login now has encType=1, others untouched
+storage = reloadStorage(OUTDIR, "signons-v2v3.sqlite");
+do_check_eq(CURRENT_SCHEMA, dbConnection.schemaVersion);
+
+encTypes = [ENCTYPE_BASE64, ENCTYPE_SDR, ENCTYPE_BASE64, ENCTYPE_BASE64, ENCTYPE_SDR];
+for (let i = 0; i < encTypes.length; i++)
+ do_check_eq(encTypes[i], getEncTypeForID(dbConnection, i + 1));
+
+// Sanity check that the data gets migrated
+// There are 5 logins, but 3 will be invalid because we can no longer decrypt
+// base64-encoded items. (testuser1/4/5). We no longer reencrypt with SDR.
+checkStorageData(storage, ["https://disabled.net"], [testuser2, testuser3]);
+encTypes = [ENCTYPE_BASE64, ENCTYPE_SDR, ENCTYPE_BASE64, ENCTYPE_BASE64, ENCTYPE_SDR];
+for (let i = 0; i < encTypes.length; i++)
+ do_check_eq(encTypes[i], getEncTypeForID(dbConnection, i + 1));
+dbConnection.close();
+
+deleteFile(OUTDIR, "signons-v2v3.sqlite");
+
+
+/* ========== 7 ========== */
+testnum++;
+testdesc = "Test upgrade from v3->v4 storage";
+
+yield* copyFile("signons-v3.sqlite");
+// Sanity check the test file.
+dbConnection = openDB("signons-v3.sqlite");
+do_check_eq(3, dbConnection.schemaVersion);
+
+storage = reloadStorage(OUTDIR, "signons-v3.sqlite");
+do_check_eq(CURRENT_SCHEMA, dbConnection.schemaVersion);
+
+// Remove old entry from permission manager.
+setLoginSavingEnabled("https://disabled.net", true);
+
+// Check that timestamps and counts were initialized correctly
+checkStorageData(storage, [], [testuser1, testuser2]);
+
+var logins = storage.getAllLogins();
+for (var i = 0; i < 2; i++) {
+ do_check_true(logins[i] instanceof Ci.nsILoginMetaInfo);
+ do_check_eq(1, logins[i].timesUsed);
+ LoginTestUtils.assertTimeIsAboutNow(logins[i].timeCreated);
+ LoginTestUtils.assertTimeIsAboutNow(logins[i].timeLastUsed);
+ LoginTestUtils.assertTimeIsAboutNow(logins[i].timePasswordChanged);
+}
+
+/* ========== 8 ========== */
+testnum++;
+testdesc = "Test upgrade from v3->v4->v3 storage";
+
+yield* copyFile("signons-v3v4.sqlite");
+// Sanity check the test file.
+dbConnection = openDB("signons-v3v4.sqlite");
+do_check_eq(3, dbConnection.schemaVersion);
+
+storage = reloadStorage(OUTDIR, "signons-v3v4.sqlite");
+do_check_eq(CURRENT_SCHEMA, dbConnection.schemaVersion);
+
+// testuser1 already has timestamps, testuser2 does not.
+checkStorageData(storage, [], [testuser1, testuser2]);
+
+logins = storage.getAllLogins();
+
+var t1, t2;
+if (logins[0].username == "testuser1") {
+ t1 = logins[0];
+ t2 = logins[1];
+} else {
+ t1 = logins[1];
+ t2 = logins[0];
+}
+
+do_check_true(t1 instanceof Ci.nsILoginMetaInfo);
+do_check_true(t2 instanceof Ci.nsILoginMetaInfo);
+
+do_check_eq(9, t1.timesUsed);
+do_check_eq(1262049951275, t1.timeCreated);
+do_check_eq(1262049951275, t1.timeLastUsed);
+do_check_eq(1262049951275, t1.timePasswordChanged);
+
+do_check_eq(1, t2.timesUsed);
+LoginTestUtils.assertTimeIsAboutNow(t2.timeCreated);
+LoginTestUtils.assertTimeIsAboutNow(t2.timeLastUsed);
+LoginTestUtils.assertTimeIsAboutNow(t2.timePasswordChanged);
+
+
+/* ========== 9 ========== */
+testnum++;
+testdesc = "Test upgrade from v4 storage";
+
+yield* copyFile("signons-v4.sqlite");
+// Sanity check the test file.
+dbConnection = openDB("signons-v4.sqlite");
+do_check_eq(4, dbConnection.schemaVersion);
+do_check_false(dbConnection.tableExists("moz_deleted_logins"));
+
+storage = reloadStorage(OUTDIR, "signons-v4.sqlite");
+do_check_eq(CURRENT_SCHEMA, dbConnection.schemaVersion);
+do_check_true(dbConnection.tableExists("moz_deleted_logins"));
+
+
+/* ========== 10 ========== */
+testnum++;
+testdesc = "Test upgrade from v4->v5->v4 storage";
+
+yield copyFile("signons-v4v5.sqlite");
+// Sanity check the test file.
+dbConnection = openDB("signons-v4v5.sqlite");
+do_check_eq(4, dbConnection.schemaVersion);
+do_check_true(dbConnection.tableExists("moz_deleted_logins"));
+
+storage = reloadStorage(OUTDIR, "signons-v4v5.sqlite");
+do_check_eq(CURRENT_SCHEMA, dbConnection.schemaVersion);
+do_check_true(dbConnection.tableExists("moz_deleted_logins"));
+
+/* ========== 11 ========== */
+testnum++;
+testdesc = "Test upgrade from v5->v6 storage";
+
+yield* copyFile("signons-v5v6.sqlite");
+
+// Sanity check the test file.
+dbConnection = openDB("signons-v5v6.sqlite");
+do_check_eq(5, dbConnection.schemaVersion);
+do_check_true(dbConnection.tableExists("moz_disabledHosts"));
+
+// Initial disabled hosts inside signons-v5v6.sqlite
+var disabledHosts = [
+ "http://disabled1.example.com",
+ "http://大.net",
+ "http://xn--19g.com"
+];
+
+LoginTestUtils.assertDisabledHostsEqual(disabledHosts, getAllDisabledHostsFromMozStorage(dbConnection));
+
+// Reload storage
+storage = reloadStorage(OUTDIR, "signons-v5v6.sqlite");
+do_check_eq(CURRENT_SCHEMA, dbConnection.schemaVersion);
+
+// moz_disabledHosts should now be empty after migration.
+LoginTestUtils.assertDisabledHostsEqual([], getAllDisabledHostsFromMozStorage(dbConnection));
+
+// Get all the other hosts currently saved in the permission manager.
+let hostsInPermissionManager = getAllDisabledHostsFromPermissionManager();
+
+// All disabledHosts should have migrated to the permission manager
+LoginTestUtils.assertDisabledHostsEqual(disabledHosts, hostsInPermissionManager);
+
+// Remove all disabled hosts from the permission manager before test ends
+for (let host of disabledHosts) {
+ setLoginSavingEnabled(host, true);
+}
+
+/* ========== 12 ========== */
+testnum++;
+testdesc = "Create nsILoginInfo instances for testing with";
+
+testuser1 = new nsLoginInfo;
+testuser1.init("http://dummyhost.mozilla.org", "", null,
+ "dummydude", "itsasecret", "put_user_here", "put_pw_here");
+
+
+/*
+ * ---------------------- DB Corruption ----------------------
+ * Try to initialize with a corrupt database file. This should create a backup
+ * file, then upon next use create a new database file.
+ */
+
+/* ========== 13 ========== */
+testnum++;
+testdesc = "Corrupt database and backup";
+
+const filename = "signons-c.sqlite";
+const filepath = OS.Path.join(OS.Constants.Path.profileDir, filename);
+
+yield OS.File.copy(do_get_file("data/corruptDB.sqlite").path, filepath);
+
+// will init mozStorage module with corrupt database, init should fail
+Assert.throws(
+ () => reloadStorage(OS.Constants.Path.profileDir, filename),
+ /Initialization failed/);
+
+// check that the backup file exists
+do_check_true(yield OS.File.exists(filepath + ".corrupt"));
+
+// check that the original corrupt file has been deleted
+do_check_false(yield OS.File.exists(filepath));
+
+// initialize the storage module again
+storage = reloadStorage(OS.Constants.Path.profileDir, filename);
+
+// use the storage module again, should work now
+storage.addLogin(testuser1);
+checkStorageData(storage, [], [testuser1]);
+
+// check the file exists
+var file = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsILocalFile);
+file.initWithPath(OS.Constants.Path.profileDir);
+file.append(filename);
+do_check_true(file.exists());
+
+deleteFile(OS.Constants.Path.profileDir, filename + ".corrupt");
+deleteFile(OS.Constants.Path.profileDir, filename);
+
+} catch (e) {
+ throw new Error("FAILED in test #" + testnum + " -- " + testdesc + ": " + e);
+}
+
+});
diff --git a/toolkit/components/passwordmgr/test/unit/test_telemetry.js b/toolkit/components/passwordmgr/test/unit/test_telemetry.js
new file mode 100644
index 000000000..1d8f80226
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_telemetry.js
@@ -0,0 +1,187 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Tests the statistics and other counters reported through telemetry.
+ */
+
+"use strict";
+
+// Globals
+
+const MS_PER_DAY = 24 * 60 * 60 * 1000;
+
+// To prevent intermittent failures when the test is executed at a time that is
+// very close to a day boundary, we make it deterministic by using a static
+// reference date for all the time-based statistics.
+const gReferenceTimeMs = new Date("2000-01-01T00:00:00").getTime();
+
+// Returns a milliseconds value to use with nsILoginMetaInfo properties, falling
+// approximately in the middle of the specified number of days before the
+// reference time, where zero days indicates a time within the past 24 hours.
+var daysBeforeMs = days => gReferenceTimeMs - (days + 0.5) * MS_PER_DAY;
+
+/**
+ * Contains metadata that will be attached to test logins in order to verify
+ * that the statistics collection is working properly. Most properties of the
+ * logins are initialized to the default test values already.
+ *
+ * If you update this data or any of the telemetry histograms it checks, you'll
+ * probably need to update the expected statistics in the test below.
+ */
+const StatisticsTestData = [
+ {
+ timeLastUsed: daysBeforeMs(0),
+ },
+ {
+ timeLastUsed: daysBeforeMs(1),
+ },
+ {
+ timeLastUsed: daysBeforeMs(7),
+ formSubmitURL: null,
+ httpRealm: "The HTTP Realm",
+ },
+ {
+ username: "",
+ timeLastUsed: daysBeforeMs(7),
+ },
+ {
+ username: "",
+ timeLastUsed: daysBeforeMs(30),
+ },
+ {
+ username: "",
+ timeLastUsed: daysBeforeMs(31),
+ },
+ {
+ timeLastUsed: daysBeforeMs(365),
+ },
+ {
+ username: "",
+ timeLastUsed: daysBeforeMs(366),
+ },
+ {
+ // If the login was saved in the future, it is ignored for statistiscs.
+ timeLastUsed: daysBeforeMs(-1),
+ },
+ {
+ timeLastUsed: daysBeforeMs(1000),
+ },
+];
+
+/**
+ * Triggers the collection of those statistics that are not accumulated each
+ * time an action is taken, but are a static snapshot of the current state.
+ */
+function triggerStatisticsCollection() {
+ Services.obs.notifyObservers(null, "gather-telemetry", "" + gReferenceTimeMs);
+}
+
+/**
+ * Tests the telemetry histogram with the given ID contains only the specified
+ * non-zero ranges, expressed in the format { range1: value1, range2: value2 }.
+ */
+function testHistogram(histogramId, expectedNonZeroRanges) {
+ let snapshot = Services.telemetry.getHistogramById(histogramId).snapshot();
+
+ // Compute the actual ranges in the format { range1: value1, range2: value2 }.
+ let actualNonZeroRanges = {};
+ for (let [index, range] of snapshot.ranges.entries()) {
+ let value = snapshot.counts[index];
+ if (value > 0) {
+ actualNonZeroRanges[range] = value;
+ }
+ }
+
+ // These are stringified to visualize the differences between the values.
+ do_print("Testing histogram: " + histogramId);
+ do_check_eq(JSON.stringify(actualNonZeroRanges),
+ JSON.stringify(expectedNonZeroRanges));
+}
+
+// Tests
+
+/**
+ * Enable local telemetry recording for the duration of the tests, and prepare
+ * the test data that will be used by the following tests.
+ */
+add_task(function test_initialize() {
+ let oldCanRecord = Services.telemetry.canRecordExtended;
+ Services.telemetry.canRecordExtended = true;
+ do_register_cleanup(function () {
+ Services.telemetry.canRecordExtended = oldCanRecord;
+ });
+
+ let uniqueNumber = 1;
+ for (let loginModifications of StatisticsTestData) {
+ loginModifications.hostname = `http://${uniqueNumber++}.example.com`;
+ Services.logins.addLogin(TestData.formLogin(loginModifications));
+ }
+});
+
+/**
+ * Tests the collection of statistics related to login metadata.
+ */
+add_task(function test_logins_statistics() {
+ // Repeat the operation twice to test that histograms are not accumulated.
+ for (let repeating of [false, true]) {
+ triggerStatisticsCollection();
+
+ // Should record 1 in the bucket corresponding to the number of passwords.
+ testHistogram("PWMGR_NUM_SAVED_PASSWORDS",
+ { 10: 1 });
+
+ // Should record 1 in the bucket corresponding to the number of passwords.
+ testHistogram("PWMGR_NUM_HTTPAUTH_PASSWORDS",
+ { 1: 1 });
+
+ // For each saved login, should record 1 in the bucket corresponding to the
+ // age in days since the login was last used.
+ testHistogram("PWMGR_LOGIN_LAST_USED_DAYS",
+ { 0: 1, 1: 1, 7: 2, 29: 2, 356: 2, 750: 1 });
+
+ // Should record the number of logins without a username in bucket 0, and
+ // the number of logins with a username in bucket 1.
+ testHistogram("PWMGR_USERNAME_PRESENT",
+ { 0: 4, 1: 6 });
+ }
+});
+
+/**
+ * Tests the collection of statistics related to hosts for which passowrd saving
+ * has been explicitly disabled.
+ */
+add_task(function test_disabledHosts_statistics() {
+ // Should record 1 in the bucket corresponding to the number of sites for
+ // which password saving is disabled.
+ Services.logins.setLoginSavingEnabled("http://www.example.com", false);
+ triggerStatisticsCollection();
+ testHistogram("PWMGR_BLOCKLIST_NUM_SITES", { 1: 1 });
+
+ Services.logins.setLoginSavingEnabled("http://www.example.com", true);
+ triggerStatisticsCollection();
+ testHistogram("PWMGR_BLOCKLIST_NUM_SITES", { 0: 1 });
+});
+
+/**
+ * Tests the collection of statistics related to general settings.
+ */
+add_task(function test_settings_statistics() {
+ let oldRememberSignons = Services.prefs.getBoolPref("signon.rememberSignons");
+ do_register_cleanup(function () {
+ Services.prefs.setBoolPref("signon.rememberSignons", oldRememberSignons);
+ });
+
+ // Repeat the operation twice per value to test that histograms are reset.
+ for (let remember of [false, true, false, true]) {
+ // This change should be observed immediately by the login service.
+ Services.prefs.setBoolPref("signon.rememberSignons", remember);
+
+ triggerStatisticsCollection();
+
+ // Should record 1 in either bucket 0 or bucket 1 based on the preference.
+ testHistogram("PWMGR_SAVING_ENABLED", remember ? { 1: 1 } : { 0: 1 });
+ }
+});
diff --git a/toolkit/components/passwordmgr/test/unit/test_user_autocomplete_result.js b/toolkit/components/passwordmgr/test/unit/test_user_autocomplete_result.js
new file mode 100644
index 000000000..e1d250a76
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_user_autocomplete_result.js
@@ -0,0 +1,488 @@
+XPCOMUtils.defineLazyModuleGetter(this, "LoginHelper",
+ "resource://gre/modules/LoginHelper.jsm");
+Cu.import("resource://gre/modules/LoginManagerContent.jsm");
+var nsLoginInfo = Components.Constructor("@mozilla.org/login-manager/loginInfo;1",
+ Ci.nsILoginInfo, "init");
+
+const PREF_INSECURE_FIELD_WARNING_ENABLED = "security.insecure_field_warning.contextual.enabled";
+const PREF_INSECURE_AUTOFILLFORMS_ENABLED = "signon.autofillForms.http";
+
+let matchingLogins = [];
+matchingLogins.push(new nsLoginInfo("http://mochi.test:8888", "http://autocomplete:8888", null,
+ "", "emptypass1", "uname", "pword"));
+
+matchingLogins.push(new nsLoginInfo("http://mochi.test:8888", "http://autocomplete:8888", null,
+ "tempuser1", "temppass1", "uname", "pword"));
+
+matchingLogins.push(new nsLoginInfo("http://mochi.test:8888", "http://autocomplete:8888", null,
+ "testuser2", "testpass2", "uname", "pword"));
+
+matchingLogins.push(new nsLoginInfo("http://mochi.test:8888", "http://autocomplete:8888", null,
+ "testuser3", "testpass3", "uname", "pword"));
+
+matchingLogins.push(new nsLoginInfo("http://mochi.test:8888", "http://autocomplete:8888", null,
+ "zzzuser4", "zzzpass4", "uname", "pword"));
+
+let meta = matchingLogins[0].QueryInterface(Ci.nsILoginMetaInfo);
+let dateAndTimeFormatter = new Intl.DateTimeFormat(undefined,
+ { day: "numeric", month: "short", year: "numeric" });
+let time = dateAndTimeFormatter.format(new Date(meta.timePasswordChanged));
+const LABEL_NO_USERNAME = "No username (" + time + ")";
+
+let expectedResults = [
+ {
+ insecureFieldWarningEnabled: true,
+ insecureAutoFillFormsEnabled: true,
+ isSecure: true,
+ isPasswordField: false,
+ matchingLogins: matchingLogins,
+ items: [{
+ value: "",
+ label: LABEL_NO_USERNAME,
+ style: "login",
+ }, {
+ value: "tempuser1",
+ label: "tempuser1",
+ style: "login",
+ }, {
+ value: "testuser2",
+ label: "testuser2",
+ style: "login",
+ }, {
+ value: "testuser3",
+ label: "testuser3",
+ style: "login",
+ }, {
+ value: "zzzuser4",
+ label: "zzzuser4",
+ style: "login",
+ }]
+ },
+ {
+ insecureFieldWarningEnabled: true,
+ insecureAutoFillFormsEnabled: true,
+ isSecure: false,
+ isPasswordField: false,
+ matchingLogins: matchingLogins,
+ items: [{
+ value: "",
+ label: "This connection is not secure. Logins entered here could be compromised. Learn More",
+ style: "insecureWarning"
+ }, {
+ value: "",
+ label: LABEL_NO_USERNAME,
+ style: "login",
+ }, {
+ value: "tempuser1",
+ label: "tempuser1",
+ style: "login",
+ }, {
+ value: "testuser2",
+ label: "testuser2",
+ style: "login",
+ }, {
+ value: "testuser3",
+ label: "testuser3",
+ style: "login",
+ }, {
+ value: "zzzuser4",
+ label: "zzzuser4",
+ style: "login",
+ }]
+ },
+ {
+ insecureFieldWarningEnabled: true,
+ insecureAutoFillFormsEnabled: true,
+ isSecure: true,
+ isPasswordField: true,
+ matchingLogins: matchingLogins,
+ items: [{
+ value: "emptypass1",
+ label: LABEL_NO_USERNAME,
+ style: "login",
+ }, {
+ value: "temppass1",
+ label: "tempuser1",
+ style: "login",
+ }, {
+ value: "testpass2",
+ label: "testuser2",
+ style: "login",
+ }, {
+ value: "testpass3",
+ label: "testuser3",
+ style: "login",
+ }, {
+ value: "zzzpass4",
+ label: "zzzuser4",
+ style: "login",
+ }]
+ },
+ {
+ insecureFieldWarningEnabled: true,
+ insecureAutoFillFormsEnabled: true,
+ isSecure: false,
+ isPasswordField: true,
+ matchingLogins: matchingLogins,
+ items: [{
+ value: "",
+ label: "This connection is not secure. Logins entered here could be compromised. Learn More",
+ style: "insecureWarning"
+ }, {
+ value: "emptypass1",
+ label: LABEL_NO_USERNAME,
+ style: "login",
+ }, {
+ value: "temppass1",
+ label: "tempuser1",
+ style: "login",
+ }, {
+ value: "testpass2",
+ label: "testuser2",
+ style: "login",
+ }, {
+ value: "testpass3",
+ label: "testuser3",
+ style: "login",
+ }, {
+ value: "zzzpass4",
+ label: "zzzuser4",
+ style: "login",
+ }]
+ },
+ {
+ insecureFieldWarningEnabled: false,
+ insecureAutoFillFormsEnabled: true,
+ isSecure: true,
+ isPasswordField: false,
+ matchingLogins: matchingLogins,
+ items: [{
+ value: "",
+ label: LABEL_NO_USERNAME,
+ style: "login",
+ }, {
+ value: "tempuser1",
+ label: "tempuser1",
+ style: "login",
+ }, {
+ value: "testuser2",
+ label: "testuser2",
+ style: "login",
+ }, {
+ value: "testuser3",
+ label: "testuser3",
+ style: "login",
+ }, {
+ value: "zzzuser4",
+ label: "zzzuser4",
+ style: "login",
+ }]
+ },
+ {
+ insecureFieldWarningEnabled: false,
+ insecureAutoFillFormsEnabled: true,
+ isSecure: false,
+ isPasswordField: false,
+ matchingLogins: matchingLogins,
+ items: [{
+ value: "",
+ label: LABEL_NO_USERNAME,
+ style: "login",
+ }, {
+ value: "tempuser1",
+ label: "tempuser1",
+ style: "login",
+ }, {
+ value: "testuser2",
+ label: "testuser2",
+ style: "login",
+ }, {
+ value: "testuser3",
+ label: "testuser3",
+ style: "login",
+ }, {
+ value: "zzzuser4",
+ label: "zzzuser4",
+ style: "login",
+ }]
+ },
+ {
+ insecureFieldWarningEnabled: false,
+ insecureAutoFillFormsEnabled: true,
+ isSecure: true,
+ isPasswordField: true,
+ matchingLogins: matchingLogins,
+ items: [{
+ value: "emptypass1",
+ label: LABEL_NO_USERNAME,
+ style: "login",
+ }, {
+ value: "temppass1",
+ label: "tempuser1",
+ style: "login",
+ }, {
+ value: "testpass2",
+ label: "testuser2",
+ style: "login",
+ }, {
+ value: "testpass3",
+ label: "testuser3",
+ style: "login",
+ }, {
+ value: "zzzpass4",
+ label: "zzzuser4",
+ style: "login",
+ }]
+ },
+ {
+ insecureFieldWarningEnabled: false,
+ insecureAutoFillFormsEnabled: true,
+ isSecure: false,
+ isPasswordField: true,
+ matchingLogins: matchingLogins,
+ items: [{
+ value: "emptypass1",
+ label: LABEL_NO_USERNAME,
+ style: "login",
+ }, {
+ value: "temppass1",
+ label: "tempuser1",
+ style: "login",
+ }, {
+ value: "testpass2",
+ label: "testuser2",
+ style: "login",
+ }, {
+ value: "testpass3",
+ label: "testuser3",
+ style: "login",
+ }, {
+ value: "zzzpass4",
+ label: "zzzuser4",
+ style: "login",
+ }]
+ },
+ {
+ insecureFieldWarningEnabled: true,
+ insecureAutoFillFormsEnabled: false,
+ isSecure: true,
+ isPasswordField: false,
+ matchingLogins: matchingLogins,
+ items: [{
+ value: "",
+ label: LABEL_NO_USERNAME,
+ style: "login",
+ }, {
+ value: "tempuser1",
+ label: "tempuser1",
+ style: "login",
+ }, {
+ value: "testuser2",
+ label: "testuser2",
+ style: "login",
+ }, {
+ value: "testuser3",
+ label: "testuser3",
+ style: "login",
+ }, {
+ value: "zzzuser4",
+ label: "zzzuser4",
+ style: "login",
+ }]
+ },
+ {
+ insecureFieldWarningEnabled: true,
+ insecureAutoFillFormsEnabled: false,
+ isSecure: false,
+ isPasswordField: false,
+ matchingLogins: matchingLogins,
+ items: [{
+ value: "",
+ label: "This connection is not secure. Logins entered here could be compromised. Learn More",
+ style: "insecureWarning"
+ }, {
+ value: "",
+ label: LABEL_NO_USERNAME,
+ style: "login",
+ }, {
+ value: "tempuser1",
+ label: "tempuser1",
+ style: "login",
+ }, {
+ value: "testuser2",
+ label: "testuser2",
+ style: "login",
+ }, {
+ value: "testuser3",
+ label: "testuser3",
+ style: "login",
+ }, {
+ value: "zzzuser4",
+ label: "zzzuser4",
+ style: "login",
+ }]
+ },
+ {
+ insecureFieldWarningEnabled: true,
+ insecureAutoFillFormsEnabled: false,
+ isSecure: true,
+ isPasswordField: true,
+ matchingLogins: matchingLogins,
+ items: [{
+ value: "emptypass1",
+ label: LABEL_NO_USERNAME,
+ style: "login",
+ }, {
+ value: "temppass1",
+ label: "tempuser1",
+ style: "login",
+ }, {
+ value: "testpass2",
+ label: "testuser2",
+ style: "login",
+ }, {
+ value: "testpass3",
+ label: "testuser3",
+ style: "login",
+ }, {
+ value: "zzzpass4",
+ label: "zzzuser4",
+ style: "login",
+ }]
+ },
+ {
+ insecureFieldWarningEnabled: true,
+ insecureAutoFillFormsEnabled: false,
+ isSecure: false,
+ isPasswordField: true,
+ matchingLogins: matchingLogins,
+ items: [{
+ value: "",
+ label: "This connection is not secure. Logins entered here could be compromised. Learn More",
+ style: "insecureWarning"
+ }, {
+ value: "emptypass1",
+ label: LABEL_NO_USERNAME,
+ style: "login",
+ }, {
+ value: "temppass1",
+ label: "tempuser1",
+ style: "login",
+ }, {
+ value: "testpass2",
+ label: "testuser2",
+ style: "login",
+ }, {
+ value: "testpass3",
+ label: "testuser3",
+ style: "login",
+ }, {
+ value: "zzzpass4",
+ label: "zzzuser4",
+ style: "login",
+ }]
+ },
+ {
+ insecureFieldWarningEnabled: false,
+ insecureAutoFillFormsEnabled: false,
+ isSecure: true,
+ isPasswordField: false,
+ matchingLogins: matchingLogins,
+ items: [{
+ value: "",
+ label: LABEL_NO_USERNAME,
+ style: "login",
+ }, {
+ value: "tempuser1",
+ label: "tempuser1",
+ style: "login",
+ }, {
+ value: "testuser2",
+ label: "testuser2",
+ style: "login",
+ }, {
+ value: "testuser3",
+ label: "testuser3",
+ style: "login",
+ }, {
+ value: "zzzuser4",
+ label: "zzzuser4",
+ style: "login",
+ }]
+ },
+ {
+ insecureFieldWarningEnabled: false,
+ insecureAutoFillFormsEnabled: false,
+ isSecure: false,
+ isPasswordField: false,
+ matchingLogins: matchingLogins,
+ items: []
+ },
+ {
+ insecureFieldWarningEnabled: false,
+ insecureAutoFillFormsEnabled: false,
+ isSecure: true,
+ isPasswordField: true,
+ matchingLogins: matchingLogins,
+ items: [{
+ value: "emptypass1",
+ label: LABEL_NO_USERNAME,
+ style: "login",
+ }, {
+ value: "temppass1",
+ label: "tempuser1",
+ style: "login",
+ }, {
+ value: "testpass2",
+ label: "testuser2",
+ style: "login",
+ }, {
+ value: "testpass3",
+ label: "testuser3",
+ style: "login",
+ }, {
+ value: "zzzpass4",
+ label: "zzzuser4",
+ style: "login",
+ }]
+ },
+ {
+ insecureFieldWarningEnabled: false,
+ insecureAutoFillFormsEnabled: false,
+ isSecure: false,
+ isPasswordField: true,
+ matchingLogins: matchingLogins,
+ items: []
+ },
+];
+
+add_task(function* test_all_patterns() {
+ LoginHelper.createLogger("UserAutoCompleteResult");
+ expectedResults.forEach(pattern => {
+ Services.prefs.setBoolPref(PREF_INSECURE_FIELD_WARNING_ENABLED,
+ pattern.insecureFieldWarningEnabled);
+ Services.prefs.setBoolPref(PREF_INSECURE_AUTOFILLFORMS_ENABLED,
+ pattern.insecureAutoFillFormsEnabled);
+ let actual = new UserAutoCompleteResult("", pattern.matchingLogins,
+ {
+ isSecure: pattern.isSecure,
+ isPasswordField: pattern.isPasswordField
+ });
+ pattern.items.forEach((item, index) => {
+ equal(actual.getValueAt(index), item.value);
+ equal(actual.getLabelAt(index), item.label);
+ equal(actual.getStyleAt(index), item.style);
+ });
+
+ if (pattern.items.length != 0) {
+ Assert.throws(() => actual.getValueAt(pattern.items.length),
+ /Index out of range\./);
+
+ Assert.throws(() => actual.getLabelAt(pattern.items.length),
+ /Index out of range\./);
+
+ Assert.throws(() => actual.removeValueAt(pattern.items.length, true),
+ /Index out of range\./);
+ }
+ });
+});
diff --git a/toolkit/components/passwordmgr/test/unit/xpcshell.ini b/toolkit/components/passwordmgr/test/unit/xpcshell.ini
new file mode 100644
index 000000000..8f8c92a28
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/xpcshell.ini
@@ -0,0 +1,46 @@
+[DEFAULT]
+head = head.js
+tail =
+support-files = data/**
+
+# Test JSON file access and import from SQLite, not applicable to Android.
+[test_module_LoginImport.js]
+skip-if = os == "android"
+[test_module_LoginStore.js]
+skip-if = os == "android"
+[test_removeLegacySignonFiles.js]
+skip-if = os == "android"
+
+# Test SQLite database backup and migration, applicable to Android only.
+[test_storage_mozStorage.js]
+skip-if = true || os != "android" # Bug 1171687: Needs fixing on Android
+
+# The following tests apply to any storage back-end.
+[test_context_menu.js]
+skip-if = os == "android" # The context menu isn't used on Android.
+# LoginManagerContextMenu is only included for MOZ_BUILD_APP == 'browser'.
+run-if = buildapp == "browser"
+[test_dedupeLogins.js]
+[test_disabled_hosts.js]
+[test_getFormFields.js]
+[test_getPasswordFields.js]
+[test_getPasswordOrigin.js]
+[test_isOriginMatching.js]
+[test_legacy_empty_formSubmitURL.js]
+[test_legacy_validation.js]
+[test_logins_change.js]
+[test_logins_decrypt_failure.js]
+skip-if = os == "android" # Bug 1171687: Needs fixing on Android
+[test_user_autocomplete_result.js]
+skip-if = os == "android"
+[test_logins_metainfo.js]
+[test_logins_search.js]
+[test_maybeImportLogin.js]
+[test_notifications.js]
+[test_OSCrypto_win.js]
+skip-if = os != "win"
+[test_recipes_add.js]
+[test_recipes_content.js]
+[test_search_schemeUpgrades.js]
+[test_storage.js]
+[test_telemetry.js]