summaryrefslogtreecommitdiffstats
path: root/toolkit/components/passwordmgr/test/unit
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-28 14:29:10 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-28 14:29:10 +0000
commit2aa4a82499d4becd2284cdb482213d541b8804dd (patch)
treeb80bf8bf13c3766139fbacc530efd0dd9d54394c /toolkit/components/passwordmgr/test/unit
parentInitial commit. (diff)
downloadfirefox-2aa4a82499d4becd2284cdb482213d541b8804dd.tar.xz
firefox-2aa4a82499d4becd2284cdb482213d541b8804dd.zip
Adding upstream version 86.0.1.upstream/86.0.1upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'toolkit/components/passwordmgr/test/unit')
-rw-r--r--toolkit/components/passwordmgr/test/unit/data/corruptDB.sqlitebin0 -> 32772 bytes
-rw-r--r--toolkit/components/passwordmgr/test/unit/data/key4.dbbin0 -> 294912 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.js133
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_LoginManagerParent_doAutocompleteSearch.js142
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_LoginManagerParent_getGeneratedPassword.js127
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_LoginManagerParent_onPasswordEditedOrGenerated.js1124
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_LoginManagerParent_searchAndDedupeLogins.js195
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_LoginManagerPrompter_getUsernameSuggestions.js189
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_OSCrypto_win.js447
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_PasswordGenerator.js78
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_context_menu.js347
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_dedupeLogins.js423
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_disabled_hosts.js223
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_displayOrigin.js45
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_doLoginsMatch.js57
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_getFormFields.js359
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_getPasswordFields.js313
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_getPasswordOrigin.js31
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_getUserNameAndPasswordFields.js151
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_isOriginMatching.js177
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_isProbablyANewPasswordField.js170
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_isUsernameFieldType.js160
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_legacy_empty_formActionOrigin.js123
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_legacy_validation.js94
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_login_autocomplete_result.js1321
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_loginsBackup.js215
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_logins_change.js548
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_logins_decrypt_failure.js176
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_logins_metainfo.js302
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_logins_search.js234
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_maybeImportLogin.js368
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_module_LoginCSVImport.js684
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_module_LoginExport.js217
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_module_LoginStore.js342
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_notifications.js193
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_recipes_add.js288
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_recipes_content.js53
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_remote_recipes.js159
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_search_schemeUpgrades.js241
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_shadowHTTPLogins.js86
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_storage.js95
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_telemetry.js201
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_vulnerable_passwords.js47
-rw-r--r--toolkit/components/passwordmgr/test/unit/xpcshell.ini66
55 files changed, 10944 insertions, 0 deletions
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 0000000000..b234246cac
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/data/corruptDB.sqlite
Binary files differ
diff --git a/toolkit/components/passwordmgr/test/unit/data/key4.db b/toolkit/components/passwordmgr/test/unit/data/key4.db
new file mode 100644
index 0000000000..b75a14aa8e
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/data/key4.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 0000000000..fe030b61fd
--- /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 0000000000..729512a12b
--- /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 0000000000..a6c72b31e8
--- /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 0000000000..359df5d311
--- /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 0000000000..918f4142fe
--- /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 0000000000..e06c33aae3
--- /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 0000000000..227c09c816
--- /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 0000000000..4534cf2553
--- /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 0000000000..eb4ee6d01e
--- /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 0000000000..e09c4f7100
--- /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 0000000000..0328a1a02a
--- /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 0000000000..e7c9205257
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/head.js
@@ -0,0 +1,133 @@
+/**
+ * Provides infrastructure for automated login components tests.
+ */
+
+"use strict";
+
+// Globals
+
+const { AppConstants } = ChromeUtils.import(
+ "resource://gre/modules/AppConstants.jsm"
+);
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+const { LoginRecipesContent, LoginRecipesParent } = ChromeUtils.import(
+ "resource://gre/modules/LoginRecipes.jsm"
+);
+const { LoginHelper } = ChromeUtils.import(
+ "resource://gre/modules/LoginHelper.jsm"
+);
+const { FileTestUtils } = ChromeUtils.import(
+ "resource://testing-common/FileTestUtils.jsm"
+);
+const { LoginTestUtils } = ChromeUtils.import(
+ "resource://testing-common/LoginTestUtils.jsm"
+);
+const { MockDocument } = ChromeUtils.import(
+ "resource://testing-common/MockDocument.jsm"
+);
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "DownloadPaths",
+ "resource://gre/modules/DownloadPaths.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "FileUtils",
+ "resource://gre/modules/FileUtils.jsm"
+);
+ChromeUtils.defineModuleGetter(this, "OS", "resource://gre/modules/osfile.jsm");
+
+const LoginInfo = Components.Constructor(
+ "@mozilla.org/login-manager/loginInfo;1",
+ "nsILoginInfo",
+ "init"
+);
+
+const TestData = LoginTestUtils.testData;
+const newPropertyBag = LoginHelper.newPropertyBag;
+
+const NEW_PASSWORD_HEURISTIC_ENABLED_PREF =
+ "signon.generation.confidenceThreshold";
+
+/**
+ * All the tests are implemented with add_task, this starts them automatically.
+ */
+function run_test() {
+ do_get_profile();
+ run_next_test();
+}
+
+// Global helpers
+
+/**
+ * Returns a reference to a temporary file that is guaranteed not to exist and
+ * is cleaned up later. See FileTestUtils.getTempFile for details.
+ */
+function getTempFile(leafName) {
+ return FileTestUtils.getTempFile(leafName);
+}
+
+const RecipeHelpers = {
+ initNewParent() {
+ return new LoginRecipesParent({ defaults: null }).initializationPromise;
+ },
+};
+
+// Initialization functions common to all tests
+
+add_task(async 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.
+ const keyDBName = "key4.db";
+ await OS.File.copy(
+ do_get_file(`data/${keyDBName}`).path,
+ OS.Path.join(OS.Constants.Path.profileDir, keyDBName)
+ );
+
+ // Ensure that the service and the storage module are initialized.
+ await Services.logins.initializationPromise;
+});
+
+add_task(async function test_common_prefs() {
+ Services.prefs.setStringPref(NEW_PASSWORD_HEURISTIC_ENABLED_PREF, "0.75");
+});
+
+/**
+ * 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_LoginManagerParent_doAutocompleteSearch.js b/toolkit/components/passwordmgr/test/unit/test_LoginManagerParent_doAutocompleteSearch.js
new file mode 100644
index 0000000000..b2a6958d70
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_LoginManagerParent_doAutocompleteSearch.js
@@ -0,0 +1,142 @@
+/**
+ * Test LoginManagerParent.doAutocompleteSearch()
+ */
+
+"use strict";
+
+const { sinon } = ChromeUtils.import("resource://testing-common/Sinon.jsm");
+const { LoginManagerParent } = ChromeUtils.import(
+ "resource://gre/modules/LoginManagerParent.jsm"
+);
+
+// new-password to the happy path
+const NEW_PASSWORD_TEMPLATE_ARG = {
+ actionOrigin: "https://mozilla.org",
+ searchString: "",
+ previousResult: null,
+ requestId: "foo",
+ hasBeenTypePassword: true,
+ isSecure: true,
+ isProbablyANewPasswordField: true,
+};
+
+add_task(async function setup() {
+ Services.prefs.setBoolPref("signon.generation.available", true);
+ Services.prefs.setBoolPref("signon.generation.enabled", true);
+
+ sinon
+ .stub(LoginManagerParent._browsingContextGlobal, "get")
+ .withArgs(123)
+ .callsFake(() => {
+ return {
+ currentWindowGlobal: {
+ documentPrincipal: Services.scriptSecurityManager.createContentPrincipalFromOrigin(
+ "https://www.example.com^userContextId=1"
+ ),
+ },
+ };
+ });
+});
+
+add_task(async function test_generated_noLogins() {
+ let LMP = new LoginManagerParent();
+ LMP.useBrowsingContext(123);
+
+ ok(LMP.doAutocompleteSearch, "doAutocompleteSearch exists");
+
+ let result1 = await LMP.doAutocompleteSearch(
+ "https://example.com",
+ NEW_PASSWORD_TEMPLATE_ARG
+ );
+ equal(result1.logins.length, 0, "no logins");
+ ok(result1.generatedPassword, "has a generated password");
+ equal(result1.generatedPassword.length, 15, "generated password length");
+ ok(
+ result1.willAutoSaveGeneratedPassword,
+ "will auto-save when storage is empty"
+ );
+
+ info("repeat the search and ensure the same password was used");
+ let result2 = await LMP.doAutocompleteSearch(
+ "https://example.com",
+ NEW_PASSWORD_TEMPLATE_ARG
+ );
+ equal(result2.logins.length, 0, "no logins");
+ equal(
+ result2.generatedPassword,
+ result1.generatedPassword,
+ "same generated password"
+ );
+ ok(
+ result1.willAutoSaveGeneratedPassword,
+ "will auto-save when storage is still empty"
+ );
+
+ info("Check cases where a password shouldn't be generated");
+
+ let result3 = await LMP.doAutocompleteSearch("https://example.com", {
+ ...NEW_PASSWORD_TEMPLATE_ARG,
+ ...{
+ hasBeenTypePassword: false,
+ isProbablyANewPasswordField: false,
+ },
+ });
+ equal(
+ result3.generatedPassword,
+ null,
+ "no generated password when not a pw. field"
+ );
+
+ let result4 = await LMP.doAutocompleteSearch("https://example.com", {
+ ...NEW_PASSWORD_TEMPLATE_ARG,
+ ...{
+ // This is false when there is no autocomplete="new-password" attribute &&
+ // LoginAutoComplete._isProbablyANewPasswordField returns false
+ isProbablyANewPasswordField: false,
+ },
+ });
+ equal(
+ result4.generatedPassword,
+ null,
+ "no generated password when isProbablyANewPasswordField is false"
+ );
+
+ LMP.useBrowsingContext(999);
+ let result5 = await LMP.doAutocompleteSearch("https://example.com", {
+ ...NEW_PASSWORD_TEMPLATE_ARG,
+ });
+ equal(
+ result5.generatedPassword,
+ null,
+ "no generated password with a missing browsingContextId"
+ );
+});
+
+add_task(async function test_generated_emptyUsernameSavedLogin() {
+ info("Test with a login that will prevent auto-saving");
+ await LoginTestUtils.addLogin({
+ username: "",
+ password: "my-saved-password",
+ origin: "https://example.com",
+ formActionOrigin: NEW_PASSWORD_TEMPLATE_ARG.actionOrigin,
+ });
+
+ let LMP = new LoginManagerParent();
+ LMP.useBrowsingContext(123);
+
+ ok(LMP.doAutocompleteSearch, "doAutocompleteSearch exists");
+
+ let result1 = await LMP.doAutocompleteSearch(
+ "https://example.com",
+ NEW_PASSWORD_TEMPLATE_ARG
+ );
+ equal(result1.logins.length, 1, "1 login");
+ ok(result1.generatedPassword, "has a generated password");
+ equal(result1.generatedPassword.length, 15, "generated password length");
+ ok(
+ !result1.willAutoSaveGeneratedPassword,
+ "won't auto-save when an empty-username match is found"
+ );
+
+ LoginTestUtils.clearData();
+});
diff --git a/toolkit/components/passwordmgr/test/unit/test_LoginManagerParent_getGeneratedPassword.js b/toolkit/components/passwordmgr/test/unit/test_LoginManagerParent_getGeneratedPassword.js
new file mode 100644
index 0000000000..8dd8b17dfc
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_LoginManagerParent_getGeneratedPassword.js
@@ -0,0 +1,127 @@
+/**
+ * Test LoginManagerParent.getGeneratedPassword()
+ */
+
+"use strict";
+
+const { sinon } = ChromeUtils.import("resource://testing-common/Sinon.jsm");
+const { LoginManagerParent } = ChromeUtils.import(
+ "resource://gre/modules/LoginManagerParent.jsm"
+);
+
+add_task(async function test_getGeneratedPassword() {
+ // Force the feature to be enabled.
+ Services.prefs.setBoolPref("signon.generation.available", true);
+ Services.prefs.setBoolPref("signon.generation.enabled", true);
+
+ let LMP = new LoginManagerParent();
+ LMP.useBrowsingContext(99);
+
+ ok(LMP.getGeneratedPassword, "LMP.getGeneratedPassword exists");
+ equal(
+ LoginManagerParent.getGeneratedPasswordsByPrincipalOrigin().size,
+ 0,
+ "Empty cache to start"
+ );
+
+ equal(LMP.getGeneratedPassword(), null, "Null with no BrowsingContext");
+
+ ok(
+ LoginManagerParent._browsingContextGlobal,
+ "Check _browsingContextGlobal exists"
+ );
+ ok(
+ !LoginManagerParent._browsingContextGlobal.get(99),
+ "BrowsingContext 99 shouldn't exist yet"
+ );
+ info("Stubbing BrowsingContext.get(99)");
+ sinon
+ .stub(LoginManagerParent._browsingContextGlobal, "get")
+ .withArgs(99)
+ .callsFake(() => {
+ return {
+ currentWindowGlobal: {
+ documentPrincipal: Services.scriptSecurityManager.createContentPrincipalFromOrigin(
+ "https://www.example.com^userContextId=6"
+ ),
+ },
+ };
+ });
+ ok(
+ LoginManagerParent._browsingContextGlobal.get(99),
+ "Checking BrowsingContext.get(99) stub"
+ );
+
+ let password1 = LMP.getGeneratedPassword();
+ notEqual(password1, null, "Check password was returned");
+ equal(
+ password1.length,
+ LoginTestUtils.generation.LENGTH,
+ "Check password length"
+ );
+ equal(
+ LoginManagerParent.getGeneratedPasswordsByPrincipalOrigin().size,
+ 1,
+ "1 added to cache"
+ );
+ equal(
+ LoginManagerParent.getGeneratedPasswordsByPrincipalOrigin().get(
+ "https://www.example.com^userContextId=6"
+ ).value,
+ password1,
+ "Cache key and value"
+ );
+ let password2 = LMP.getGeneratedPassword();
+ equal(
+ password1,
+ password2,
+ "Same password should be returned for the same origin"
+ );
+
+ info("Changing the documentPrincipal to simulate a navigation in the frame");
+ LoginManagerParent._browsingContextGlobal.get.restore();
+ sinon
+ .stub(LoginManagerParent._browsingContextGlobal, "get")
+ .withArgs(99)
+ .callsFake(() => {
+ return {
+ currentWindowGlobal: {
+ documentPrincipal: Services.scriptSecurityManager.createContentPrincipalFromOrigin(
+ "https://www.mozilla.org^userContextId=2"
+ ),
+ },
+ };
+ });
+ let password3 = LMP.getGeneratedPassword();
+ notEqual(
+ password2,
+ password3,
+ "Different password for a different origin for the same BC"
+ );
+ equal(
+ password3.length,
+ LoginTestUtils.generation.LENGTH,
+ "Check password3 length"
+ );
+
+ info("Now checks cases where null should be returned");
+
+ Services.prefs.setBoolPref("signon.rememberSignons", false);
+ equal(LMP.getGeneratedPassword(), null, "Prevented when pwmgr disabled");
+ Services.prefs.setBoolPref("signon.rememberSignons", true);
+
+ Services.prefs.setBoolPref("signon.generation.available", false);
+ equal(LMP.getGeneratedPassword(), null, "Prevented when unavailable");
+ Services.prefs.setBoolPref("signon.generation.available", true);
+
+ Services.prefs.setBoolPref("signon.generation.enabled", false);
+ equal(LMP.getGeneratedPassword(), null, "Prevented when disabled");
+ Services.prefs.setBoolPref("signon.generation.enabled", true);
+
+ LMP.useBrowsingContext(123);
+ equal(
+ LMP.getGeneratedPassword(),
+ null,
+ "Prevented when browsingContext is missing"
+ );
+});
diff --git a/toolkit/components/passwordmgr/test/unit/test_LoginManagerParent_onPasswordEditedOrGenerated.js b/toolkit/components/passwordmgr/test/unit/test_LoginManagerParent_onPasswordEditedOrGenerated.js
new file mode 100644
index 0000000000..a84807c8a9
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_LoginManagerParent_onPasswordEditedOrGenerated.js
@@ -0,0 +1,1124 @@
+/**
+ * Test LoginManagerParent._onPasswordEditedOrGenerated()
+ */
+
+"use strict";
+
+const { sinon } = ChromeUtils.import("resource://testing-common/Sinon.jsm");
+const { LoginManagerParent } = ChromeUtils.import(
+ "resource://gre/modules/LoginManagerParent.jsm"
+);
+const { LoginManagerPrompter } = ChromeUtils.import(
+ "resource://gre/modules/LoginManagerPrompter.jsm"
+);
+const { PopupNotifications } = ChromeUtils.import(
+ "resource://gre/modules/PopupNotifications.jsm"
+);
+
+const { TestUtils } = ChromeUtils.import(
+ "resource://testing-common/TestUtils.jsm"
+);
+const loginTemplate = Object.freeze({
+ origin: "https://www.example.com",
+ formActionOrigin: "https://www.mozilla.org",
+});
+
+let LMP = new LoginManagerParent();
+
+function stubPrompter() {
+ let fakePromptToSavePassword = sinon.stub();
+ let fakePromptToChangePassword = sinon.stub();
+ sinon.stub(LMP, "_getPrompter").callsFake(() => {
+ return {
+ promptToSavePassword: fakePromptToSavePassword,
+ promptToChangePassword: fakePromptToChangePassword,
+ };
+ });
+ LMP._getPrompter().promptToSavePassword();
+ LMP._getPrompter().promptToChangePassword();
+ ok(LMP._getPrompter.calledTwice, "Checking _getPrompter stub");
+ ok(
+ fakePromptToSavePassword.calledOnce,
+ "Checking fakePromptToSavePassword stub"
+ );
+ ok(
+ fakePromptToChangePassword.calledOnce,
+ "Checking fakePromptToChangePassword stub"
+ );
+ function resetPrompterHistory() {
+ LMP._getPrompter.resetHistory();
+ fakePromptToSavePassword.resetHistory();
+ fakePromptToChangePassword.resetHistory();
+ }
+ function restorePrompter() {
+ LMP._getPrompter.restore();
+ }
+ resetPrompterHistory();
+ return {
+ fakePromptToSavePassword,
+ fakePromptToChangePassword,
+ resetPrompterHistory,
+ restorePrompter,
+ };
+}
+
+function stubGeneratedPasswordForBrowsingContextId(id) {
+ ok(
+ LoginManagerParent._browsingContextGlobal,
+ "Check _browsingContextGlobal exists"
+ );
+ ok(
+ !LoginManagerParent._browsingContextGlobal.get(id),
+ `BrowsingContext ${id} shouldn't exist yet`
+ );
+ info(`Stubbing BrowsingContext.get(${id})`);
+ let stub = sinon
+ .stub(LoginManagerParent._browsingContextGlobal, "get")
+ .withArgs(id)
+ .callsFake(() => {
+ return {
+ currentWindowGlobal: {
+ documentPrincipal: Services.scriptSecurityManager.createContentPrincipalFromOrigin(
+ "https://www.example.com^userContextId=6"
+ ),
+ },
+ get embedderElement() {
+ info("returning embedderElement");
+ let browser = MockDocument.createTestDocument(
+ "chrome://browser/content/browser.xhtml",
+ `<box xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul">
+ <browser></browser>
+ </box>`,
+ "application/xml",
+ true
+ ).querySelector("browser");
+ MockDocument.mockBrowsingContextProperty(browser, this);
+ return browser;
+ },
+ get top() {
+ return this;
+ },
+ };
+ });
+ ok(
+ LoginManagerParent._browsingContextGlobal.get(id),
+ `Checking BrowsingContext.get(${id}) stub`
+ );
+
+ let generatedPassword = LMP.getGeneratedPassword(id);
+ notEqual(generatedPassword, null, "Check password was returned");
+ equal(
+ generatedPassword.length,
+ LoginTestUtils.generation.LENGTH,
+ "Check password length"
+ );
+ equal(
+ LoginManagerParent.getGeneratedPasswordsByPrincipalOrigin().size,
+ 1,
+ "1 added to cache"
+ );
+ equal(
+ LoginManagerParent.getGeneratedPasswordsByPrincipalOrigin().get(
+ "https://www.example.com^userContextId=6"
+ ).value,
+ generatedPassword,
+ "Cache key and value"
+ );
+ LoginManagerParent._browsingContextGlobal.get.resetHistory();
+
+ return {
+ stub,
+ generatedPassword,
+ };
+}
+
+function checkEditTelemetryRecorded(expectedCount, msg) {
+ info("Check that expected telemetry event was recorded");
+ const snapshot = Services.telemetry.snapshotEvents(
+ Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS,
+ false
+ );
+ let resultsCount = 0;
+ if ("parent" in snapshot) {
+ const telemetryProps = Object.freeze({
+ category: "pwmgr",
+ method: "filled_field_edited",
+ object: "generatedpassword",
+ });
+ const results = snapshot.parent.filter(
+ ([time, category, method, object]) => {
+ return (
+ category === telemetryProps.category &&
+ method === telemetryProps.method &&
+ object === telemetryProps.object
+ );
+ }
+ );
+ resultsCount = results.length;
+ }
+ equal(
+ resultsCount,
+ expectedCount,
+ "Check count of pwmgr.filled_field_edited for generatedpassword: " + msg
+ );
+}
+
+function startTestConditions(contextId) {
+ LMP.useBrowsingContext(contextId);
+
+ ok(
+ LMP._onPasswordEditedOrGenerated,
+ "LMP._onPasswordEditedOrGenerated exists"
+ );
+ equal(LMP.getGeneratedPassword(), null, "Null with no BrowsingContext");
+ equal(
+ LoginManagerParent.getGeneratedPasswordsByPrincipalOrigin().size,
+ 0,
+ "Empty cache to start"
+ );
+ equal(
+ Services.logins.getAllLogins().length,
+ 0,
+ "Should have no saved logins at the start of the test"
+ );
+}
+
+/*
+ * Compare login details excluding usernameField and passwordField
+ */
+function assertLoginProperties(actualLogin, expected) {
+ equal(actualLogin.origin, expected.origin, "Compare origin");
+ equal(
+ actualLogin.formActionOrigin,
+ expected.formActionOrigin,
+ "Compare formActionOrigin"
+ );
+ equal(actualLogin.httpRealm, expected.httpRealm, "Compare httpRealm");
+ equal(actualLogin.username, expected.username, "Compare username");
+ equal(actualLogin.password, expected.password, "Compare password");
+}
+
+add_task(async function setup() {
+ // Get a profile for storage.
+ do_get_profile();
+
+ // Force the feature to be enabled.
+ Services.prefs.setBoolPref("signon.generation.available", true);
+ Services.prefs.setBoolPref("signon.generation.enabled", true);
+});
+
+add_task(async function test_onPasswordEditedOrGenerated_generatedPassword() {
+ startTestConditions(99);
+ let { generatedPassword } = stubGeneratedPasswordForBrowsingContextId(99);
+ let { fakePromptToChangePassword, restorePrompter } = stubPrompter();
+ let rootBrowser = LMP.getRootBrowser();
+
+ let storageChangedPromised = TestUtils.topicObserved(
+ "passwordmgr-storage-changed",
+ (_, data) => data == "addLogin"
+ );
+
+ equal(
+ Services.logins.getAllLogins().length,
+ 0,
+ "Should have no saved logins at the start of the test"
+ );
+
+ await LMP._onPasswordEditedOrGenerated(
+ rootBrowser,
+ "https://www.example.com",
+ {
+ browsingContextId: 99,
+ formActionOrigin: "https://www.mozilla.org",
+ newPasswordField: { value: generatedPassword },
+ usernameField: { value: "someusername" },
+ triggeredByFillingGenerated: true,
+ }
+ );
+
+ let [login] = await storageChangedPromised;
+ let expected = new LoginInfo(
+ "https://www.example.com",
+ "https://www.mozilla.org",
+ null,
+ "", // verify we don't include the username when auto-saving a login
+ generatedPassword
+ );
+
+ ok(login.equals(expected), "Check added login");
+ ok(LMP._getPrompter.calledOnce, "Checking _getPrompter was called");
+ ok(
+ fakePromptToChangePassword.calledOnce,
+ "Checking promptToChangePassword was called"
+ );
+ ok(
+ fakePromptToChangePassword.getCall(0).args[3],
+ "promptToChangePassword had a truthy 'dismissed' argument"
+ );
+ ok(
+ fakePromptToChangePassword.getCall(0).args[4],
+ "promptToChangePassword had a truthy 'notifySaved' argument"
+ );
+
+ info("Edit the password");
+ const newPassword = generatedPassword + "🔥";
+ storageChangedPromised = TestUtils.topicObserved(
+ "passwordmgr-storage-changed",
+ (_, data) => data == "modifyLogin"
+ );
+ await LMP._onPasswordEditedOrGenerated(
+ rootBrowser,
+ "https://www.example.com",
+ {
+ browsingContextId: 99,
+ formActionOrigin: "https://www.mozilla.org",
+ newPasswordField: { value: newPassword },
+ usernameField: { value: "someusername" },
+ triggeredByFillingGenerated: true,
+ }
+ );
+ let generatedPW = LoginManagerParent.getGeneratedPasswordsByPrincipalOrigin().get(
+ "https://www.example.com^userContextId=6"
+ );
+ ok(generatedPW.edited, "Cached edited boolean should be true");
+ equal(generatedPW.value, newPassword, "Cached password should be updated");
+ // login metadata should be updated
+ let [dataArray] = await storageChangedPromised;
+ login = dataArray.queryElementAt(1, Ci.nsILoginInfo);
+ expected.password = newPassword;
+ ok(login.equals(expected), "Check updated login");
+ equal(
+ Services.logins.getAllLogins().length,
+ 1,
+ "Should have 1 saved login still"
+ );
+
+ info(
+ "Simulate a second edit to check that the telemetry event for the first edit is not recorded twice"
+ );
+ const newerPassword = newPassword + "🦊";
+ storageChangedPromised = TestUtils.topicObserved(
+ "passwordmgr-storage-changed",
+ (_, data) => data == "modifyLogin"
+ );
+ await LMP._onPasswordEditedOrGenerated(
+ rootBrowser,
+ "https://www.example.com",
+ {
+ browsingContextId: 99,
+ formActionOrigin: "https://www.mozilla.org",
+ newPasswordField: { value: newerPassword },
+ usernameField: { value: "someusername" },
+ triggeredByFillingGenerated: true,
+ }
+ );
+ generatedPW = LoginManagerParent.getGeneratedPasswordsByPrincipalOrigin().get(
+ "https://www.example.com^userContextId=6"
+ );
+ ok(generatedPW.edited, "Cached edited state should remain true");
+ equal(generatedPW.value, newerPassword, "Cached password should be updated");
+ [dataArray] = await storageChangedPromised;
+ login = dataArray.queryElementAt(1, Ci.nsILoginInfo);
+ expected.password = newerPassword;
+ ok(login.equals(expected), "Check updated login");
+ equal(
+ Services.logins.getAllLogins().length,
+ 1,
+ "Should have 1 saved login still"
+ );
+
+ checkEditTelemetryRecorded(1, "with auto-save");
+
+ LoginManagerParent._browsingContextGlobal.get.restore();
+ restorePrompter();
+ LoginManagerParent.getGeneratedPasswordsByPrincipalOrigin().clear();
+ Services.logins.removeAllUserFacingLogins();
+ Services.telemetry.clearEvents();
+});
+
+add_task(
+ async function test_onPasswordEditedOrGenerated_editToEmpty_generatedPassword() {
+ startTestConditions(99);
+ let { generatedPassword } = stubGeneratedPasswordForBrowsingContextId(99);
+ let { fakePromptToChangePassword, restorePrompter } = stubPrompter();
+ let rootBrowser = LMP.getRootBrowser();
+
+ let storageChangedPromised = TestUtils.topicObserved(
+ "passwordmgr-storage-changed",
+ (_, data) => data == "addLogin"
+ );
+
+ equal(
+ Services.logins.getAllLogins().length,
+ 0,
+ "Should have no saved logins at the start of the test"
+ );
+
+ await LMP._onPasswordEditedOrGenerated(
+ rootBrowser,
+ "https://www.example.com",
+ {
+ browsingContextId: 99,
+ formActionOrigin: "https://www.mozilla.org",
+ newPasswordField: { value: generatedPassword },
+ usernameField: { value: "someusername" },
+ triggeredByFillingGenerated: true,
+ }
+ );
+
+ let [login] = await storageChangedPromised;
+ let expected = new LoginInfo(
+ "https://www.example.com",
+ "https://www.mozilla.org",
+ null,
+ "", // verify we don't include the username when auto-saving a login
+ generatedPassword
+ );
+
+ ok(login.equals(expected), "Check added login");
+ ok(LMP._getPrompter.calledOnce, "Checking _getPrompter was called");
+ ok(
+ fakePromptToChangePassword.calledOnce,
+ "Checking promptToChangePassword was called"
+ );
+ ok(
+ fakePromptToChangePassword.getCall(0).args[3],
+ "promptToChangePassword had a truthy 'dismissed' argument"
+ );
+ ok(
+ fakePromptToChangePassword.getCall(0).args[4],
+ "promptToChangePassword had a truthy 'notifySaved' argument"
+ );
+
+ info("Edit the password to be empty");
+ const newPassword = "";
+ await LMP._onPasswordEditedOrGenerated(
+ rootBrowser,
+ "https://www.example.com",
+ {
+ browsingContextId: 99,
+ formActionOrigin: "https://www.mozilla.org",
+ newPasswordField: { value: newPassword },
+ usernameField: { value: "someusername" },
+ triggeredByFillingGenerated: true,
+ }
+ );
+ let generatedPW = LoginManagerParent.getGeneratedPasswordsByPrincipalOrigin().get(
+ "https://www.example.com^userContextId=6"
+ );
+ ok(!generatedPW.edited, "Cached edited boolean should be false");
+ equal(
+ generatedPW.value,
+ generatedPassword,
+ "Cached password shouldn't be updated"
+ );
+
+ checkEditTelemetryRecorded(0, "Blanking doesn't count as an edit");
+
+ LoginManagerParent._browsingContextGlobal.get.restore();
+ restorePrompter();
+ LoginManagerParent.getGeneratedPasswordsByPrincipalOrigin().clear();
+ Services.logins.removeAllUserFacingLogins();
+ Services.telemetry.clearEvents();
+ }
+);
+
+add_task(async function test_addUsernameBeforeAutoSaveEdit() {
+ startTestConditions(99);
+ let { generatedPassword } = stubGeneratedPasswordForBrowsingContextId(99);
+ let {
+ fakePromptToChangePassword,
+ restorePrompter,
+ resetPrompterHistory,
+ } = stubPrompter();
+ let rootBrowser = LMP.getRootBrowser();
+ let fakePopupNotifications = {
+ getNotification: sinon.stub().returns({ dismissed: true }),
+ };
+ sinon.stub(LoginHelper, "getBrowserForPrompt").callsFake(() => {
+ return {
+ ownerGlobal: {
+ PopupNotifications: fakePopupNotifications,
+ },
+ };
+ });
+
+ let storageChangedPromised = TestUtils.topicObserved(
+ "passwordmgr-storage-changed",
+ (_, data) => data == "addLogin"
+ );
+
+ equal(
+ Services.logins.getAllLogins().length,
+ 0,
+ "Should have no saved logins at the start of the test"
+ );
+
+ await LMP._onPasswordEditedOrGenerated(
+ rootBrowser,
+ "https://www.example.com",
+ {
+ browsingContextId: 99,
+ formActionOrigin: "https://www.mozilla.org",
+ newPasswordField: { value: generatedPassword },
+ usernameField: { value: "someusername" },
+ triggeredByFillingGenerated: true,
+ }
+ );
+
+ let [login] = await storageChangedPromised;
+ let expected = new LoginInfo(
+ "https://www.example.com",
+ "https://www.mozilla.org",
+ null,
+ "", // verify we don't include the username when auto-saving a login
+ generatedPassword
+ );
+
+ ok(login.equals(expected), "Check added login");
+ ok(LMP._getPrompter.calledOnce, "Checking _getPrompter was called");
+ ok(
+ fakePromptToChangePassword.calledOnce,
+ "Checking promptToChangePassword was called"
+ );
+ ok(
+ fakePromptToChangePassword.getCall(0).args[3],
+ "promptToChangePassword had a truthy 'dismissed' argument"
+ );
+ ok(
+ fakePromptToChangePassword.getCall(0).args[4],
+ "promptToChangePassword had a truthy 'notifySaved' argument"
+ );
+
+ info("Checking the getNotification stub");
+ ok(
+ !fakePopupNotifications.getNotification.called,
+ "getNotification didn't get called yet"
+ );
+ resetPrompterHistory();
+
+ info("Add a username to the auto-saved login in storage");
+ let loginWithUsername = login.clone();
+ loginWithUsername.username = "added_username";
+ LoginManagerPrompter._updateLogin(login, loginWithUsername);
+
+ info("Edit the password");
+ const newPassword = generatedPassword + "🔥";
+ storageChangedPromised = TestUtils.topicObserved(
+ "passwordmgr-storage-changed",
+ (_, data) => data == "modifyLogin"
+ );
+ // will update the doorhanger with changed password
+ await LMP._onPasswordEditedOrGenerated(
+ rootBrowser,
+ "https://www.example.com",
+ {
+ browsingContextId: 99,
+ formActionOrigin: "https://www.mozilla.org",
+ newPasswordField: { value: newPassword },
+ usernameField: { value: "someusername" },
+ triggeredByFillingGenerated: true,
+ }
+ );
+ let generatedPW = LoginManagerParent.getGeneratedPasswordsByPrincipalOrigin().get(
+ "https://www.example.com^userContextId=6"
+ );
+ ok(generatedPW.edited, "Cached edited boolean should be true");
+ equal(generatedPW.value, newPassword, "Cached password should be updated");
+ let [dataArray] = await storageChangedPromised;
+ login = dataArray.queryElementAt(1, Ci.nsILoginInfo);
+ loginWithUsername.password = newPassword;
+ // the password should be updated in storage, but not the username (until the user confirms the doorhanger)
+ assertLoginProperties(login, loginWithUsername);
+ ok(login.matches(loginWithUsername, false), "Check updated login");
+ equal(
+ Services.logins.getAllLogins().length,
+ 1,
+ "Should have 1 saved login still"
+ );
+
+ ok(
+ fakePopupNotifications.getNotification.calledOnce,
+ "getNotification was called"
+ );
+ ok(LMP._getPrompter.calledOnce, "Checking _getPrompter was called");
+ ok(
+ fakePromptToChangePassword.calledOnce,
+ "Checking promptToChangePassword was called"
+ );
+ ok(
+ fakePromptToChangePassword.getCall(0).args[3],
+ "promptToChangePassword had a truthy 'dismissed' argument"
+ );
+ // The generated password changed, so we expect notifySaved to be true
+ ok(
+ fakePromptToChangePassword.getCall(0).args[4],
+ "promptToChangePassword should have a falsey 'notifySaved' argument"
+ );
+ resetPrompterHistory();
+
+ info(
+ "Simulate a second edit to check that the telemetry event for the first edit is not recorded twice"
+ );
+ const newerPassword = newPassword + "🦊";
+ storageChangedPromised = TestUtils.topicObserved(
+ "passwordmgr-storage-changed",
+ (_, data) => data == "modifyLogin"
+ );
+ info("Calling _onPasswordEditedOrGenerated again");
+ await LMP._onPasswordEditedOrGenerated(
+ rootBrowser,
+ "https://www.example.com",
+ {
+ browsingContextId: 99,
+ formActionOrigin: "https://www.mozilla.org",
+ newPasswordField: { value: newerPassword },
+ usernameField: { value: "someusername" },
+ triggeredByFillingGenerated: true,
+ }
+ );
+ generatedPW = LoginManagerParent.getGeneratedPasswordsByPrincipalOrigin().get(
+ "https://www.example.com^userContextId=6"
+ );
+ ok(generatedPW.edited, "Cached edited state should remain true");
+ equal(generatedPW.value, newerPassword, "Cached password should be updated");
+ [dataArray] = await storageChangedPromised;
+ login = dataArray.queryElementAt(1, Ci.nsILoginInfo);
+ loginWithUsername.password = newerPassword;
+ assertLoginProperties(login, loginWithUsername);
+ ok(login.matches(loginWithUsername, false), "Check updated login");
+ equal(
+ Services.logins.getAllLogins().length,
+ 1,
+ "Should have 1 saved login still"
+ );
+
+ checkEditTelemetryRecorded(1, "with auto-save");
+
+ ok(
+ fakePromptToChangePassword.calledOnce,
+ "Checking promptToChangePassword was called"
+ );
+ equal(
+ fakePromptToChangePassword.getCall(0).args[2].password,
+ newerPassword,
+ "promptToChangePassword had the updated password"
+ );
+ ok(
+ fakePromptToChangePassword.getCall(0).args[3],
+ "promptToChangePassword had a truthy 'dismissed' argument"
+ );
+
+ LoginManagerParent._browsingContextGlobal.get.restore();
+ LoginHelper.getBrowserForPrompt.restore();
+ restorePrompter();
+ LoginManagerParent.getGeneratedPasswordsByPrincipalOrigin().clear();
+ Services.logins.removeAllUserFacingLogins();
+ Services.telemetry.clearEvents();
+});
+
+add_task(async function test_editUsernameOfFilledSavedLogin() {
+ startTestConditions(99);
+ stubGeneratedPasswordForBrowsingContextId(99);
+ let {
+ fakePromptToChangePassword,
+ fakePromptToSavePassword,
+ restorePrompter,
+ resetPrompterHistory,
+ } = stubPrompter();
+ let rootBrowser = LMP.getRootBrowser();
+ let fakePopupNotifications = {
+ getNotification: sinon.stub().returns({ dismissed: true }),
+ };
+ sinon.stub(LoginHelper, "getBrowserForPrompt").callsFake(() => {
+ return {
+ ownerGlobal: {
+ PopupNotifications: fakePopupNotifications,
+ },
+ };
+ });
+
+ let login0Props = Object.assign({}, loginTemplate, {
+ username: "someusername",
+ password: "qweqweq",
+ });
+ info("Adding initial login: " + JSON.stringify(login0Props));
+ let savedLogin = await LoginTestUtils.addLogin(login0Props);
+
+ info(
+ "Saved initial login: " + JSON.stringify(Services.logins.getAllLogins()[0])
+ );
+
+ equal(
+ Services.logins.getAllLogins().length,
+ 1,
+ "Should have 1 saved login at the start of the test"
+ );
+
+ // first prompt to save a new login
+ let newUsername = "differentuser";
+ let newPassword = login0Props.password + "🔥";
+ await LMP._onPasswordEditedOrGenerated(
+ rootBrowser,
+ "https://www.example.com",
+ {
+ browsingContextId: 99,
+ formActionOrigin: "https://www.mozilla.org",
+ autoFilledLoginGuid: savedLogin.guid,
+ newPasswordField: { value: newPassword },
+ usernameField: { value: newUsername },
+ triggeredByFillingGenerated: false,
+ }
+ );
+
+ let expected = new LoginInfo(
+ login0Props.origin,
+ login0Props.formActionOrigin,
+ null,
+ newUsername,
+ newPassword
+ );
+
+ ok(LMP._getPrompter.calledOnce, "Checking _getPrompter was called");
+ info("Checking the getNotification stub");
+ ok(
+ !fakePopupNotifications.getNotification.called,
+ "getNotification was not called"
+ );
+ ok(
+ fakePromptToSavePassword.calledOnce,
+ "Checking promptToSavePassword was called"
+ );
+ ok(
+ fakePromptToSavePassword.getCall(0).args[2],
+ "promptToSavePassword had a truthy 'dismissed' argument"
+ );
+ ok(
+ !fakePromptToSavePassword.getCall(0).args[3],
+ "promptToSavePassword had a falsey 'notifySaved' argument"
+ );
+ assertLoginProperties(fakePromptToSavePassword.getCall(0).args[1], expected);
+ resetPrompterHistory();
+
+ // then prompt with matching username/password
+ await LMP._onPasswordEditedOrGenerated(
+ rootBrowser,
+ "https://www.example.com",
+ {
+ browsingContextId: 99,
+ formActionOrigin: "https://www.mozilla.org",
+ autoFilledLoginGuid: savedLogin.guid,
+ newPasswordField: { value: login0Props.password },
+ usernameField: { value: login0Props.username },
+ triggeredByFillingGenerated: false,
+ }
+ );
+
+ expected = new LoginInfo(
+ login0Props.origin,
+ login0Props.formActionOrigin,
+ null,
+ login0Props.username,
+ login0Props.password
+ );
+
+ ok(LMP._getPrompter.calledOnce, "Checking _getPrompter was called");
+ info("Checking the getNotification stub");
+ ok(
+ fakePopupNotifications.getNotification.called,
+ "getNotification was called"
+ );
+ ok(
+ fakePromptToChangePassword.calledOnce,
+ "Checking promptToChangePassword was called"
+ );
+ ok(
+ fakePromptToChangePassword.getCall(0).args[3],
+ "promptToChangePassword had a truthy 'dismissed' argument"
+ );
+ ok(
+ !fakePromptToChangePassword.getCall(0).args[4],
+ "promptToChangePassword had a falsey 'notifySaved' argument"
+ );
+ assertLoginProperties(
+ fakePromptToChangePassword.getCall(0).args[2],
+ expected
+ );
+ resetPrompterHistory();
+
+ LoginManagerParent._browsingContextGlobal.get.restore();
+ LoginHelper.getBrowserForPrompt.restore();
+ restorePrompter();
+ LoginManagerParent.getGeneratedPasswordsByPrincipalOrigin().clear();
+ Services.logins.removeAllUserFacingLogins();
+ Services.telemetry.clearEvents();
+});
+
+add_task(
+ async function test_onPasswordEditedOrGenerated_generatedPassword_withDisabledLogin() {
+ startTestConditions(99);
+ let { generatedPassword } = stubGeneratedPasswordForBrowsingContextId(99);
+ let { restorePrompter } = stubPrompter();
+ let rootBrowser = LMP.getRootBrowser();
+
+ info("Disable login saving for the site");
+ Services.logins.setLoginSavingEnabled("https://www.example.com", false);
+ await LMP._onPasswordEditedOrGenerated(
+ rootBrowser,
+ "https://www.example.com",
+ {
+ browsingContextId: 99,
+ formActionOrigin: "https://www.mozilla.org",
+ newPasswordField: { value: generatedPassword },
+ triggeredByFillingGenerated: true,
+ }
+ );
+ equal(
+ Services.logins.getAllLogins().length,
+ 0,
+ "Should have no saved logins since saving is disabled"
+ );
+ ok(LMP._getPrompter.notCalled, "Checking _getPrompter wasn't called");
+
+ // Clean up
+ LoginManagerParent._browsingContextGlobal.get.restore();
+ restorePrompter();
+ LoginManagerParent.getGeneratedPasswordsByPrincipalOrigin().clear();
+ Services.logins.setLoginSavingEnabled("https://www.example.com", true);
+ Services.logins.removeAllUserFacingLogins();
+ }
+);
+
+add_task(
+ async function test_onPasswordEditedOrGenerated_generatedPassword_withSavedEmptyUsername() {
+ startTestConditions(99);
+ let login0Props = Object.assign({}, loginTemplate, {
+ username: "",
+ password: "qweqweq",
+ });
+ info("Adding initial login: " + JSON.stringify(login0Props));
+ let expected = await LoginTestUtils.addLogin(login0Props);
+
+ info(
+ "Saved initial login: " +
+ JSON.stringify(Services.logins.getAllLogins()[0])
+ );
+
+ let {
+ generatedPassword: password1,
+ } = stubGeneratedPasswordForBrowsingContextId(99);
+ let { restorePrompter, fakePromptToChangePassword } = stubPrompter();
+ let rootBrowser = LMP.getRootBrowser();
+
+ await LMP._onPasswordEditedOrGenerated(
+ rootBrowser,
+ "https://www.example.com",
+ {
+ browsingContextId: 99,
+ formActionOrigin: "https://www.mozilla.org",
+ newPasswordField: { value: password1 },
+ triggeredByFillingGenerated: true,
+ }
+ );
+ equal(
+ Services.logins.getAllLogins().length,
+ 1,
+ "Should just have the previously-saved login with empty username"
+ );
+ assertLoginProperties(Services.logins.getAllLogins()[0], login0Props);
+
+ ok(LMP._getPrompter.calledOnce, "Checking _getPrompter was called");
+ ok(
+ fakePromptToChangePassword.calledOnce,
+ "Checking promptToChangePassword was called"
+ );
+ ok(
+ fakePromptToChangePassword.getCall(0).args[3],
+ "promptToChangePassword had a truthy 'dismissed' argument"
+ );
+ ok(
+ !fakePromptToChangePassword.getCall(0).args[4],
+ "promptToChangePassword had a falsey 'notifySaved' argument"
+ );
+
+ info("Edit the password");
+ const newPassword = password1 + "🔥";
+ await LMP._onPasswordEditedOrGenerated(
+ rootBrowser,
+ "https://www.example.com",
+ {
+ browsingContextId: 99,
+ formActionOrigin: "https://www.mozilla.org",
+ newPasswordField: { value: newPassword },
+ usernameField: { value: "someusername" },
+ triggeredByFillingGenerated: true,
+ }
+ );
+ let generatedPW = LoginManagerParent.getGeneratedPasswordsByPrincipalOrigin().get(
+ "https://www.example.com^userContextId=6"
+ );
+ ok(generatedPW.edited, "Cached edited boolean should be true");
+ equal(generatedPW.storageGUID, null, "Should have no storageGUID");
+ equal(generatedPW.value, newPassword, "Cached password should be updated");
+ assertLoginProperties(Services.logins.getAllLogins()[0], login0Props);
+ ok(Services.logins.getAllLogins()[0].equals(expected), "Ensure no changes");
+ equal(
+ Services.logins.getAllLogins().length,
+ 1,
+ "Should have 1 saved login still"
+ );
+
+ checkEditTelemetryRecorded(1, "Updating cache, not storage (no auto-save)");
+
+ LoginManagerParent._browsingContextGlobal.get.restore();
+ restorePrompter();
+ LoginManagerParent.getGeneratedPasswordsByPrincipalOrigin().clear();
+ Services.logins.removeAllUserFacingLogins();
+ Services.telemetry.clearEvents();
+ }
+);
+
+add_task(
+ async function test_onPasswordEditedOrGenerated_generatedPassword_withSavedEmptyUsernameAndUsernameValue() {
+ // Save as the above task but with a non-empty username field value.
+ startTestConditions(99);
+ let login0Props = Object.assign({}, loginTemplate, {
+ username: "",
+ password: "qweqweq",
+ });
+ info("Adding initial login: " + JSON.stringify(login0Props));
+ let expected = await LoginTestUtils.addLogin(login0Props);
+
+ info(
+ "Saved initial login: " +
+ JSON.stringify(Services.logins.getAllLogins()[0])
+ );
+
+ let {
+ generatedPassword: password1,
+ } = stubGeneratedPasswordForBrowsingContextId(99);
+ let {
+ restorePrompter,
+ fakePromptToChangePassword,
+ fakePromptToSavePassword,
+ } = stubPrompter();
+ let rootBrowser = LMP.getRootBrowser();
+
+ await LMP._onPasswordEditedOrGenerated(
+ rootBrowser,
+ "https://www.example.com",
+ {
+ browsingContextId: 99,
+ formActionOrigin: "https://www.mozilla.org",
+ newPasswordField: { value: password1 },
+ usernameField: { value: "non-empty-username" },
+ triggeredByFillingGenerated: true,
+ }
+ );
+ equal(
+ Services.logins.getAllLogins().length,
+ 1,
+ "Should just have the previously-saved login with empty username"
+ );
+ assertLoginProperties(Services.logins.getAllLogins()[0], login0Props);
+
+ ok(LMP._getPrompter.calledOnce, "Checking _getPrompter was called");
+ ok(
+ fakePromptToChangePassword.notCalled,
+ "Checking promptToChangePassword wasn't called"
+ );
+ ok(
+ fakePromptToSavePassword.calledOnce,
+ "Checking promptToSavePassword was called"
+ );
+ ok(
+ fakePromptToSavePassword.getCall(0).args[2],
+ "promptToSavePassword had a truthy 'dismissed' argument"
+ );
+ ok(
+ !fakePromptToSavePassword.getCall(0).args[3],
+ "promptToSavePassword had a falsey 'notifySaved' argument"
+ );
+
+ info("Edit the password");
+ const newPassword = password1 + "🔥";
+ await LMP._onPasswordEditedOrGenerated(
+ rootBrowser,
+ "https://www.example.com",
+ {
+ browsingContextId: 99,
+ formActionOrigin: "https://www.mozilla.org",
+ newPasswordField: { value: newPassword },
+ usernameField: { value: "non-empty-username" },
+ triggeredByFillingGenerated: true,
+ }
+ );
+ ok(
+ fakePromptToChangePassword.notCalled,
+ "Checking promptToChangePassword wasn't called"
+ );
+ ok(
+ fakePromptToSavePassword.calledTwice,
+ "Checking promptToSavePassword was called again"
+ );
+ ok(
+ fakePromptToSavePassword.getCall(1).args[2],
+ "promptToSavePassword had a truthy 'dismissed' argument"
+ );
+ ok(
+ !fakePromptToSavePassword.getCall(1).args[3],
+ "promptToSavePassword had a falsey 'notifySaved' argument"
+ );
+
+ let generatedPW = LoginManagerParent.getGeneratedPasswordsByPrincipalOrigin().get(
+ "https://www.example.com^userContextId=6"
+ );
+ ok(generatedPW.edited, "Cached edited boolean should be true");
+ equal(generatedPW.storageGUID, null, "Should have no storageGUID");
+ equal(generatedPW.value, newPassword, "Cached password should be updated");
+ assertLoginProperties(Services.logins.getAllLogins()[0], login0Props);
+ ok(Services.logins.getAllLogins()[0].equals(expected), "Ensure no changes");
+ equal(
+ Services.logins.getAllLogins().length,
+ 1,
+ "Should have 1 saved login still"
+ );
+
+ checkEditTelemetryRecorded(
+ 1,
+ "Updating cache, not storage (no auto-save) with username in field"
+ );
+
+ LoginManagerParent._browsingContextGlobal.get.restore();
+ restorePrompter();
+ LoginManagerParent.getGeneratedPasswordsByPrincipalOrigin().clear();
+ Services.logins.removeAllUserFacingLogins();
+ Services.telemetry.clearEvents();
+ }
+);
+
+add_task(
+ async function test_onPasswordEditedOrGenerated_generatedPassword_withEmptyUsernameDifferentFormActionOrigin() {
+ startTestConditions(99);
+ let login0Props = Object.assign({}, loginTemplate, {
+ username: "",
+ password: "qweqweq",
+ });
+ await LoginTestUtils.addLogin(login0Props);
+
+ let {
+ generatedPassword: password1,
+ } = stubGeneratedPasswordForBrowsingContextId(99);
+ let { restorePrompter, fakePromptToChangePassword } = stubPrompter();
+ let rootBrowser = LMP.getRootBrowser();
+
+ await LMP._onPasswordEditedOrGenerated(
+ rootBrowser,
+ "https://www.example.com",
+ {
+ browsingContextId: 99,
+ formActionOrigin: "https://www.elsewhere.com",
+ newPasswordField: { value: password1 },
+ triggeredByFillingGenerated: true,
+ }
+ );
+
+ let savedLogins = Services.logins.getAllLogins();
+ equal(
+ savedLogins.length,
+ 2,
+ "Should have saved the generated-password login"
+ );
+
+ assertLoginProperties(savedLogins[0], login0Props);
+ assertLoginProperties(
+ savedLogins[1],
+ Object.assign({}, loginTemplate, {
+ formActionOrigin: "https://www.elsewhere.com",
+ username: "",
+ password: password1,
+ })
+ );
+
+ ok(LMP._getPrompter.calledOnce, "Checking _getPrompter was called");
+ ok(
+ fakePromptToChangePassword.calledOnce,
+ "Checking promptToChangePassword was called"
+ );
+ ok(
+ fakePromptToChangePassword.getCall(0).args[2],
+ "promptToChangePassword had a truthy 'dismissed' argument"
+ );
+ ok(
+ fakePromptToChangePassword.getCall(0).args[3],
+ "promptToChangePassword had a truthy 'notifySaved' argument"
+ );
+
+ LoginManagerParent._browsingContextGlobal.get.restore();
+ restorePrompter();
+ LoginManagerParent.getGeneratedPasswordsByPrincipalOrigin().clear();
+ Services.logins.removeAllUserFacingLogins();
+ }
+);
+
+add_task(
+ async function test_onPasswordEditedOrGenerated_generatedPassword_withSavedUsername() {
+ startTestConditions(99);
+ let login0Props = Object.assign({}, loginTemplate, {
+ username: "previoususer",
+ password: "qweqweq",
+ });
+ await LoginTestUtils.addLogin(login0Props);
+
+ let {
+ generatedPassword: password1,
+ } = stubGeneratedPasswordForBrowsingContextId(99);
+ let { restorePrompter, fakePromptToChangePassword } = stubPrompter();
+ let rootBrowser = LMP.getRootBrowser();
+
+ await LMP._onPasswordEditedOrGenerated(
+ rootBrowser,
+ "https://www.example.com",
+ {
+ browsingContextId: 99,
+ formActionOrigin: "https://www.mozilla.org",
+ newPasswordField: { value: password1 },
+ triggeredByFillingGenerated: true,
+ }
+ );
+
+ let savedLogins = Services.logins.getAllLogins();
+ equal(
+ savedLogins.length,
+ 2,
+ "Should have saved the generated-password login"
+ );
+ assertLoginProperties(Services.logins.getAllLogins()[0], login0Props);
+ assertLoginProperties(
+ savedLogins[1],
+ Object.assign({}, loginTemplate, {
+ username: "",
+ password: password1,
+ })
+ );
+
+ ok(LMP._getPrompter.calledOnce, "Checking _getPrompter was called");
+ ok(
+ fakePromptToChangePassword.calledOnce,
+ "Checking promptToChangePassword was called"
+ );
+ ok(
+ fakePromptToChangePassword.getCall(0).args[2],
+ "promptToChangePassword had a truthy 'dismissed' argument"
+ );
+ ok(
+ fakePromptToChangePassword.getCall(0).args[3],
+ "promptToChangePassword had a truthy 'notifySaved' argument"
+ );
+
+ LoginManagerParent._browsingContextGlobal.get.restore();
+ restorePrompter();
+ LoginManagerParent.getGeneratedPasswordsByPrincipalOrigin().clear();
+ Services.logins.removeAllUserFacingLogins();
+ }
+);
diff --git a/toolkit/components/passwordmgr/test/unit/test_LoginManagerParent_searchAndDedupeLogins.js b/toolkit/components/passwordmgr/test/unit/test_LoginManagerParent_searchAndDedupeLogins.js
new file mode 100644
index 0000000000..8ebbef2c7e
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_LoginManagerParent_searchAndDedupeLogins.js
@@ -0,0 +1,195 @@
+/**
+ * Test LoginManagerParent._searchAndDedupeLogins()
+ */
+
+"use strict";
+
+const { LoginManagerParent: LMP } = ChromeUtils.import(
+ "resource://gre/modules/LoginManagerParent.jsm"
+);
+
+const DOMAIN1_HTTP_ORIGIN = "http://www3.example.com";
+const DOMAIN1_HTTPS_ORIGIN = "https://www3.example.com";
+
+const DOMAIN1_HTTP_TO_HTTP_U1_P1 = TestData.formLogin({});
+const DOMAIN1_HTTP_TO_HTTP_U2_P1 = TestData.formLogin({
+ username: "user2",
+});
+const DOMAIN1_HTTP_TO_HTTP_U3_P1 = TestData.formLogin({
+ username: "user3",
+});
+const DOMAIN1_HTTPS_TO_HTTPS_U1_P1 = TestData.formLogin({
+ origin: DOMAIN1_HTTPS_ORIGIN,
+ formActionOrigin: "https://login.example.com",
+});
+const DOMAIN1_HTTPS_TO_HTTPS_U2_P1 = TestData.formLogin({
+ origin: DOMAIN1_HTTPS_ORIGIN,
+ formActionOrigin: "https://login.example.com",
+ username: "user2",
+});
+const DOMAIN1_HTTPS_TO_HTTPS_U1_P2 = TestData.formLogin({
+ origin: DOMAIN1_HTTPS_ORIGIN,
+ formActionOrigin: "https://login.example.com",
+ password: "password two",
+});
+const DOMAIN1_HTTPS_TO_HTTPS_U1_P2_DIFFERENT_PORT = TestData.formLogin({
+ origin: "https://www3.example.com:8080",
+ password: "password two",
+});
+const DOMAIN1_HTTP_TO_HTTP_U1_P2 = TestData.formLogin({
+ password: "password two",
+});
+const DOMAIN1_HTTP_TO_HTTP_U1_P1_DIFFERENT_PORT = TestData.formLogin({
+ origin: "http://www3.example.com:8080",
+});
+const DOMAIN2_HTTP_TO_HTTP_U1_P1 = TestData.formLogin({
+ origin: "http://different.example.com",
+});
+const DOMAIN2_HTTPS_TO_HTTPS_U1_P1 = TestData.formLogin({
+ origin: "https://different.example.com",
+ formActionOrigin: "https://login.example.com",
+});
+
+add_task(function setup() {
+ // Not enabled by default in all.js:
+ Services.prefs.setBoolPref("signon.schemeUpgrades", true);
+});
+
+add_task(async function test_searchAndDedupeLogins_acceptDifferentSubdomains() {
+ let testcases = [
+ {
+ description: "HTTPS form, same hostPort, same username, different scheme",
+ formActionOrigin: DOMAIN1_HTTPS_ORIGIN,
+ logins: [DOMAIN1_HTTPS_TO_HTTPS_U1_P1, DOMAIN1_HTTP_TO_HTTP_U1_P1],
+ expected: [DOMAIN1_HTTPS_TO_HTTPS_U1_P1],
+ },
+ {
+ description: "HTTP form, same hostPort, same username, different scheme",
+ formActionOrigin: DOMAIN1_HTTP_ORIGIN,
+ logins: [DOMAIN1_HTTPS_TO_HTTPS_U1_P1, DOMAIN1_HTTP_TO_HTTP_U1_P1],
+ expected: [DOMAIN1_HTTP_TO_HTTP_U1_P1],
+ },
+ {
+ description: "HTTPS form, different passwords, different scheme",
+ formActionOrigin: DOMAIN1_HTTPS_ORIGIN,
+ logins: [DOMAIN1_HTTPS_TO_HTTPS_U1_P1, DOMAIN1_HTTP_TO_HTTP_U1_P2],
+ expected: [DOMAIN1_HTTPS_TO_HTTPS_U1_P1],
+ },
+ {
+ description: "HTTP form, different passwords, different scheme",
+ formActionOrigin: DOMAIN1_HTTP_ORIGIN,
+ logins: [DOMAIN1_HTTPS_TO_HTTPS_U1_P1, DOMAIN1_HTTP_TO_HTTP_U1_P2],
+ expected: [DOMAIN1_HTTP_TO_HTTP_U1_P2],
+ },
+ {
+ description: "HTTPS form, both https, same username, different password",
+ formActionOrigin: DOMAIN1_HTTPS_ORIGIN,
+ logins: [DOMAIN1_HTTPS_TO_HTTPS_U1_P1, DOMAIN1_HTTPS_TO_HTTPS_U1_P2],
+ expected: [DOMAIN1_HTTPS_TO_HTTPS_U1_P1, DOMAIN1_HTTPS_TO_HTTPS_U1_P2],
+ },
+ {
+ description: "HTTP form, both https, same username, different password",
+ formActionOrigin: DOMAIN1_HTTP_ORIGIN,
+ logins: [DOMAIN1_HTTPS_TO_HTTPS_U1_P1, DOMAIN1_HTTPS_TO_HTTPS_U1_P2],
+ expected: [],
+ },
+ {
+ description: "HTTPS form, same origin, different port, both schemes",
+ formActionOrigin: DOMAIN1_HTTPS_ORIGIN,
+ logins: [
+ DOMAIN1_HTTPS_TO_HTTPS_U1_P1,
+ DOMAIN1_HTTP_TO_HTTP_U1_P1_DIFFERENT_PORT,
+ DOMAIN1_HTTPS_TO_HTTPS_U1_P2_DIFFERENT_PORT,
+ ],
+ expected: [
+ DOMAIN1_HTTPS_TO_HTTPS_U1_P1,
+ DOMAIN1_HTTPS_TO_HTTPS_U1_P2_DIFFERENT_PORT,
+ ],
+ },
+ {
+ description: "HTTP form, same origin, different port, both schemes",
+ formActionOrigin: DOMAIN1_HTTP_ORIGIN,
+ logins: [
+ DOMAIN1_HTTPS_TO_HTTPS_U1_P1,
+ DOMAIN1_HTTP_TO_HTTP_U1_P1_DIFFERENT_PORT,
+ DOMAIN1_HTTPS_TO_HTTPS_U1_P2_DIFFERENT_PORT,
+ ],
+ expected: [DOMAIN1_HTTP_TO_HTTP_U1_P1_DIFFERENT_PORT],
+ },
+ {
+ description: "HTTPS form, different origin, different scheme",
+ formActionOrigin: DOMAIN1_HTTPS_ORIGIN,
+ logins: [DOMAIN1_HTTPS_TO_HTTPS_U1_P1, DOMAIN2_HTTP_TO_HTTP_U1_P1],
+ expected: [DOMAIN1_HTTPS_TO_HTTPS_U1_P1],
+ },
+ {
+ description:
+ "HTTPS form, different origin, different scheme, same password, same hostPort preferred",
+ formActionOrigin: DOMAIN1_HTTPS_ORIGIN,
+ logins: [DOMAIN1_HTTP_TO_HTTP_U1_P1, DOMAIN2_HTTPS_TO_HTTPS_U1_P1],
+ expected: [DOMAIN1_HTTP_TO_HTTP_U1_P1],
+ },
+ {
+ description: "HTTP form, different origin, different scheme",
+ formActionOrigin: DOMAIN1_HTTP_ORIGIN,
+ logins: [DOMAIN1_HTTPS_TO_HTTPS_U1_P1, DOMAIN2_HTTP_TO_HTTP_U1_P1],
+ expected: [DOMAIN2_HTTP_TO_HTTP_U1_P1],
+ },
+ {
+ description: "HTTPS form, different username, different scheme",
+ formActionOrigin: DOMAIN1_HTTPS_ORIGIN,
+ logins: [DOMAIN1_HTTPS_TO_HTTPS_U1_P1, DOMAIN1_HTTP_TO_HTTP_U2_P1],
+ expected: [DOMAIN1_HTTPS_TO_HTTPS_U1_P1, DOMAIN1_HTTP_TO_HTTP_U2_P1],
+ },
+ {
+ description: "HTTP form, different username, different scheme",
+ formActionOrigin: DOMAIN1_HTTP_ORIGIN,
+ logins: [DOMAIN1_HTTPS_TO_HTTPS_U1_P1, DOMAIN1_HTTP_TO_HTTP_U2_P1],
+ expected: [DOMAIN1_HTTP_TO_HTTP_U2_P1],
+ },
+ {
+ description: "HTTPS form, different usernames, different schemes",
+ formActionOrigin: DOMAIN1_HTTPS_ORIGIN,
+ logins: [
+ DOMAIN1_HTTPS_TO_HTTPS_U1_P2,
+ DOMAIN1_HTTPS_TO_HTTPS_U2_P1,
+ DOMAIN1_HTTP_TO_HTTP_U1_P1,
+ DOMAIN1_HTTP_TO_HTTP_U3_P1,
+ ],
+ expected: [
+ DOMAIN1_HTTPS_TO_HTTPS_U1_P2,
+ DOMAIN1_HTTPS_TO_HTTPS_U2_P1,
+ DOMAIN1_HTTP_TO_HTTP_U3_P1,
+ ],
+ },
+ ];
+
+ for (let tc of testcases) {
+ info(tc.description);
+
+ let guids = await Services.logins.addLogins(tc.logins);
+ Assert.strictEqual(
+ guids.length,
+ tc.logins.length,
+ "Check length of added logins"
+ );
+
+ let actual = await LMP.searchAndDedupeLogins(tc.formActionOrigin, {
+ formActionOrigin: tc.formActionOrigin,
+ looseActionOriginMatch: true,
+ acceptDifferentSubdomains: true,
+ });
+ info(`actual:\n ${JSON.stringify(actual, null, 2)}`);
+ info(`expected:\n ${JSON.stringify(tc.expected, null, 2)}`);
+ Assert.strictEqual(
+ actual.length,
+ tc.expected.length,
+ `Check result length`
+ );
+ for (let [i, login] of tc.expected.entries()) {
+ Assert.ok(actual[i].equals(login), `Check index ${i}`);
+ }
+
+ Services.logins.removeAllUserFacingLogins();
+ }
+});
diff --git a/toolkit/components/passwordmgr/test/unit/test_LoginManagerPrompter_getUsernameSuggestions.js b/toolkit/components/passwordmgr/test/unit/test_LoginManagerPrompter_getUsernameSuggestions.js
new file mode 100644
index 0000000000..a414a6bb29
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_LoginManagerPrompter_getUsernameSuggestions.js
@@ -0,0 +1,189 @@
+let { LoginManagerPrompter } = ChromeUtils.import(
+ "resource://gre/modules/LoginManagerPrompter.jsm"
+);
+
+const TEST_CASES = [
+ {
+ description: "page values should appear before saved values",
+ savedLogins: [{ username: "savedUsername", password: "savedPassword" }],
+ possibleUsernames: ["pageUsername"],
+ expectedSuggestions: [
+ { text: "pageUsername", style: "possible-username" },
+ { text: "savedUsername", style: "login" },
+ ],
+ isLoggedIn: true,
+ },
+ {
+ description: "duplicate page values should be deduped",
+ savedLogins: [],
+ possibleUsernames: ["pageUsername", "pageUsername", "pageUsername2"],
+ expectedSuggestions: [
+ { text: "pageUsername", style: "possible-username" },
+ { text: "pageUsername2", style: "possible-username" },
+ ],
+ isLoggedIn: true,
+ },
+ {
+ description: "page values should dedupe and win over saved values",
+ savedLogins: [{ username: "username", password: "savedPassword" }],
+ possibleUsernames: ["username"],
+ expectedSuggestions: [{ text: "username", style: "possible-username" }],
+ isLoggedIn: true,
+ },
+ {
+ description: "empty usernames should be filtered out",
+ savedLogins: [{ username: "", password: "savedPassword" }],
+ possibleUsernames: [""],
+ expectedSuggestions: [],
+ isLoggedIn: true,
+ },
+ {
+ description: "auth logins should be displayed alongside normal ones",
+ savedLogins: [
+ { username: "normalUsername", password: "normalPassword" },
+ { isAuth: true, username: "authUsername", password: "authPassword" },
+ ],
+ possibleUsernames: [""],
+ expectedSuggestions: [
+ { text: "normalUsername", style: "login" },
+ { text: "authUsername", style: "login" },
+ ],
+ isLoggedIn: true,
+ },
+ {
+ description: "saved logins from subdomains should be displayed",
+ savedLogins: [
+ {
+ username: "savedUsername",
+ password: "savedPassword",
+ origin: "https://subdomain.example.com",
+ },
+ ],
+ possibleUsernames: [],
+ expectedSuggestions: [{ text: "savedUsername", style: "login" }],
+ isLoggedIn: true,
+ },
+ {
+ description: "usernames from different subdomains should be deduped",
+ savedLogins: [
+ {
+ username: "savedUsername",
+ password: "savedPassword",
+ origin: "https://subdomain.example.com",
+ },
+ {
+ username: "savedUsername",
+ password: "savedPassword",
+ origin: "https://example.com",
+ },
+ ],
+ possibleUsernames: [],
+ expectedSuggestions: [{ text: "savedUsername", style: "login" }],
+ isLoggedIn: true,
+ },
+ {
+ description: "No results with saved login when Primary Password is locked",
+ savedLogins: [
+ {
+ username: "savedUsername",
+ password: "savedPassword",
+ origin: "https://example.com",
+ },
+ ],
+ possibleUsernames: [],
+ expectedSuggestions: [],
+ isLoggedIn: false,
+ },
+];
+
+const LOGIN = TestData.formLogin({
+ origin: "https://example.com",
+ formActionOrigin: "https://example.com",
+ username: "LOGIN is used only for its origin",
+ password: "LOGIN is used only for its origin",
+});
+
+function _setPrefs() {
+ Services.prefs.setBoolPref("signon.capture.inputChanges.enabled", true);
+ registerCleanupFunction(() => {
+ Services.prefs.clearUserPref("signon.capture.inputChanges.enabled");
+ });
+}
+
+function _saveLogins(logins) {
+ logins
+ .map(loginData => {
+ let login;
+ if (loginData.isAuth) {
+ login = TestData.authLogin({
+ origin: loginData.origin ?? "https://example.com",
+ httpRealm: "example-realm",
+ username: loginData.username,
+ password: loginData.password,
+ });
+ } else {
+ login = TestData.formLogin({
+ origin: loginData.origin ?? "https://example.com",
+ formActionOrigin: "https://example.com",
+ username: loginData.username,
+ password: loginData.password,
+ });
+ }
+ return login;
+ })
+ .forEach(login => Services.logins.addLogin(login));
+}
+
+function _compare(expectedArr, actualArr) {
+ ok(!!expectedArr, "Expect expectedArr to be truthy");
+ ok(!!actualArr, "Expect actualArr to be truthy");
+ ok(
+ expectedArr.length == actualArr.length,
+ "Expect expectedArr and actualArr to be the same length"
+ );
+ for (let i = 0; i < expectedArr.length; i++) {
+ let expected = expectedArr[i];
+ let actual = actualArr[i];
+
+ ok(
+ expected.text == actual.text,
+ `Expect element #${i} text to match. Expected: '${expected.text}', Actual '${actual.text}'`
+ );
+ ok(
+ expected.style == actual.style,
+ `Expect element #${i} text to match. Expected: '${expected.style}', Actual '${actual.style}'`
+ );
+ }
+}
+
+async function _test(testCase) {
+ info(`Starting test case: ${testCase.description}`);
+ info(`Storing saved logins: ${JSON.stringify(testCase.savedLogins)}`);
+ _saveLogins(testCase.savedLogins);
+
+ if (!testCase.isLoggedIn) {
+ // Primary Password should be enabled and locked
+ LoginTestUtils.masterPassword.enable();
+ }
+
+ info("Computing results");
+ let result = await LoginManagerPrompter._getUsernameSuggestions(
+ LOGIN,
+ testCase.possibleUsernames
+ );
+
+ _compare(testCase.expectedSuggestions, result);
+
+ info("Cleaning up state");
+ if (!testCase.isLoggedIn) {
+ LoginTestUtils.masterPassword.disable();
+ }
+ LoginTestUtils.clearData();
+}
+
+add_task(async function test_LoginManagerPrompter_getUsernameSuggestions() {
+ _setPrefs();
+ for (let tc of TEST_CASES) {
+ await _test(tc);
+ }
+});
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 0000000000..b5c944a49d
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_OSCrypto_win.js
@@ -0,0 +1,447 @@
+/**
+ * Tests the OSCrypto object.
+ */
+
+"use strict";
+
+// Globals
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "OSCrypto",
+ "resource://gre/modules/OSCrypto.jsm"
+);
+
+let crypto = new OSCrypto();
+
+// Tests
+
+add_task(function test_getIELoginHash() {
+ Assert.equal(
+ crypto.getIELoginHash("https://bugzilla.mozilla.org/page.cgi"),
+ "4A66FE96607885790F8E67B56EEE52AB539BAFB47D"
+ );
+
+ Assert.equal(
+ crypto.getIELoginHash("https://github.com/login"),
+ "0112F7DCE67B8579EA01367678AA44AB9868B5A143"
+ );
+
+ Assert.equal(
+ crypto.getIELoginHash("https://login.live.com/login.srf"),
+ "FBF92E5D804C82717A57856533B779676D92903688"
+ );
+
+ Assert.equal(
+ crypto.getIELoginHash("https://preview.c9.io/riadh/w1/pass.1.html"),
+ "6935CF27628830605927F86AB53831016FC8973D1A"
+ );
+
+ Assert.equal(
+ crypto.getIELoginHash("https://reviewboard.mozilla.org/account/login/"),
+ "09141FD287E2E59A8B1D3BB5671537FD3D6B61337A"
+ );
+
+ Assert.equal(
+ crypto.getIELoginHash("https://www.facebook.com/"),
+ "EF44D3E034009CB0FD1B1D81A1FF3F3335213BD796"
+ );
+});
+
+add_task(function test_decryptData_encryptData() {
+ function encryptDecrypt(value, key) {
+ Assert.ok(true, `Testing value='${value}' with key='${key}'`);
+ let encrypted = crypto.encryptData(value, key);
+ Assert.ok(!!encrypted, "Encrypted value returned");
+ let decrypted = crypto.decryptData(encrypted, key);
+ Assert.equal(decrypted, value, "Decrypted value matches initial value");
+ return decrypted;
+ }
+
+ let values = [
+ "",
+ "secret",
+ "https://www.mozilla.org",
+ "https://reviewboard.mozilla.org",
+ "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 value of values) {
+ for (let key of keys) {
+ Assert.equal(
+ encryptDecrypt(value, key),
+ value,
+ `'${value}' encrypted then decrypted with entropy of '${key}' should match original value.`
+ );
+ }
+ }
+
+ 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++) {
+ Assert.equal(arr2[i], value[i], "Checking index " + i);
+ }
+});
+
+add_task(function test_decryptDataOutput() {
+ const testString = "2<FPZd";
+ const encrypted = crypto.encryptData(testString);
+
+ const decryptedString = crypto.decryptData(encrypted, null, "string");
+ Assert.equal(
+ decryptedString,
+ testString,
+ "Decrypted string matches initial value"
+ );
+
+ const decryptedBytes = crypto.decryptData(encrypted, null, "bytes");
+ testString.split("").forEach((c, i) => {
+ const code = c.charCodeAt(0);
+ Assert.equal(
+ decryptedBytes[i],
+ code,
+ `Decrypted bytes matches ${c} charCode (${code})`
+ );
+ });
+});
diff --git a/toolkit/components/passwordmgr/test/unit/test_PasswordGenerator.js b/toolkit/components/passwordmgr/test/unit/test_PasswordGenerator.js
new file mode 100644
index 0000000000..bdbca4aecf
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_PasswordGenerator.js
@@ -0,0 +1,78 @@
+"use strict";
+
+const { PasswordGenerator } = ChromeUtils.import(
+ "resource://gre/modules/PasswordGenerator.jsm"
+);
+
+add_task(async function test_shuffleString() {
+ let original = "1234567890";
+ let shuffled = PasswordGenerator._shuffleString(original);
+ notEqual(original, shuffled, "String should have been shuffled");
+});
+
+add_task(async function test_randomUInt8Index() {
+ throws(
+ () => PasswordGenerator._randomUInt8Index(256),
+ /uint8/,
+ "Should throw for larger than uint8"
+ );
+ ok(
+ Number.isSafeInteger(PasswordGenerator._randomUInt8Index(255)),
+ "Check integer returned"
+ );
+});
+
+add_task(async function test_generatePassword_classes() {
+ let password = PasswordGenerator.generatePassword(
+ /* REQUIRED_CHARACTER_CLASSES */ 3
+ );
+ info(password);
+ equal(password.length, 3, "Check length is correct");
+ ok(
+ password.match(/[a-km-np-z]/),
+ "Minimal password should include at least one lowercase character"
+ );
+ ok(
+ password.match(/[A-HJ-NP-Z]/),
+ "Minimal password should include at least one uppercase character"
+ );
+ ok(
+ password.match(/[2-9]/),
+ "Minimal password should include at least one digit"
+ );
+ ok(
+ password.match(/^[a-km-np-zA-HJ-NP-Z2-9]+$/),
+ "All characters should be in the expected set"
+ );
+});
+
+add_task(async function test_generatePassword_length() {
+ let password = PasswordGenerator.generatePassword(5);
+ info(password);
+ equal(password.length, 5, "Check length is correct");
+
+ throws(
+ () => PasswordGenerator.generatePassword(2),
+ /too short/i,
+ "Throws if too short"
+ );
+ throws(
+ () => PasswordGenerator.generatePassword(Math.pow(2, 8)),
+ /too long/i,
+ "Throws if too long"
+ );
+ ok(
+ password.match(/^[a-km-np-zA-HJ-NP-Z2-9]+$/),
+ "All characters should be in the expected set"
+ );
+});
+
+add_task(async function test_generatePassword_defaultLength() {
+ let password = PasswordGenerator.generatePassword();
+ info(password);
+ equal(password.length, 15, "Check default length is correct");
+ ok(
+ password.match(/^[a-km-np-zA-HJ-NP-Z2-9]{15}$/),
+ "All characters should be in the expected set"
+ );
+});
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 0000000000..b409ed6aa4
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_context_menu.js
@@ -0,0 +1,347 @@
+/**
+ * Test the password manager context menu.
+ */
+
+"use strict";
+
+const { LoginManagerContextMenu } = ChromeUtils.import(
+ "resource://gre/modules/LoginManagerContextMenu.jsm"
+);
+
+const dateAndTimeFormatter = new Services.intl.DateTimeFormat(undefined, {
+ dateStyle: "medium",
+});
+
+const ORIGIN_HTTP_EXAMPLE_ORG = "http://example.org";
+const ORIGIN_HTTPS_EXAMPLE_ORG = "https://example.org";
+const ORIGIN_HTTPS_EXAMPLE_ORG_8080 = "https://example.org:8080";
+
+const FORM_LOGIN_HTTPS_EXAMPLE_ORG_U1_P1 = formLogin({
+ formActionOrigin: ORIGIN_HTTPS_EXAMPLE_ORG,
+ guid: "FORM_LOGIN_HTTPS_EXAMPLE_ORG_U1_P1",
+ origin: ORIGIN_HTTPS_EXAMPLE_ORG,
+});
+
+// HTTP version of the above
+const FORM_LOGIN_HTTP_EXAMPLE_ORG_U1_P1 = formLogin({
+ formActionOrigin: ORIGIN_HTTP_EXAMPLE_ORG,
+ guid: "FORM_LOGIN_HTTP_EXAMPLE_ORG_U1_P1",
+ origin: ORIGIN_HTTP_EXAMPLE_ORG,
+});
+
+// Same as above but with a different password
+const FORM_LOGIN_HTTP_EXAMPLE_ORG_U1_P2 = formLogin({
+ formActionOrigin: ORIGIN_HTTP_EXAMPLE_ORG,
+ guid: "FORM_LOGIN_HTTP_EXAMPLE_ORG_U1_P2",
+ origin: ORIGIN_HTTP_EXAMPLE_ORG,
+ password: "pass2",
+});
+
+// Non-default port
+
+const FORM_LOGIN_HTTPS_EXAMPLE_ORG_8080_U1_P2 = formLogin({
+ formActionOrigin: ORIGIN_HTTPS_EXAMPLE_ORG_8080,
+ guid: "FORM_LOGIN_HTTPS_EXAMPLE_ORG_8080_U1_P2",
+ origin: ORIGIN_HTTPS_EXAMPLE_ORG_8080,
+ password: "pass2",
+});
+
+// HTTP Auth.
+
+const HTTP_LOGIN_HTTPS_EXAMPLE_ORG_U1_P1 = authLogin({
+ guid: "FORM_LOGIN_HTTPS_EXAMPLE_ORG_U1_P1",
+ origin: ORIGIN_HTTPS_EXAMPLE_ORG,
+});
+
+XPCOMUtils.defineLazyGetter(this, "_stringBundle", function() {
+ return Services.strings.createBundle(
+ "chrome://passwordmgr/locale/passwordmgr.properties"
+ );
+});
+
+/**
+ * Prepare data for the following tests.
+ */
+add_task(async function test_initialize() {
+ Services.prefs.setBoolPref("signon.schemeUpgrades", true);
+});
+
+add_task(async function test_sameOriginBothHTTPAndHTTPSDeduped() {
+ await runTestcase({
+ formOrigin: FORM_LOGIN_HTTPS_EXAMPLE_ORG_U1_P1.origin,
+ savedLogins: [FORM_LOGIN_HTTPS_EXAMPLE_ORG_U1_P1],
+ expectedItems: [
+ {
+ login: FORM_LOGIN_HTTPS_EXAMPLE_ORG_U1_P1,
+ },
+ ],
+ });
+});
+
+add_task(async function test_sameOriginOnlyHTTPS_noUsername() {
+ let loginWithoutUsername = FORM_LOGIN_HTTPS_EXAMPLE_ORG_U1_P1.clone();
+ loginWithoutUsername.QueryInterface(Ci.nsILoginMetaInfo).guid = "no-username";
+ loginWithoutUsername.username = "";
+ await runTestcase({
+ formOrigin: loginWithoutUsername.origin,
+ savedLogins: [loginWithoutUsername],
+ expectedItems: [
+ {
+ login: loginWithoutUsername,
+ time: true,
+ },
+ ],
+ });
+});
+
+add_task(async function test_sameOriginOnlyHTTP() {
+ await runTestcase({
+ formOrigin: FORM_LOGIN_HTTP_EXAMPLE_ORG_U1_P1.origin,
+ savedLogins: [FORM_LOGIN_HTTP_EXAMPLE_ORG_U1_P1],
+ expectedItems: [
+ {
+ login: FORM_LOGIN_HTTP_EXAMPLE_ORG_U1_P1,
+ },
+ ],
+ });
+});
+
+// Scheme upgrade/downgrade tasks
+
+add_task(async function test_sameOriginDedupeSchemeUpgrade() {
+ await runTestcase({
+ formOrigin: FORM_LOGIN_HTTPS_EXAMPLE_ORG_U1_P1.origin,
+ savedLogins: [
+ FORM_LOGIN_HTTPS_EXAMPLE_ORG_U1_P1,
+ FORM_LOGIN_HTTP_EXAMPLE_ORG_U1_P1,
+ ],
+ expectedItems: [
+ {
+ login: FORM_LOGIN_HTTPS_EXAMPLE_ORG_U1_P1,
+ },
+ ],
+ });
+});
+
+add_task(async function test_sameOriginSchemeDowngrade() {
+ // Should have no https: when formOrigin is https:
+ await runTestcase({
+ formOrigin: FORM_LOGIN_HTTP_EXAMPLE_ORG_U1_P1.origin,
+ savedLogins: [
+ FORM_LOGIN_HTTPS_EXAMPLE_ORG_U1_P1,
+ FORM_LOGIN_HTTP_EXAMPLE_ORG_U1_P1,
+ ],
+ expectedItems: [
+ {
+ login: FORM_LOGIN_HTTP_EXAMPLE_ORG_U1_P1,
+ },
+ ],
+ });
+});
+
+add_task(async function test_sameOriginNotShadowedSchemeUpgrade() {
+ await runTestcase({
+ formOrigin: FORM_LOGIN_HTTPS_EXAMPLE_ORG_U1_P1.origin,
+ savedLogins: [
+ FORM_LOGIN_HTTPS_EXAMPLE_ORG_U1_P1,
+ FORM_LOGIN_HTTP_EXAMPLE_ORG_U1_P2, // Different password
+ ],
+ expectedItems: [
+ {
+ login: FORM_LOGIN_HTTPS_EXAMPLE_ORG_U1_P1,
+ time: true,
+ },
+ {
+ login: FORM_LOGIN_HTTP_EXAMPLE_ORG_U1_P2,
+ time: true,
+ },
+ ],
+ });
+});
+
+add_task(async function test_sameOriginShadowedSchemeDowngrade() {
+ // Should have no https: when formOrigin is https:
+ await runTestcase({
+ formOrigin: FORM_LOGIN_HTTP_EXAMPLE_ORG_U1_P1.origin,
+ savedLogins: [
+ FORM_LOGIN_HTTPS_EXAMPLE_ORG_U1_P1,
+ FORM_LOGIN_HTTP_EXAMPLE_ORG_U1_P2, // Different password
+ ],
+ expectedItems: [
+ {
+ login: FORM_LOGIN_HTTP_EXAMPLE_ORG_U1_P2,
+ },
+ ],
+ });
+});
+
+// Non-default port tasks
+
+add_task(async function test_sameDomainDifferentPort_onDefault() {
+ await runTestcase({
+ formOrigin: FORM_LOGIN_HTTPS_EXAMPLE_ORG_U1_P1.origin,
+ savedLogins: [
+ FORM_LOGIN_HTTPS_EXAMPLE_ORG_U1_P1,
+ FORM_LOGIN_HTTPS_EXAMPLE_ORG_8080_U1_P2,
+ ],
+ expectedItems: [
+ {
+ login: FORM_LOGIN_HTTPS_EXAMPLE_ORG_U1_P1,
+ },
+ ],
+ });
+});
+
+add_task(async function test_sameDomainDifferentPort_onNonDefault() {
+ await runTestcase({
+ // Swap the formOrigin compared to above
+ formOrigin: FORM_LOGIN_HTTPS_EXAMPLE_ORG_8080_U1_P2.origin,
+ savedLogins: [
+ FORM_LOGIN_HTTPS_EXAMPLE_ORG_U1_P1,
+ FORM_LOGIN_HTTPS_EXAMPLE_ORG_8080_U1_P2,
+ ],
+ expectedItems: [
+ {
+ login: FORM_LOGIN_HTTPS_EXAMPLE_ORG_8080_U1_P2,
+ },
+ ],
+ });
+});
+
+// HTTP auth. suggestions
+
+add_task(async function test_sameOriginOnlyHTTPAuth() {
+ await runTestcase({
+ formOrigin: FORM_LOGIN_HTTPS_EXAMPLE_ORG_U1_P1.origin,
+ savedLogins: [HTTP_LOGIN_HTTPS_EXAMPLE_ORG_U1_P1],
+ expectedItems: [
+ {
+ login: HTTP_LOGIN_HTTPS_EXAMPLE_ORG_U1_P1,
+ },
+ ],
+ });
+});
+
+// Helpers
+
+function formLogin(modifications = {}) {
+ let mods = Object.assign(
+ {},
+ {
+ timePasswordChanged: 1573821296000,
+ },
+ modifications
+ );
+ return TestData.formLogin(mods);
+}
+
+function authLogin(modifications = {}) {
+ let mods = Object.assign(
+ {},
+ {
+ timePasswordChanged: 1573821296000,
+ },
+ modifications
+ );
+ return TestData.authLogin(mods);
+}
+
+/**
+ * Tests if the LoginManagerContextMenu returns the correct login items.
+ */
+async function runTestcase({ formOrigin, savedLogins, expectedItems }) {
+ const DOCUMENT_CONTENT = "<form><input id='pw' type=password></form>";
+
+ for (let login of savedLogins) {
+ Services.logins.addLogin(login);
+ }
+
+ // Create the logins menuitems fragment.
+ let { fragment, document } = createLoginsFragment(
+ formOrigin,
+ DOCUMENT_CONTENT
+ );
+
+ if (!expectedItems.length) {
+ Assert.ok(fragment === null, "Null returned. No logins were found.");
+ return;
+ }
+ let actualItems = [...fragment.children];
+
+ // Check if the items are those expected to be listed.
+ checkLoginItems(actualItems, expectedItems);
+
+ document.body.appendChild(fragment);
+
+ // Try to clear the fragment.
+ LoginManagerContextMenu.clearLoginsFromMenu(document);
+ Assert.equal(
+ document.querySelectorAll("menuitem").length,
+ 0,
+ "All items correctly cleared."
+ );
+
+ Services.logins.removeAllUserFacingLogins();
+}
+
+/**
+ * Create a fragment with a menuitem for each login.
+ */
+function createLoginsFragment(url, content) {
+ const CHROME_URL = "chrome://mock-chrome/content/";
+
+ // Create a mock document.
+ let document = MockDocument.createTestDocument(
+ CHROME_URL,
+ content,
+ undefined,
+ true
+ );
+
+ // We also need a simple mock Browser object for this test.
+ let browser = {
+ ownerDocument: document,
+ };
+
+ let formOrigin = LoginHelper.getLoginOrigin(url);
+ return {
+ document,
+ fragment: LoginManagerContextMenu.addLoginsToMenu(
+ null,
+ browser,
+ formOrigin
+ ),
+ };
+}
+
+function checkLoginItems(actualItems, expectedDetails) {
+ for (let [i, expectedDetail] of expectedDetails.entries()) {
+ let actualElement = actualItems[i];
+
+ Assert.equal(actualElement.localName, "menuitem", "Check localName");
+
+ let expectedLabel = expectedDetail.login.username;
+ if (!expectedLabel) {
+ expectedLabel += _stringBundle.GetStringFromName("noUsername");
+ }
+ if (expectedDetail.time) {
+ expectedLabel +=
+ " (" +
+ dateAndTimeFormatter.format(
+ new Date(expectedDetail.login.timePasswordChanged)
+ ) +
+ ")";
+ }
+ Assert.equal(
+ actualElement.getAttribute("label"),
+ expectedLabel,
+ `Check label ${i}`
+ );
+ }
+
+ Assert.equal(
+ actualItems.length,
+ expectedDetails.length,
+ "Should have the correct number of menu items"
+ );
+}
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 0000000000..f2d50f8691
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_dedupeLogins.js
@@ -0,0 +1,423 @@
+/**
+ * Test LoginHelper.dedupeLogins
+ */
+
+"use strict";
+
+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 DOMAIN2_HTTP_TO_HTTP_U2_P2 = TestData.formLogin({
+ origin: "http://www4.example.com",
+ formActionOrigin: "http://www4.example.com",
+ password: "password two",
+ username: "username two",
+});
+
+const DOMAIN1_HTTPS_TO_HTTP_U1_P1 = TestData.formLogin({
+ formActionOrigin: "http://www.example.com",
+ origin: "https://www3.example.com",
+ timePasswordChanged: 4000,
+ timeLastUsed: 1000,
+});
+const DOMAIN1_HTTPS_TO_HTTPS_U1_P1 = TestData.formLogin({
+ formActionOrigin: "https://www.example.com",
+ origin: "https://www3.example.com",
+ timePasswordChanged: 4000,
+ timeLastUsed: 1000,
+});
+
+const DOMAIN1_HTTPS_TO_HTTPS_U1_P2 = TestData.formLogin({
+ formActionOrigin: "https://www.example.com",
+ origin: "https://www3.example.com",
+ password: "password two",
+ timePasswordChanged: 4000,
+ timeLastUsed: 1000,
+});
+
+const DOMAIN1_HTTPS_TO_EMPTY_U1_P1 = TestData.formLogin({
+ formActionOrigin: "",
+ origin: "https://www3.example.com",
+});
+const DOMAIN1_HTTPS_TO_EMPTYU_P1 = TestData.formLogin({
+ origin: "https://www3.example.com",
+ username: "",
+});
+const DOMAIN1_HTTP_AUTH = TestData.authLogin({
+ origin: "http://www3.example.com",
+});
+const DOMAIN1_HTTPS_AUTH = TestData.authLogin({
+ origin: "https://www3.example.com",
+});
+const DOMAIN1_HTTPS_LOGIN = TestData.formLogin({
+ origin: "https://www3.example.com",
+ formActionOrigin: "https://www3.example.com",
+});
+const DOMAIN1_HTTP_LOGIN = TestData.formLogin({
+ origin: "http://www3.example.com",
+ formActionOrigin: "http://www3.example.com",
+});
+const DOMAIN1_HTTPS_NONSTANDARD_PORT1 = TestData.formLogin({
+ origin: "https://www3.example.com:8001",
+ formActionOrigin: "https://www3.example.com:8001",
+});
+const DOMAIN1_HTTPS_NONSTANDARD_PORT2 = TestData.formLogin({
+ origin: "https://www3.example.com:8008",
+ formActionOrigin: "https://www3.example.com:8008",
+});
+const DOMAIN2_HTTPS_LOGIN = TestData.formLogin({
+ origin: "https://www4.example.com",
+ formActionOrigin: "https://www4.example.com",
+});
+const DOMAIN2_HTTPS_LOGIN_NEWER = TestData.formLogin({
+ origin: "https://www4.example.com",
+ formActionOrigin: "https://www4.example.com",
+ timePasswordChanged: 4000,
+ timeLastUsed: 4000,
+});
+const DOMAIN2_HTTPS_TO_HTTPS_U2_P2 = TestData.formLogin({
+ origin: "https://www4.example.com",
+ formActionOrigin: "https://www4.example.com",
+ password: "password two",
+ username: "username two",
+});
+
+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_HTTP_U1_P1],
+ undefined,
+ [],
+ ],
+ [
+ "same un+pw, different scheme, reverse order",
+ [DOMAIN1_HTTPS_TO_HTTP_U1_P1],
+ [DOMAIN1_HTTPS_TO_HTTP_U1_P1, DOMAIN1_HTTP_TO_HTTP_U1_P1],
+ undefined,
+ [],
+ ],
+ [
+ "same un+pw, different scheme, include origin",
+ [DOMAIN1_HTTP_TO_HTTP_U1_P1, DOMAIN1_HTTPS_TO_HTTP_U1_P1],
+ [DOMAIN1_HTTP_TO_HTTP_U1_P1, DOMAIN1_HTTPS_TO_HTTP_U1_P1],
+ ["origin", "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(async function test_dedupeLogins_resolveBy() {
+ Assert.ok(
+ DOMAIN1_HTTP_TO_HTTP_U1_P1.timeLastUsed >
+ DOMAIN1_HTTPS_TO_HTTP_U1_P1.timeLastUsed,
+ "Sanity check timeLastUsed difference"
+ );
+ Assert.ok(
+ DOMAIN1_HTTP_TO_HTTP_U1_P1.timePasswordChanged <
+ DOMAIN1_HTTPS_TO_HTTP_U1_P1.timePasswordChanged,
+ "Sanity check timePasswordChanged difference"
+ );
+ Assert.ok(
+ DOMAIN1_HTTPS_LOGIN.timePasswordChanged <
+ DOMAIN2_HTTPS_LOGIN_NEWER.timePasswordChanged,
+ "Sanity check timePasswordChanged difference"
+ );
+
+ let testcases = [
+ [
+ "default resolveBy is timeLastUsed",
+ [DOMAIN1_HTTP_TO_HTTP_U1_P1],
+ [DOMAIN1_HTTPS_TO_HTTP_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_HTTP_U1_P1],
+ ],
+ [
+ "resolveBy timeLastUsed + timePasswordChanged",
+ [DOMAIN1_HTTP_TO_HTTP_U1_P1],
+ [DOMAIN1_HTTPS_TO_HTTP_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_HTTP_U1_P1],
+ undefined,
+ ["timeLastUsed", "timePasswordChanged"],
+ ],
+ [
+ "resolveBy timePasswordChanged",
+ [DOMAIN1_HTTPS_TO_HTTP_U1_P1],
+ [DOMAIN1_HTTPS_TO_HTTP_U1_P1, DOMAIN1_HTTP_TO_HTTP_U1_P1],
+ undefined,
+ ["timePasswordChanged"],
+ ],
+ [
+ "resolveBy timePasswordChanged, reversed",
+ [DOMAIN1_HTTPS_TO_HTTP_U1_P1],
+ [DOMAIN1_HTTP_TO_HTTP_U1_P1, DOMAIN1_HTTPS_TO_HTTP_U1_P1],
+ undefined,
+ ["timePasswordChanged"],
+ ],
+ [
+ "resolveBy timePasswordChanged + timeLastUsed",
+ [DOMAIN1_HTTPS_TO_HTTP_U1_P1],
+ [DOMAIN1_HTTPS_TO_HTTP_U1_P1, DOMAIN1_HTTP_TO_HTTP_U1_P1],
+ undefined,
+ ["timePasswordChanged", "timeLastUsed"],
+ ],
+ [
+ "resolveBy timePasswordChanged + timeLastUsed, reversed",
+ [DOMAIN1_HTTPS_TO_HTTP_U1_P1],
+ [DOMAIN1_HTTP_TO_HTTP_U1_P1, DOMAIN1_HTTPS_TO_HTTP_U1_P1],
+ undefined,
+ ["timePasswordChanged", "timeLastUsed"],
+ ],
+ [
+ "resolveBy scheme + timePasswordChanged, prefer HTTP",
+ [DOMAIN1_HTTP_TO_HTTP_U1_P1],
+ [DOMAIN1_HTTPS_TO_HTTP_U1_P1, DOMAIN1_HTTP_TO_HTTP_U1_P1],
+ undefined,
+ ["scheme", "timePasswordChanged"],
+ DOMAIN1_HTTP_TO_HTTP_U1_P1.origin,
+ ],
+ [
+ "resolveBy scheme + timePasswordChanged, prefer HTTP, reversed input",
+ [DOMAIN1_HTTP_TO_HTTP_U1_P1],
+ [DOMAIN1_HTTP_TO_HTTP_U1_P1, DOMAIN1_HTTPS_TO_HTTP_U1_P1],
+ undefined,
+ ["scheme", "timePasswordChanged"],
+ DOMAIN1_HTTP_TO_HTTP_U1_P1.origin,
+ ],
+ [
+ "resolveBy scheme + timePasswordChanged, prefer HTTPS",
+ [DOMAIN1_HTTPS_TO_HTTP_U1_P1],
+ [DOMAIN1_HTTPS_TO_HTTP_U1_P1, DOMAIN1_HTTP_TO_HTTP_U1_P1],
+ undefined,
+ ["scheme", "timePasswordChanged"],
+ DOMAIN1_HTTPS_TO_HTTP_U1_P1.origin,
+ ],
+ [
+ "resolveBy scheme + timePasswordChanged, prefer HTTPS, reversed input",
+ [DOMAIN1_HTTPS_TO_HTTP_U1_P1],
+ [DOMAIN1_HTTP_TO_HTTP_U1_P1, DOMAIN1_HTTPS_TO_HTTP_U1_P1],
+ undefined,
+ ["scheme", "timePasswordChanged"],
+ DOMAIN1_HTTPS_TO_HTTP_U1_P1.origin,
+ ],
+ [
+ "resolveBy scheme HTTP auth",
+ [DOMAIN1_HTTPS_AUTH],
+ [DOMAIN1_HTTP_AUTH, DOMAIN1_HTTPS_AUTH],
+ undefined,
+ ["scheme"],
+ DOMAIN1_HTTPS_AUTH.origin,
+ ],
+ [
+ "resolveBy scheme HTTP auth, reversed input",
+ [DOMAIN1_HTTPS_AUTH],
+ [DOMAIN1_HTTPS_AUTH, DOMAIN1_HTTP_AUTH],
+ undefined,
+ ["scheme"],
+ DOMAIN1_HTTPS_AUTH.origin,
+ ],
+ [
+ "resolveBy scheme, empty form submit URL",
+ [DOMAIN1_HTTPS_TO_HTTP_U1_P1],
+ [DOMAIN1_HTTPS_TO_HTTP_U1_P1, DOMAIN1_HTTPS_TO_EMPTY_U1_P1],
+ undefined,
+ ["scheme"],
+ DOMAIN1_HTTPS_TO_HTTP_U1_P1.origin,
+ ],
+ [
+ "resolveBy subdomain, different subdomains, same login, subdomain1 preferred",
+ [DOMAIN1_HTTPS_LOGIN],
+ [DOMAIN1_HTTPS_LOGIN, DOMAIN2_HTTPS_LOGIN],
+ undefined,
+ ["subdomain"],
+ DOMAIN1_HTTPS_LOGIN.origin,
+ ],
+ [
+ "resolveBy subdomain, different subdomains, same login, subdomain2 preferred",
+ [DOMAIN2_HTTPS_LOGIN],
+ [DOMAIN1_HTTPS_LOGIN, DOMAIN2_HTTPS_LOGIN],
+ undefined,
+ ["subdomain"],
+ DOMAIN2_HTTPS_LOGIN.origin,
+ ],
+ [
+ "resolveBy subdomain+timePasswordChanged, different subdomains, same login, subdomain1 preferred",
+ [DOMAIN1_HTTPS_LOGIN],
+ [DOMAIN1_HTTPS_LOGIN, DOMAIN2_HTTPS_LOGIN_NEWER],
+ undefined,
+ ["subdomain", "timePasswordChanged"],
+ DOMAIN1_HTTPS_LOGIN.origin,
+ ],
+ [
+ "resolveBy subdomain, same subdomain, different schemes",
+ [DOMAIN1_HTTPS_LOGIN],
+ [DOMAIN1_HTTPS_LOGIN, DOMAIN1_HTTP_LOGIN],
+ undefined,
+ ["subdomain"],
+ DOMAIN1_HTTPS_LOGIN.origin,
+ ],
+ [
+ "resolveBy subdomain, same subdomain, different ports",
+ [DOMAIN1_HTTPS_LOGIN],
+ [
+ DOMAIN1_HTTPS_LOGIN,
+ DOMAIN1_HTTPS_NONSTANDARD_PORT1,
+ DOMAIN1_HTTPS_NONSTANDARD_PORT2,
+ ],
+ undefined,
+ ["subdomain"],
+ DOMAIN1_HTTPS_LOGIN.origin,
+ ],
+ [
+ "resolveBy subdomain, same subdomain, different schemes, different ports",
+ [DOMAIN1_HTTPS_LOGIN],
+ [
+ DOMAIN1_HTTPS_LOGIN,
+ DOMAIN1_HTTPS_NONSTANDARD_PORT1,
+ DOMAIN1_HTTPS_NONSTANDARD_PORT2,
+ ],
+ undefined,
+ ["subdomain"],
+ DOMAIN1_HTTPS_AUTH.origin,
+ ],
+ [
+ "resolveBy matching searchAndDedupeLogins, prefer domain matches then https: scheme over http:",
+ // expected:
+ [DOMAIN1_HTTPS_TO_HTTPS_U1_P1, DOMAIN2_HTTPS_TO_HTTPS_U2_P2],
+ // logins:
+ [
+ DOMAIN1_HTTP_TO_HTTP_U1_P1,
+ DOMAIN1_HTTPS_TO_HTTPS_U1_P1,
+ DOMAIN2_HTTP_TO_HTTP_U2_P2,
+ DOMAIN2_HTTPS_TO_HTTPS_U2_P2,
+ ],
+ // uniqueKeys:
+ undefined,
+ // resolveBy:
+ ["subdomain", "actionOrigin", "scheme", "timePasswordChanged"],
+ // preferredOrigin:
+ DOMAIN1_HTTPS_TO_HTTPS_U1_P1.origin,
+ ],
+ ];
+
+ for (let tc of testcases) {
+ let description = tc.shift();
+ let expected = tc.shift();
+ let actual = LoginHelper.dedupeLogins(...tc);
+ info(`'${description}' actual:\n ${JSON.stringify(actual, null, 2)}`);
+ 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(async function test_dedupeLogins_preferredOriginMissing() {
+ let testcases = [
+ [
+ "resolveBy scheme + timePasswordChanged, missing preferredOrigin",
+ /preferredOrigin/,
+ [DOMAIN1_HTTPS_TO_HTTP_U1_P1, DOMAIN1_HTTP_TO_HTTP_U1_P1],
+ undefined,
+ ["scheme", "timePasswordChanged"],
+ ],
+ [
+ "resolveBy timePasswordChanged + scheme, missing preferredOrigin",
+ /preferredOrigin/,
+ [DOMAIN1_HTTPS_TO_HTTP_U1_P1, DOMAIN1_HTTP_TO_HTTP_U1_P1],
+ undefined,
+ ["timePasswordChanged", "scheme"],
+ ],
+ [
+ "resolveBy scheme + timePasswordChanged, empty preferredOrigin",
+ /preferredOrigin/,
+ [DOMAIN1_HTTPS_TO_HTTP_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 0000000000..1d6e161149
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_disabled_hosts.js
@@ -0,0 +1,223 @@
+/* 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 origin1 = "http://disabled1.example.com";
+ let origin2 = "http://disabled2.example.com";
+ let origin3 = "https://disabled2.example.com";
+ Services.logins.setLoginSavingEnabled(origin1, false);
+ Services.logins.setLoginSavingEnabled(origin2, false);
+ Services.logins.setLoginSavingEnabled(origin3, false);
+
+ LoginTestUtils.assertDisabledHostsEqual(
+ Services.logins.getAllDisabledHosts(),
+ [origin1, origin2, origin3]
+ );
+
+ // Adding the same host twice should not result in an error.
+ Services.logins.setLoginSavingEnabled(origin2, false);
+ LoginTestUtils.assertDisabledHostsEqual(
+ Services.logins.getAllDisabledHosts(),
+ [origin1, origin2, origin3]
+ );
+
+ // Removing a disabled host should work.
+ Services.logins.setLoginSavingEnabled(origin2, true);
+ LoginTestUtils.assertDisabledHostsEqual(
+ Services.logins.getAllDisabledHosts(),
+ [origin1, origin3]
+ );
+
+ // Removing the last disabled host should work.
+ Services.logins.setLoginSavingEnabled(origin1, true);
+ Services.logins.setLoginSavingEnabled(origin3, true);
+ LoginTestUtils.assertDisabledHostsEqual(
+ Services.logins.getAllDisabledHosts(),
+ []
+ );
+});
+
+/**
+ * Tests setLoginSavingEnabled and getLoginSavingEnabled.
+ */
+add_task(function test_setLoginSavingEnabled_getLoginSavingEnabled() {
+ let origin1 = "http://disabled.example.com";
+ let origin2 = "https://disabled.example.com";
+
+ // Hosts should not be disabled by default.
+ Assert.ok(Services.logins.getLoginSavingEnabled(origin1));
+ Assert.ok(Services.logins.getLoginSavingEnabled(origin2));
+
+ // Test setting initial values.
+ Services.logins.setLoginSavingEnabled(origin1, false);
+ Services.logins.setLoginSavingEnabled(origin2, true);
+ Assert.ok(!Services.logins.getLoginSavingEnabled(origin1));
+ Assert.ok(Services.logins.getLoginSavingEnabled(origin2));
+
+ // Test changing values.
+ Services.logins.setLoginSavingEnabled(origin1, true);
+ Services.logins.setLoginSavingEnabled(origin2, false);
+ Assert.ok(Services.logins.getLoginSavingEnabled(origin1));
+ Assert.ok(!Services.logins.getLoginSavingEnabled(origin2));
+
+ // Clean up.
+ Services.logins.setLoginSavingEnabled(origin2, true);
+});
+
+/**
+ * Tests setLoginSavingEnabled with invalid NUL characters in the origin.
+ */
+add_task(function test_setLoginSavingEnabled_invalid_characters() {
+ let origin = "http://null\0X.example.com";
+ Assert.throws(
+ () => Services.logins.setLoginSavingEnabled(origin, false),
+ /Invalid origin/
+ );
+
+ // 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 origin1 = "http://example.com";
+ let origin2 = "http://localhost";
+
+ // The default value for the preference should be true.
+ Assert.ok(Services.prefs.getBoolPref("signon.rememberSignons"));
+
+ // Hosts should not be disabled by default.
+ Services.logins.setLoginSavingEnabled(origin1, false);
+ Assert.ok(!Services.logins.getLoginSavingEnabled(origin1));
+ Assert.ok(Services.logins.getLoginSavingEnabled(origin2));
+
+ // Disable storage of saved passwords globally.
+ Services.prefs.setBoolPref("signon.rememberSignons", false);
+ registerCleanupFunction(() =>
+ Services.prefs.clearUserPref("signon.rememberSignons")
+ );
+
+ // All hosts should now appear disabled.
+ Assert.ok(!Services.logins.getLoginSavingEnabled(origin1));
+ Assert.ok(!Services.logins.getLoginSavingEnabled(origin2));
+
+ // The list of disabled hosts should be unaltered.
+ LoginTestUtils.assertDisabledHostsEqual(
+ Services.logins.getAllDisabledHosts(),
+ [origin1]
+ );
+
+ // Changing values with the preference set should work.
+ Services.logins.setLoginSavingEnabled(origin1, true);
+ Services.logins.setLoginSavingEnabled(origin2, false);
+
+ // All hosts should still appear disabled.
+ Assert.ok(!Services.logins.getLoginSavingEnabled(origin1));
+ Assert.ok(!Services.logins.getLoginSavingEnabled(origin2));
+
+ // The list of disabled hosts should have been changed.
+ LoginTestUtils.assertDisabledHostsEqual(
+ Services.logins.getAllDisabledHosts(),
+ [origin2]
+ );
+
+ // Enable storage of saved passwords again.
+ Services.prefs.setBoolPref("signon.rememberSignons", true);
+
+ // Hosts should now appear enabled as requested.
+ Assert.ok(Services.logins.getLoginSavingEnabled(origin1));
+ Assert.ok(!Services.logins.getLoginSavingEnabled(origin2));
+
+ // Clean up.
+ Services.logins.setLoginSavingEnabled(origin2, true);
+ LoginTestUtils.assertDisabledHostsEqual(
+ Services.logins.getAllDisabledHosts(),
+ []
+ );
+});
+
+/**
+ * Tests storing disabled hosts with non-ASCII characters where IDN is supported.
+ */
+add_task(
+ async function test_storage_setLoginSavingEnabled_nonascii_IDN_is_supported() {
+ let origin = "http://大.net";
+ let encoding = "http://xn--pss.net";
+
+ // Test adding disabled host with nonascii URL (http://大.net).
+ Services.logins.setLoginSavingEnabled(origin, false);
+ await LoginTestUtils.reloadData();
+ Assert.equal(Services.logins.getLoginSavingEnabled(origin), false);
+ Assert.equal(Services.logins.getLoginSavingEnabled(encoding), false);
+ LoginTestUtils.assertDisabledHostsEqual(
+ Services.logins.getAllDisabledHosts(),
+ [origin]
+ );
+
+ LoginTestUtils.clearData();
+
+ // Test adding disabled host with IDN ("http://xn--pss.net").
+ Services.logins.setLoginSavingEnabled(encoding, false);
+ await LoginTestUtils.reloadData();
+ Assert.equal(Services.logins.getLoginSavingEnabled(origin), false);
+ Assert.equal(Services.logins.getLoginSavingEnabled(encoding), false);
+ LoginTestUtils.assertDisabledHostsEqual(
+ Services.logins.getAllDisabledHosts(),
+ [origin]
+ );
+
+ LoginTestUtils.clearData();
+ }
+);
+
+/**
+ * Tests storing disabled hosts with non-ASCII characters where IDN is not supported.
+ */
+add_task(
+ async function test_storage_setLoginSavingEnabled_nonascii_IDN_not_supported() {
+ let origin = "http://√.com";
+ let encoding = "http://xn--19g.com";
+
+ // Test adding disabled host with nonascii URL (http://√.com).
+ Services.logins.setLoginSavingEnabled(origin, false);
+ await LoginTestUtils.reloadData();
+ Assert.equal(Services.logins.getLoginSavingEnabled(origin), 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);
+ await LoginTestUtils.reloadData();
+ Assert.equal(Services.logins.getLoginSavingEnabled(origin), 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_displayOrigin.js b/toolkit/components/passwordmgr/test/unit/test_displayOrigin.js
new file mode 100644
index 0000000000..e8e8d5606c
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_displayOrigin.js
@@ -0,0 +1,45 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Test nsILoginInfo.displayOrigin
+ */
+
+"use strict";
+
+add_task(function test_displayOrigin() {
+ // Trying to access `displayOrigin` for each login shouldn't throw.
+ for (let loginInfo of TestData.loginList()) {
+ let { displayOrigin } = loginInfo;
+ info(loginInfo.origin);
+ info(displayOrigin);
+ Assert.equal(typeof displayOrigin, "string", "Check type");
+ Assert.greater(displayOrigin.length, 0, "Check length");
+ if (
+ loginInfo.origin.startsWith("chrome://") ||
+ loginInfo.origin.startsWith("file://")
+ ) {
+ Assert.ok(displayOrigin.startsWith(loginInfo.origin), "Contains origin");
+ } else {
+ Assert.ok(
+ displayOrigin.startsWith(loginInfo.origin.replace(/.+:\/\//, "")),
+ "Contains domain"
+ );
+ }
+ let matches;
+ if ((matches = loginInfo.origin.match(/:([0-9]+)$/))) {
+ Assert.ok(displayOrigin.includes(matches[1]), "Check port is included");
+ }
+ Assert.ok(!displayOrigin.includes("null"), "Doesn't contain `null`");
+ Assert.ok(
+ !displayOrigin.includes("undefined"),
+ "Doesn't contain `undefined`"
+ );
+ if (loginInfo.httpRealm !== null) {
+ Assert.ok(
+ displayOrigin.includes(loginInfo.httpRealm),
+ "Contains httpRealm"
+ );
+ }
+ }
+});
diff --git a/toolkit/components/passwordmgr/test/unit/test_doLoginsMatch.js b/toolkit/components/passwordmgr/test/unit/test_doLoginsMatch.js
new file mode 100644
index 0000000000..fb38f08cf7
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_doLoginsMatch.js
@@ -0,0 +1,57 @@
+/**
+ * Test LoginHelper.doLoginsMatch
+ */
+
+add_task(function test_formActionOrigin_ignoreSchemes() {
+ let httpActionLogin = TestData.formLogin();
+ let httpsActionLogin = TestData.formLogin({
+ formActionOrigin: "https://www.example.com",
+ });
+ let jsActionLogin = TestData.formLogin({
+ formActionOrigin: "javascript:",
+ });
+ let emptyActionLogin = TestData.formLogin({
+ formActionOrigin: "",
+ });
+
+ Assert.notEqual(
+ httpActionLogin.formActionOrigin,
+ httpsActionLogin.formActionOrigin,
+ "Ensure actions differ"
+ );
+
+ const TEST_CASES = [
+ [httpActionLogin, httpActionLogin, true],
+ [httpsActionLogin, httpsActionLogin, true],
+ [jsActionLogin, jsActionLogin, true],
+ [emptyActionLogin, emptyActionLogin, true],
+ // only differing by scheme:
+ [httpsActionLogin, httpActionLogin, true],
+ [httpActionLogin, httpsActionLogin, true],
+
+ // empty matches everything
+ [httpsActionLogin, emptyActionLogin, true],
+ [emptyActionLogin, httpsActionLogin, true],
+ [jsActionLogin, emptyActionLogin, true],
+ [emptyActionLogin, jsActionLogin, true],
+
+ // Begin false cases:
+ [httpsActionLogin, jsActionLogin, false],
+ [jsActionLogin, httpsActionLogin, false],
+ [httpActionLogin, jsActionLogin, false],
+ [jsActionLogin, httpActionLogin, false],
+ ];
+
+ for (let [login1, login2, expected] of TEST_CASES) {
+ Assert.strictEqual(
+ LoginHelper.doLoginsMatch(login1, login2, {
+ ignorePassword: false,
+ ignoreSchemes: true,
+ }),
+ expected,
+ `LoginHelper.doLoginsMatch:
+\t${JSON.stringify(login1)}
+\t${JSON.stringify(login2)}`
+ );
+ }
+});
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 0000000000..835527eece
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_getFormFields.js
@@ -0,0 +1,359 @@
+/**
+ * Test for LoginManagerChild._getFormFields.
+ */
+
+"use strict";
+
+XPCOMUtils.defineLazyGlobalGetters(this, ["URL"]);
+
+const { LoginFormFactory } = ChromeUtils.import(
+ "resource://gre/modules/LoginFormFactory.jsm"
+);
+const LMCBackstagePass = ChromeUtils.import(
+ "resource://gre/modules/LoginManagerChild.jsm",
+ null
+);
+const { LoginManagerChild } = LMCBackstagePass;
+
+const TESTENVIRONMENTS = {
+ filledPW1WithGeneratedPassword: {
+ generatedPWFieldSelectors: ["#pw1"],
+ },
+};
+
+const TESTCASES = [
+ {
+ description: "1 password field outside of a <form>",
+ document: `<input id="pw1" type=password>`,
+ returnedFieldIDs: {
+ usernameField: null,
+ newPasswordField: "pw1",
+ oldPasswordField: null,
+ },
+ skipEmptyFields: undefined,
+ extraTestEnvironments: [TESTENVIRONMENTS.filledPW1WithGeneratedPassword],
+ },
+ {
+ description: "1 text field outside of a <form> without a password field",
+ document: `<input id="un1">`,
+ returnedFieldIDs: {
+ usernameField: null,
+ newPasswordField: null,
+ oldPasswordField: null,
+ },
+ skipEmptyFields: undefined,
+ // there is no password field to fill, so no sense testing with gen. passwords
+ extraTestEnvironments: [],
+ },
+ {
+ description: "1 username & password field outside of a <form>",
+ document: `<input id="un1">
+ <input id="pw1" type=password>`,
+ returnedFieldIDs: {
+ usernameField: "un1",
+ newPasswordField: "pw1",
+ oldPasswordField: null,
+ },
+ skipEmptyFields: undefined,
+ extraTestEnvironments: [TESTENVIRONMENTS.filledPW1WithGeneratedPassword],
+ },
+ {
+ beforeGetFunction(doc, formLike) {
+ // Access the formLike.elements lazy getter to have it cached.
+ Assert.equal(
+ formLike.elements.length,
+ 2,
+ "Check initial elements length"
+ );
+ doc.getElementById("un1").remove();
+ },
+ description: "1 username & password field outside of a <form>, un1 removed",
+ document: `<input id="un1">
+ <input id="pw1" type=password>`,
+ returnedFieldIDs: {
+ usernameField: null,
+ newPasswordField: "pw1",
+ oldPasswordField: null,
+ },
+ skipEmptyFields: undefined,
+ extraTestEnvironments: [TESTENVIRONMENTS.filledPW1WithGeneratedPassword],
+ },
+ {
+ description: "1 username & password field in a <form>",
+ document: `<form>
+ <input id="un1">
+ <input id="pw1" type=password>
+ </form>`,
+ returnedFieldIDs: {
+ usernameField: "un1",
+ newPasswordField: "pw1",
+ oldPasswordField: null,
+ },
+ skipEmptyFields: undefined,
+ extraTestEnvironments: [TESTENVIRONMENTS.filledPW1WithGeneratedPassword],
+ },
+ {
+ description: "5 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>
+ <input id="pw5" type=password>`,
+ returnedFieldIDs: {
+ usernameField: null,
+ newPasswordField: "pw1",
+ oldPasswordField: null,
+ },
+ skipEmptyFields: undefined,
+ extraTestEnvironments: [TESTENVIRONMENTS.filledPW1WithGeneratedPassword],
+ },
+ {
+ description: "6 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>
+ <input id="pw5" type=password>
+ <input id="pw6" type=password>`,
+ returnedFieldIDs: {
+ usernameField: null,
+ newPasswordField: null,
+ oldPasswordField: null,
+ },
+ skipEmptyFields: undefined,
+ extraTestEnvironments: [TESTENVIRONMENTS.filledPW1WithGeneratedPassword],
+ },
+ {
+ 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: {
+ usernameField: null,
+ newPasswordField: null,
+ oldPasswordField: null,
+ },
+ skipEmptyFields: true,
+ // This test assumes that pw1 has not been filled, so don't test prefilling it
+ extraTestEnvironments: [],
+ },
+ {
+ description: "Form with 1 password field",
+ document: `<form><input id="pw1" type=password></form>`,
+ returnedFieldIDs: {
+ usernameField: null,
+ newPasswordField: "pw1",
+ oldPasswordField: null,
+ },
+ skipEmptyFields: undefined,
+ extraTestEnvironments: [TESTENVIRONMENTS.filledPW1WithGeneratedPassword],
+ },
+ {
+ description: "Form with 2 password fields",
+ document: `<form><input id="pw1" type=password><input id='pw2' type=password></form>`,
+ returnedFieldIDs: {
+ usernameField: null,
+ newPasswordField: "pw1",
+ oldPasswordField: null,
+ },
+ skipEmptyFields: undefined,
+ extraTestEnvironments: [TESTENVIRONMENTS.filledPW1WithGeneratedPassword],
+ },
+ {
+ 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: {
+ usernameField: null,
+ newPasswordField: "pw1",
+ oldPasswordField: null,
+ },
+ skipEmptyFields: undefined,
+ extraTestEnvironments: [TESTENVIRONMENTS.filledPW1WithGeneratedPassword],
+ },
+ {
+ description:
+ "1 password field in a form, 1 text field outside (not processed)",
+ document: `<form><input id="pw1" type=password></form><input>`,
+ returnedFieldIDs: {
+ usernameField: null,
+ newPasswordField: "pw1",
+ oldPasswordField: null,
+ },
+ skipEmptyFields: undefined,
+ extraTestEnvironments: [TESTENVIRONMENTS.filledPW1WithGeneratedPassword],
+ },
+ {
+ description:
+ "1 text field in a form, 1 password field outside (not processed)",
+ document: `<form><input></form><input id="pw1" type=password>`,
+ returnedFieldIDs: {
+ usernameField: null,
+ newPasswordField: null,
+ oldPasswordField: null,
+ },
+ skipEmptyFields: undefined,
+ extraTestEnvironments: [TESTENVIRONMENTS.filledPW1WithGeneratedPassword],
+ },
+ {
+ 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: {
+ usernameField: null,
+ newPasswordField: "pw1",
+ oldPasswordField: null,
+ },
+ skipEmptyFields: undefined,
+ extraTestEnvironments: [TESTENVIRONMENTS.filledPW1WithGeneratedPassword],
+ },
+ {
+ 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: {
+ usernameField: null,
+ newPasswordField: null,
+ oldPasswordField: null,
+ },
+ skipEmptyFields: true,
+ extraTestEnvironments: [TESTENVIRONMENTS.filledPW1WithGeneratedPassword],
+ },
+ {
+ 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: {
+ usernameField: null,
+ newPasswordField: "pw1",
+ oldPasswordField: null,
+ },
+ skipEmptyFields: true,
+ extraTestEnvironments: [TESTENVIRONMENTS.filledPW1WithGeneratedPassword],
+ },
+ {
+ description:
+ "3 password fields, 2nd and 3rd are filled with generated passwords",
+ document: `<input id="pw1" type=password>
+ <input id="pw2" type=password value="pass2">
+ <input id="pw3" type=password value="pass3">`,
+ returnedFieldIDs: {
+ usernameField: null,
+ newPasswordField: "pw2",
+ confirmPasswordField: "pw3",
+ oldPasswordField: "pw1",
+ },
+ skipEmptyFields: undefined,
+ generatedPWFieldSelectors: ["#pw2", "#pw3"],
+ // this test doesn't make sense to run with different filled generated password values
+ extraTestEnvironments: [],
+ },
+];
+
+const TEST_ENVIRONMENT_CASES = TESTCASES.flatMap(tc => {
+ let arr = [tc];
+ // also run this test case with this different state
+ for (let env of tc.extraTestEnvironments) {
+ arr.push({
+ ...tc,
+ ...env,
+ });
+ }
+ return arr;
+});
+
+for (let tc of TEST_ENVIRONMENT_CASES) {
+ info("Sanity checking the testcase: " + tc.description);
+
+ (function() {
+ let testcase = tc;
+ add_task(async function() {
+ info("Starting testcase: " + testcase.description);
+ info("Document string: " + testcase.document);
+ 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);
+
+ if (testcase.beforeGetFunction) {
+ await testcase.beforeGetFunction(document, formLike);
+ }
+
+ let lmc = new LoginManagerChild();
+ let loginFormState = lmc.stateForDocument(formLike.ownerDocument);
+ loginFormState.generatedPasswordFields = _generateDocStateFromTestCase(
+ testcase,
+ document
+ );
+
+ let actual = lmc._getFormFields(
+ formLike,
+ testcase.skipEmptyFields,
+ new Set()
+ );
+
+ [
+ "usernameField",
+ "newPasswordField",
+ "oldPasswordField",
+ "confirmPasswordField",
+ ].forEach(fieldName => {
+ Assert.ok(
+ fieldName in actual,
+ "_getFormFields return value includes " + fieldName
+ );
+ });
+ for (let key of Object.keys(testcase.returnedFieldIDs)) {
+ let expectedID = testcase.returnedFieldIDs[key];
+ if (expectedID === null) {
+ Assert.strictEqual(
+ actual[key],
+ expectedID,
+ "Check returned field " + key + " is null"
+ );
+ } else {
+ Assert.strictEqual(
+ actual[key].id,
+ expectedID,
+ "Check returned field " + key + " ID"
+ );
+ }
+ }
+ });
+ })();
+}
+
+function _generateDocStateFromTestCase(stateProperties, document) {
+ // prepopulate the document form state LMC holds with
+ // any generated password fields defined in this testcase
+ let generatedPasswordFields = new Set();
+ info(
+ "stateProperties has generatedPWFieldSelectors: " +
+ stateProperties.generatedPWFieldSelectors?.join(", ")
+ );
+
+ if (stateProperties.generatedPWFieldSelectors?.length) {
+ stateProperties.generatedPWFieldSelectors.forEach(sel => {
+ let field = document.querySelector(sel);
+ if (field) {
+ generatedPasswordFields.add(field);
+ } else {
+ info(`No password field: ${sel} found in this document`);
+ }
+ });
+ }
+ return generatedPasswordFields;
+}
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 0000000000..e648ff4bb7
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_getPasswordFields.js
@@ -0,0 +1,313 @@
+/**
+ * Test for LoginManagerChild._getPasswordFields using LoginFormFactory.
+ */
+
+/* globals todo_check_eq */
+"use strict";
+
+const { LoginFormFactory } = ChromeUtils.import(
+ "resource://gre/modules/LoginFormFactory.jsm"
+);
+const LMCBackstagePass = ChromeUtils.import(
+ "resource://gre/modules/LoginManagerChild.jsm",
+ null
+);
+const { LoginManagerChild } = LMCBackstagePass;
+const TESTCASES = [
+ {
+ description: "Empty document",
+ document: ``,
+ returnedFieldIDsByFormLike: [],
+ minPasswordLength: undefined,
+ },
+ {
+ description: "Non-password input with no <form> present",
+ document: `<input>`,
+ // Only the IDs of password fields should be in this array
+ returnedFieldIDsByFormLike: [[]],
+ minPasswordLength: undefined,
+ },
+ {
+ description: "1 password field outside of a <form>",
+ document: `<input id="pw1" type=password>`,
+ returnedFieldIDsByFormLike: [["pw1"]],
+ minPasswordLength: undefined,
+ },
+ {
+ description: "5 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>
+ <input id="pw5" type=password>`,
+ returnedFieldIDsByFormLike: [["pw1", "pw2", "pw3", "pw4", "pw5"]],
+ minPasswordLength: undefined,
+ },
+ {
+ description: "6 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>
+ <input id="pw5" type=password>
+ <input id="pw6" type=password>`,
+ returnedFieldIDsByFormLike: [[]],
+ minPasswordLength: undefined,
+ },
+ {
+ description:
+ "4 password fields outside of a <form> (1 empty, 3 full) with minPasswordLength=2",
+ 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"]],
+ minPasswordLength: 2,
+ },
+ {
+ description: "Form with 1 password field",
+ document: `<form><input id="pw1" type=password></form>`,
+ returnedFieldIDsByFormLike: [["pw1"]],
+ minPasswordLength: undefined,
+ },
+ {
+ description: "Form with 2 password fields",
+ document: `<form><input id="pw1" type=password><input id='pw2' type=password></form>`,
+ returnedFieldIDsByFormLike: [["pw1", "pw2"]],
+ minPasswordLength: 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"]],
+ minPasswordLength: 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"]],
+ minPasswordLength: undefined,
+ },
+ {
+ description:
+ "2 password fields outside of a <form> with 1 linked via @form + minPasswordLength",
+ document: `<input id="pw1" type=password><input id="pw2" type=password form="form1">
+ <form id="form1"></form>`,
+ returnedFieldIDsByFormLike: [[], []],
+ minPasswordLength: 2,
+ },
+ {
+ description: "minPasswordLength should also skip white-space only fields",
+ /* eslint-disable no-tabs */
+ 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>`,
+ /* eslint-enable no-tabs */
+ returnedFieldIDsByFormLike: [[], []],
+ minPasswordLength: 2,
+ },
+ {
+ description: "minPasswordLength should skip too-short field values",
+ document: `<form>
+ <input id="pw-empty" type=password>
+ <input id="pw-tooshort" type=password value="p">
+ <input id="pw" type=password value="pz">
+ </form>`,
+ returnedFieldIDsByFormLike: [["pw"]],
+ minPasswordLength: 2,
+ },
+ {
+ description: "minPasswordLength should allow matching-length field values",
+ document: `<form>
+ <input id="pw-empty" type=password>
+ <input id="pw-matchlen" type=password value="pz">
+ <input id="pw" type=password value="pazz">
+ </form>`,
+ returnedFieldIDsByFormLike: [["pw-matchlen", "pw"]],
+ minPasswordLength: 2,
+ },
+ {
+ description:
+ "2 password fields outside of a <form> with 1 linked via @form + minPasswordLength with 1 empty",
+ document: `<input id="pw1" type=password value=" pass1 "><input id="pw2" type=password form="form1">
+ <form id="form1"></form>`,
+ returnedFieldIDsByFormLike: [["pw1"], []],
+ minPasswordLength: 2,
+ fieldOverrideRecipe: {
+ // Ensure a recipe without `notPasswordSelector` doesn't cause a problem.
+ hosts: ["localhost:8080"],
+ },
+ },
+ {
+ description:
+ "3 password fields outside of a <form> with 1 linked via @form + minPasswordLength",
+ document: `<input id="pw1" type=password value="pass1"><input id="pw2" type=password form="form1" value="pass2"><input id="pw3" type=password value="pass3">
+ <form id="form1"><input id="pw4" type=password></form>`,
+ returnedFieldIDsByFormLike: [["pw3"], ["pw2"]],
+ minPasswordLength: 2,
+ fieldOverrideRecipe: {
+ hosts: ["localhost:8080"],
+ notPasswordSelector: "#pw1",
+ },
+ },
+ {
+ beforeGetFunction(doc) {
+ doc.getElementById("pw1").remove();
+ },
+ description:
+ "1 password field outside of a <form> which gets removed/disconnected",
+ document: `<input id="pw1" type=password>`,
+ returnedFieldIDsByFormLike: [[]],
+ minPasswordLength: undefined,
+ },
+];
+
+for (let tc of TESTCASES) {
+ info("Sanity checking the testcase: " + tc.description);
+
+ (function() {
+ let testcase = tc;
+ add_task(async function() {
+ info("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.
+ info(
+ "Checking if the new FormLike for the same root has the same properties"
+ );
+ formLikeEqual(formLike, existingFormLike);
+ }
+
+ if (testcase.beforeGetFunction) {
+ await testcase.beforeGetFunction(document);
+ }
+
+ 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 = new LoginManagerChild()._getPasswordFields(
+ formLikeFromInput,
+ {
+ fieldOverrideRecipe: testcase.fieldOverrideRecipe,
+ minPasswordLength: testcase.minPasswordLength,
+ }
+ );
+
+ if (
+ ChromeUtils.getClassName(formLikeFromInput.rootElement) ===
+ "HTMLFormElement"
+ ) {
+ let formLikeFromForm = LoginFormFactory.createFromForm(
+ formLikeFromInput.rootElement
+ );
+ info(
+ "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"
+ );
+ }
+ }
+ });
+ })();
+}
+
+const EMOJI_TESTCASES = [
+ {
+ description:
+ "Single characters composed of 2 code units should ideally fail minPasswordLength of 2",
+ document: `<form>
+ <input id="pw" type=password value="💩">
+ </form>`,
+ returnedFieldIDsByFormLike: [["pw"]],
+ minPasswordLength: 2,
+ },
+ {
+ description:
+ "Single characters composed of multiple code units should ideally fail minPasswordLength of 2",
+ document: `<form>
+ <input id="pw" type=password value="👪">
+ </form>`,
+ minPasswordLength: 2,
+ },
+];
+
+// Note: Bug 780449 tracks our handling of emoji and multi-code-point characters in password fields
+// and the .length we should expect when a password value includes them
+for (let tc of EMOJI_TESTCASES) {
+ info("Sanity checking the testcase: " + tc.description);
+
+ (function() {
+ let testcase = tc;
+ add_task(async function() {
+ info("Starting testcase: " + testcase.description);
+ let document = MockDocument.createTestDocument(
+ "http://localhost:8080/test/",
+ testcase.document
+ );
+ let input = document.querySelector("input[type='password']");
+ Assert.ok(input, "Found the password field");
+ let formLike = LoginFormFactory.createFromField(input);
+ let pwFields = new LoginManagerChild()._getPasswordFields(formLike, {
+ minPasswordLength: testcase.minPasswordLength,
+ });
+ info("Got password fields: " + pwFields.length);
+ todo_check_eq(
+ pwFields.length,
+ 0,
+ "Check a single-character (emoji) password is excluded from the password fields collection"
+ );
+ });
+ })();
+}
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 0000000000..e0b8c11252
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_getPasswordOrigin.js
@@ -0,0 +1,31 @@
+/**
+ * Test for LoginHelper.getLoginOrigin
+ */
+
+"use strict";
+
+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 = LoginHelper.getLoginOrigin(input, allowJS);
+ Assert.strictEqual(actual, expected, "Checking: " + input);
+}
diff --git a/toolkit/components/passwordmgr/test/unit/test_getUserNameAndPasswordFields.js b/toolkit/components/passwordmgr/test/unit/test_getUserNameAndPasswordFields.js
new file mode 100644
index 0000000000..1bc313176b
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_getUserNameAndPasswordFields.js
@@ -0,0 +1,151 @@
+/**
+ * Test for LoginManagerChild.getUserNameAndPasswordFields
+ */
+
+"use strict";
+
+XPCOMUtils.defineLazyGlobalGetters(this, ["URL"]);
+
+const LMCBackstagePass = ChromeUtils.import(
+ "resource://gre/modules/LoginManagerChild.jsm",
+ null
+);
+const { LoginManagerChild } = LMCBackstagePass;
+const TESTCASES = [
+ {
+ description: "1 password field outside of a <form>",
+ document: `<input id="pw1" type=password>`,
+ returnedFieldIDs: [null, "pw1", null],
+ },
+ {
+ description: "1 text field outside of a <form> without a password field",
+ document: `<input id="un1">`,
+ returnedFieldIDs: [null, null, null],
+ },
+ {
+ description: "1 username & password field outside of a <form>",
+ document: `<input id="un1">
+ <input id="pw1" type=password>`,
+ returnedFieldIDs: ["un1", "pw1", null],
+ },
+ {
+ description: "1 username & password field in a <form>",
+ document: `<form>
+ <input id="un1">
+ <input id="pw1" type=password>
+ </form>`,
+ returnedFieldIDs: ["un1", "pw1", null],
+ },
+ {
+ description: "5 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>
+ <input id="pw5" type=password>`,
+ returnedFieldIDs: [null, "pw1", null],
+ },
+ {
+ description: "6 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>
+ <input id="pw5" type=password>
+ <input id="pw6" type=password>`,
+ returnedFieldIDs: [null, null, null],
+ },
+ {
+ description: "Form with 1 password field",
+ document: `<form><input id="pw1" type=password></form>`,
+ returnedFieldIDs: [null, "pw1", null],
+ },
+ {
+ description: "Form with 2 password fields",
+ document: `<form><input id="pw1" type=password><input id='pw2' type=password></form>`,
+ returnedFieldIDs: [null, "pw1", null],
+ },
+ {
+ 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],
+ },
+ {
+ 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],
+ },
+ {
+ 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],
+ },
+ {
+ 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],
+ },
+];
+
+for (let tc of TESTCASES) {
+ info("Sanity checking the testcase: " + tc.description);
+
+ (function() {
+ let testcase = tc;
+ add_task(async function() {
+ info("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/"
+ );
+ MockDocument.mockNodePrincipalProperty(
+ input,
+ "http://localhost:8080/test/"
+ );
+
+ // Additional mock to cache recipes
+ let win = {};
+ Object.defineProperty(document, "defaultView", {
+ value: win,
+ });
+ let formOrigin = LoginHelper.getLoginOrigin(document.documentURI);
+ LoginRecipesContent.cacheRecipes(formOrigin, win, new Set());
+
+ let actual = new LoginManagerChild().getUserNameAndPasswordFields(input);
+
+ Assert.strictEqual(
+ testcase.returnedFieldIDs.length,
+ 3,
+ "getUserNameAndPasswordFields 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_isOriginMatching.js b/toolkit/components/passwordmgr/test/unit/test_isOriginMatching.js
new file mode 100644
index 0000000000..02547609ec
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_isOriginMatching.js
@@ -0,0 +1,177 @@
+/**
+ * Test LoginHelper.isOriginMatching
+ */
+
+"use strict";
+
+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"],
+
+ // The formActionOrigin can be "javascript:"
+ [true, "javascript:", "javascript:"],
+ [false, "javascript:", "http://example.com"],
+ [false, "http://example.com", "javascript:"],
+
+ // HTTP Auth. logins have a null formActionOrigin
+ [true, null, null],
+ [false, null, "http://example.com"],
+ [false, "http://example.com", null],
+
+ [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 },
+ ],
+ [
+ true,
+ "http://sub.example.com",
+ "http://example.com",
+ { acceptDifferentSubdomains: true },
+ ],
+ [
+ true,
+ "http://sub.sub.example.com",
+ "http://example.com",
+ { acceptDifferentSubdomains: true },
+ ],
+ [
+ true,
+ "http://example.com",
+ "http://sub.example.com",
+ { acceptDifferentSubdomains: true },
+ ],
+ [
+ true,
+ "http://example.com",
+ "http://sub.sub.example.com",
+ { acceptDifferentSubdomains: true },
+ ],
+ [
+ false,
+ "https://sub.example.com",
+ "http://example.com",
+ { acceptDifferentSubdomains: true, schemeUpgrades: true },
+ ],
+ [
+ true,
+ "http://sub.example.com",
+ "https://example.com",
+ { acceptDifferentSubdomains: true, schemeUpgrades: true },
+ ],
+ [
+ true,
+ "http://sub.example.com",
+ "http://example.com:8081",
+ { acceptDifferentSubdomains: true },
+ ],
+ [
+ false,
+ "http://sub.example.com",
+ "http://sub.example.mozilla.com",
+ { acceptDifferentSubdomains: true },
+ ],
+ // signon.includeOtherSubdomainsInLookup allows acceptDifferentSubdomains to be false
+ [
+ false,
+ "http://sub.example.com",
+ "http://example.com",
+ { acceptDifferentSubdomains: false },
+ ],
+ [
+ false,
+ "http://sub.sub.example.com",
+ "http://example.com",
+ { acceptDifferentSubdomains: false },
+ ],
+ [
+ false,
+ "http://sub.example.com",
+ "http://example.com:8081",
+ { acceptDifferentSubdomains: false },
+ ],
+ [
+ false,
+ "http://sub.example.com",
+ "http://sub.example.mozilla.com",
+ { acceptDifferentSubdomains: false },
+ ],
+
+ // HTTP Auth. logins have a null formActionOrigin
+ [
+ false,
+ null,
+ "http://example.com",
+ {
+ acceptDifferentSubdomains: false,
+ acceptWildcardMatch: true,
+ 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_isProbablyANewPasswordField.js b/toolkit/components/passwordmgr/test/unit/test_isProbablyANewPasswordField.js
new file mode 100644
index 0000000000..3571ffe58e
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_isProbablyANewPasswordField.js
@@ -0,0 +1,170 @@
+/**
+ * Test for LoginAutoComplete._isProbablyANewPasswordField.
+ */
+
+"use strict";
+
+const LoginAutoComplete = Cc[
+ "@mozilla.org/login-manager/autocompletesearch;1"
+].getService(Ci.nsILoginAutoCompleteSearch).wrappedJSObject;
+
+function labelledByDocument() {
+ let doc = MockDocument.createTestDocument(
+ "http://localhost:8080/test/",
+ `<div>
+ <label id="paper-input-label-2">Password</label>
+ <input aria-labelledby="paper-input-label-2" type="password">
+ </div>`
+ );
+ let div = doc.querySelector("div");
+ // Put the div contents inside shadow DOM.
+ div.attachShadow({ mode: "open" }).append(...div.children);
+ return doc;
+}
+const LABELLEDBY_SHADOW_TESTCASE = labelledByDocument();
+
+const TESTCASES = [
+ // Note there is no test case for `<input type="password" autocomplete="new-password">`
+ // since _isProbablyANewPasswordField explicitly does not run in that case.
+ {
+ description: "Basic login form",
+ document: `
+ <h1>Sign in</h1>
+ <form>
+ <label>Username: <input type="text" name="username"></label>
+ <label>Password: <input type="password" name="password"></label>
+ <input type="submit" value="Sign in">
+ </form>
+ `,
+ expectedResult: [false],
+ },
+ {
+ description: "Basic registration form",
+ document: `
+ <h1>Create account</h1>
+ <form>
+ <label>Username: <input type="text" name="username"></label>
+ <label>Password: <input type="password" name="new-password"></label>
+ <input type="submit" value="Register">
+ </form>
+ `,
+ expectedResult: [true],
+ },
+ {
+ description: "Basic password change form",
+ document: `
+ <h1>Change password</h1>
+ <form>
+ <label>Current Password: <input type="password" name="current-password"></label>
+ <label>New Password: <input type="password" name="new-password"></label>
+ <label>Confirm Password: <input type="password" name="confirm-password"></label>
+ <input type="submit" value="Save">
+ </form>
+ `,
+ expectedResult: [false, true, true],
+ },
+ {
+ description: "Basic login 'form' without a form element",
+ document: `
+ <h1>Sign in</h1>
+ <label>Username: <input type="text" name="username"></label>
+ <label>Password: <input type="password" name="password"></label>
+ <input type="submit" value="Sign in">
+ `,
+ expectedResult: [false],
+ },
+ {
+ description: "Basic registration 'form' without a form element",
+ document: `
+ <h1>Create account</h1>
+ <label>Username: <input type="text" name="username"></label>
+ <label>Password: <input type="password" name="new-password"></label>
+ <input type="submit" value="Register">
+ `,
+ expectedResult: [true],
+ },
+ {
+ description: "Basic password change 'form' without a form element",
+ document: `
+ <h1>Change password</h1>
+ <label>Current Password: <input type="password" name="current-password"></label>
+ <label>New Password: <input type="password" name="new-password"></label>
+ <label>Confirm Password: <input type="password" name="confirm-password"></label>
+ <input type="submit" value="Save">
+ `,
+ expectedResult: [false, true, true],
+ },
+ {
+ description: "Password field with aria-labelledby inside shadow DOM",
+ document: LABELLEDBY_SHADOW_TESTCASE,
+ inputs: LABELLEDBY_SHADOW_TESTCASE.querySelector(
+ "div"
+ ).shadowRoot.querySelectorAll("input[type='password']"),
+ expectedResult: [false],
+ },
+];
+
+add_task(async function test_returns_false_when_pref_disabled() {
+ const threshold = Services.prefs.getStringPref(
+ NEW_PASSWORD_HEURISTIC_ENABLED_PREF
+ );
+
+ info("Temporarily disabling new-password heuristic pref");
+ Services.prefs.setStringPref(NEW_PASSWORD_HEURISTIC_ENABLED_PREF, "-1");
+
+ // Use registration form test case, where we know it should return true if enabled
+ const testcase = TESTCASES[1];
+ info("Starting testcase: " + testcase.description);
+ const document =
+ testcase.document instanceof Document
+ ? testcase.document
+ : MockDocument.createTestDocument(
+ "http://localhost:8080/test/",
+ testcase.document
+ );
+ for (let [i, input] of testcase.inputs ||
+ document.querySelectorAll(`input[type="password"]`).entries()) {
+ const result = LoginAutoComplete._isProbablyANewPasswordField(input);
+ Assert.strictEqual(
+ result,
+ false,
+ `When the pref is set to disable, the result is always false, e.g. for the testcase, ${testcase.description} ${i}`
+ );
+ }
+
+ info("Re-enabling new-password heuristic pref");
+ Services.prefs.setStringPref(NEW_PASSWORD_HEURISTIC_ENABLED_PREF, threshold);
+});
+
+for (let testcase of TESTCASES) {
+ info("Sanity checking the testcase: " + testcase.description);
+
+ (function() {
+ add_task(async function() {
+ info("Starting testcase: " + testcase.description);
+ let document =
+ testcase.document instanceof Document
+ ? testcase.document
+ : MockDocument.createTestDocument(
+ "http://localhost:8080/test/",
+ testcase.document
+ );
+
+ const results = [];
+ for (let input of testcase.inputs ||
+ document.querySelectorAll(`input[type="password"]`)) {
+ const result = LoginAutoComplete._isProbablyANewPasswordField(input);
+ results.push(result);
+ }
+
+ for (let i = 0; i < testcase.expectedResult.length; i++) {
+ let expectedResult = testcase.expectedResult[i];
+ Assert.strictEqual(
+ results[i],
+ expectedResult,
+ `In the test case, ${testcase.description}, check if password field #${i} is a new password field.`
+ );
+ }
+ });
+ })();
+}
diff --git a/toolkit/components/passwordmgr/test/unit/test_isUsernameFieldType.js b/toolkit/components/passwordmgr/test/unit/test_isUsernameFieldType.js
new file mode 100644
index 0000000000..93c6488b8d
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_isUsernameFieldType.js
@@ -0,0 +1,160 @@
+/**
+ * Test for LoginHelper.isUsernameFieldType
+ */
+
+"use strict";
+
+const autocompleteTypes = {
+ "": true,
+ on: true,
+ off: true,
+ name: false,
+ "unrecognized-type": true,
+ "given-name": false,
+ "additional-name": false,
+ "family-name": false,
+ nickname: false,
+ username: true,
+ "new-password": false,
+ "current-password": false,
+ "organization-title": false,
+ organization: false,
+ "street-address": false,
+ "address-line1": false,
+ "address-line2": false,
+ "address-line3": false,
+ "address-level4": false,
+ "address-level3": false,
+ "address-level2": false,
+ "address-level1": false,
+ country: false,
+ "country-name": false,
+ "postal-code": false,
+ "cc-name": false,
+ "cc-given-name": false,
+ "cc-additional-name": false,
+ "cc-family-name": false,
+ "cc-number": false,
+ "cc-exp": false,
+ "cc-exp-month": false,
+ "cc-exp-year": false,
+ "cc-csc": false,
+ "cc-type": false,
+ "transaction-currency": false,
+ "transaction-amount": false,
+ language: false,
+ bday: false,
+ "bday-day": false,
+ "bday-month": false,
+ "bday-year": false,
+ sex: false,
+ url: false,
+ photo: false,
+ tel: true,
+ "tel-country-code": false,
+ "tel-national": true,
+ "tel-area-code": false,
+ "tel-local": false,
+ "tel-local-prefix": false,
+ "tel-local-suffix": false,
+ "tel-extension": false,
+ email: true,
+ impp: false,
+};
+
+const TESTCASES = [
+ {
+ description: "type=text",
+ document: `<input type="text">`,
+ expected: true,
+ },
+ {
+ description: "type=email, no autocomplete attribute",
+ document: `<input type="email">`,
+ expected: true,
+ },
+ {
+ description: "type=url, no autocomplete attribute",
+ document: `<input type="url">`,
+ expected: true,
+ },
+ {
+ description: "type=tel, no autocomplete attribute",
+ document: `<input type="tel">`,
+ expected: true,
+ },
+ {
+ description: "type=number, no autocomplete attribute",
+ document: `<input type="number">`,
+ expected: true,
+ },
+ {
+ description: "type=search, no autocomplete attribute",
+ document: `<input type="search">`,
+ expected: false,
+ },
+ {
+ description: "type=range, no autocomplete attribute",
+ document: `<input type="range">`,
+ expected: false,
+ },
+ {
+ description: "type=date, no autocomplete attribute",
+ document: `<input type="date">`,
+ expected: false,
+ },
+ {
+ description: "type=month, no autocomplete attribute",
+ document: `<input type="month">`,
+ expected: false,
+ },
+ {
+ description: "type=week, no autocomplete attribute",
+ document: `<input type="week">`,
+ expected: false,
+ },
+ {
+ description: "type=time, no autocomplete attribute",
+ document: `<input type="time">`,
+ expected: false,
+ },
+ {
+ description: "type=datetime, no autocomplete attribute",
+ document: `<input type="datetime">`,
+ expected: false,
+ },
+ {
+ description: "type=datetime-local, no autocomplete attribute",
+ document: `<input type="datetime-local">`,
+ expected: false,
+ },
+ {
+ description: "type=color, no autocomplete attribute",
+ document: `<input type="color">`,
+ expected: false,
+ },
+];
+
+for (let [name, expected] of Object.entries(autocompleteTypes)) {
+ TESTCASES.push({
+ description: `type=text autocomplete=${name}`,
+ document: `<input type="text" autocomplete="${name}">`,
+ expected,
+ });
+}
+
+TESTCASES.forEach(testcase => {
+ add_task(async function() {
+ info("Starting testcase: " + testcase.description);
+ let document = MockDocument.createTestDocument(
+ "http://localhost:8080/test/",
+ testcase.document
+ );
+ let input = document.querySelector("input");
+ Assert.equal(
+ LoginHelper.isUsernameFieldType(input),
+ testcase.expected,
+ testcase.description
+ );
+ });
+});
diff --git a/toolkit/components/passwordmgr/test/unit/test_legacy_empty_formActionOrigin.js b/toolkit/components/passwordmgr/test/unit/test_legacy_empty_formActionOrigin.js
new file mode 100644
index 0000000000..48459672f6
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_legacy_empty_formActionOrigin.js
@@ -0,0 +1,123 @@
+/* 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 formActionOrigin 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 origin and formActionOrigin 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 origin and the
+ * formActionOrigin (that will be equal to the origin 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 origin, but an empty formActionOrigin. 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 formActionOrigin, 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({
+ origin: "http://any.example.com",
+ formActionOrigin: "",
+ });
+ Services.logins.addLogin(loginInfo);
+
+ // Normal form logins cannot be added anymore.
+ loginInfo = TestData.formLogin({ origin: "http://any.example.com" });
+ Assert.throws(() => Services.logins.addLogin(loginInfo), /already exists/);
+
+ // Authentication logins can still be added.
+ loginInfo = TestData.authLogin({ origin: "http://any.example.com" });
+ Services.logins.addLogin(loginInfo);
+
+ // Form logins can be added for other hosts.
+ loginInfo = TestData.formLogin({ origin: "http://other.example.com" });
+ Services.logins.addLogin(loginInfo);
+});
+
+/**
+ * Verifies that findLogins, searchLogins, and countLogins include all logins
+ * that have an empty formActionOrigin in the store, even when a formActionOrigin is
+ * specified.
+ */
+add_task(function test_search_all_wildcard() {
+ // Search a given formActionOrigin on any host.
+ let matchData = newPropertyBag({
+ formActionOrigin: "http://www.example.com",
+ });
+ Assert.equal(Services.logins.searchLogins(matchData).length, 2);
+
+ Assert.equal(
+ Services.logins.findLogins("", "http://www.example.com", null).length,
+ 2
+ );
+
+ Assert.equal(
+ Services.logins.countLogins("", "http://www.example.com", null),
+ 2
+ );
+
+ // Restrict the search to one host.
+ matchData.setProperty("origin", "http://any.example.com");
+ Assert.equal(Services.logins.searchLogins(matchData).length, 1);
+
+ Assert.equal(
+ Services.logins.findLogins(
+ "http://any.example.com",
+ "http://www.example.com",
+ null
+ ).length,
+ 1
+ );
+
+ Assert.equal(
+ Services.logins.countLogins(
+ "http://any.example.com",
+ "http://www.example.com",
+ null
+ ),
+ 1
+ );
+});
+
+/**
+ * Verifies that specifying an empty string for formActionOrigin in searchLogins
+ * includes only logins that have an empty formActionOrigin in the store.
+ */
+add_task(function test_searchLogins_wildcard() {
+ let logins = Services.logins.searchLogins(
+ newPropertyBag({ formActionOrigin: "" })
+ );
+
+ let loginInfo = TestData.formLogin({
+ origin: "http://any.example.com",
+ formActionOrigin: "",
+ });
+ 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 0000000000..a953e67900
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_legacy_validation.js
@@ -0,0 +1,94 @@
+/* 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({ origin: testValue });
+ Assert.throws(
+ () => Services.logins.addLogin(loginInfo),
+ /login values can't contain newlines/
+ );
+
+ loginInfo = TestData.formLogin({ formActionOrigin: 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 formActionOrigin.
+ let loginInfo = TestData.formLogin({ usernameField: "." });
+ Assert.throws(
+ () => Services.logins.addLogin(loginInfo),
+ /login values can't be periods/
+ );
+
+ loginInfo = TestData.formLogin({ formActionOrigin: "." });
+ Assert.throws(
+ () => Services.logins.addLogin(loginInfo),
+ /login values can't be periods/
+ );
+
+ // Test the sequence " (" inside the value of the "origin" property.
+ loginInfo = TestData.formLogin({ origin: "http://parens (.example.com" });
+ Assert.throws(
+ () => Services.logins.addLogin(loginInfo),
+ /bad parens in origin/
+ );
+});
+
+/**
+ * Tests legacy validation with setLoginSavingEnabled.
+ */
+add_task(function test_setLoginSavingEnabled_invalid_characters_legacy() {
+ for (let origin of [
+ "http://newline\n.example.com",
+ "http://carriagereturn.example.com\r",
+ ".",
+ ]) {
+ Assert.throws(
+ () => Services.logins.setLoginSavingEnabled(origin, false),
+ /Invalid origin/
+ );
+ }
+});
diff --git a/toolkit/components/passwordmgr/test/unit/test_login_autocomplete_result.js b/toolkit/components/passwordmgr/test/unit/test_login_autocomplete_result.js
new file mode 100644
index 0000000000..5df952c12b
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_login_autocomplete_result.js
@@ -0,0 +1,1321 @@
+const { LoginAutoCompleteResult } = ChromeUtils.import(
+ "resource://gre/modules/LoginAutoComplete.jsm"
+);
+let 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_SCHEME_UPGRADES = "signon.schemeUpgrades";
+
+let matchingLogins = [];
+matchingLogins.push(
+ new nsLoginInfo(
+ "https://mochi.test:8888",
+ "https://autocomplete:8888",
+ null,
+ "",
+ "emptypass1",
+ "uname",
+ "pword"
+ )
+);
+
+matchingLogins.push(
+ new nsLoginInfo(
+ "https://mochi.test:8888",
+ "https://autocomplete:8888",
+ null,
+ "tempuser1",
+ "temppass1",
+ "uname",
+ "pword"
+ )
+);
+
+matchingLogins.push(
+ new nsLoginInfo(
+ "https://mochi.test:8888",
+ "https://autocomplete:8888",
+ null,
+ "testuser2",
+ "testpass2",
+ "uname",
+ "pword"
+ )
+);
+// subdomain:
+matchingLogins.push(
+ new nsLoginInfo(
+ "https://sub.mochi.test:8888",
+ "https://autocomplete:8888",
+ null,
+ "testuser3",
+ "testpass3",
+ "uname",
+ "pword"
+ )
+);
+
+// to test signon.schemeUpgrades
+matchingLogins.push(
+ new nsLoginInfo(
+ "http://mochi.test:8888",
+ "http://autocomplete:8888",
+ null,
+ "zzzuser4",
+ "zzzpass4",
+ "uname",
+ "pword"
+ )
+);
+
+// HTTP auth
+matchingLogins.push(
+ new nsLoginInfo(
+ "https://mochi.test:8888",
+ null,
+ "My HTTP auth realm",
+ "httpuser",
+ "httppass"
+ )
+);
+
+add_task(async function setup() {
+ // Get a profile so we have storage access and insert the logins to get unique GUIDs.
+ do_get_profile();
+ matchingLogins = await Services.logins.addLogins(matchingLogins);
+});
+
+add_task(async function test_all_patterns() {
+ let meta = matchingLogins[0].QueryInterface(Ci.nsILoginMetaInfo);
+ let dateAndTimeFormatter = new Services.intl.DateTimeFormat(undefined, {
+ dateStyle: "medium",
+ });
+ let time = dateAndTimeFormatter.format(new Date(meta.timePasswordChanged));
+ const LABEL_NO_USERNAME = "No username (" + time + ")";
+ const EXACT_ORIGIN_MATCH_COMMENT = "From this website";
+
+ let expectedResults = [
+ {
+ insecureFieldWarningEnabled: true,
+ isSecure: true,
+ hasBeenTypePassword: false,
+ matchingLogins,
+ items: [
+ {
+ value: "",
+ label: LABEL_NO_USERNAME,
+ style: "loginWithOrigin",
+ comment: { comment: EXACT_ORIGIN_MATCH_COMMENT },
+ },
+ {
+ value: "tempuser1",
+ label: "tempuser1",
+ style: "loginWithOrigin",
+ comment: { comment: EXACT_ORIGIN_MATCH_COMMENT },
+ },
+ {
+ value: "testuser2",
+ label: "testuser2",
+ style: "loginWithOrigin",
+ comment: { comment: EXACT_ORIGIN_MATCH_COMMENT },
+ },
+ {
+ value: "zzzuser4",
+ label: "zzzuser4",
+ style: "loginWithOrigin",
+ comment: { comment: EXACT_ORIGIN_MATCH_COMMENT },
+ },
+ {
+ value: "httpuser",
+ label: "httpuser",
+ style: "loginWithOrigin",
+ comment: { comment: "mochi.test:8888 (My HTTP auth realm)" },
+ },
+ {
+ value: "testuser3",
+ label: "testuser3",
+ style: "loginWithOrigin",
+ comment: { comment: "sub.mochi.test:8888" },
+ },
+ {
+ value: "",
+ label: "View Saved Logins",
+ style: "loginsFooter",
+ comment: { formHostname: "mochi.test", searchStartTimeMS: 0 },
+ },
+ ],
+ },
+ {
+ insecureFieldWarningEnabled: true,
+ isSecure: false,
+ hasBeenTypePassword: false,
+ matchingLogins: [],
+ items: [
+ {
+ value: "",
+ label:
+ "This connection is not secure. Logins entered here could be compromised. Learn More",
+ style: "insecureWarning",
+ comment: "",
+ },
+ {
+ value: "",
+ label: "View Saved Logins",
+ style: "loginsFooter",
+ comment: { formHostname: "mochi.test", searchStartTimeMS: 1 },
+ },
+ ],
+ },
+ {
+ insecureFieldWarningEnabled: true,
+ isSecure: false,
+ hasBeenTypePassword: false,
+ matchingLogins,
+ items: [
+ {
+ value: "",
+ label:
+ "This connection is not secure. Logins entered here could be compromised. Learn More",
+ style: "insecureWarning",
+ comment: "",
+ },
+ {
+ value: "",
+ label: LABEL_NO_USERNAME,
+ style: "loginWithOrigin",
+ comment: { comment: EXACT_ORIGIN_MATCH_COMMENT },
+ },
+ {
+ value: "tempuser1",
+ label: "tempuser1",
+ style: "loginWithOrigin",
+ comment: { comment: EXACT_ORIGIN_MATCH_COMMENT },
+ },
+ {
+ value: "testuser2",
+ label: "testuser2",
+ style: "loginWithOrigin",
+ comment: { comment: EXACT_ORIGIN_MATCH_COMMENT },
+ },
+ {
+ value: "zzzuser4",
+ label: "zzzuser4",
+ style: "loginWithOrigin",
+ comment: { comment: EXACT_ORIGIN_MATCH_COMMENT },
+ },
+ {
+ value: "httpuser",
+ label: "httpuser",
+ style: "loginWithOrigin",
+ comment: { comment: "mochi.test:8888 (My HTTP auth realm)" },
+ },
+ {
+ value: "testuser3",
+ label: "testuser3",
+ style: "loginWithOrigin",
+ comment: { comment: "sub.mochi.test:8888" },
+ },
+ {
+ value: "",
+ label: "View Saved Logins",
+ style: "loginsFooter",
+ comment: { formHostname: "mochi.test" },
+ },
+ ],
+ },
+ {
+ insecureFieldWarningEnabled: true,
+ isSecure: true,
+ hasBeenTypePassword: true,
+ matchingLogins,
+ items: [
+ {
+ value: "emptypass1",
+ label: LABEL_NO_USERNAME,
+ style: "loginWithOrigin",
+ comment: { comment: EXACT_ORIGIN_MATCH_COMMENT },
+ },
+ {
+ value: "temppass1",
+ label: "tempuser1",
+ style: "loginWithOrigin",
+ comment: { comment: EXACT_ORIGIN_MATCH_COMMENT },
+ },
+ {
+ value: "testpass2",
+ label: "testuser2",
+ style: "loginWithOrigin",
+ comment: { comment: EXACT_ORIGIN_MATCH_COMMENT },
+ },
+ {
+ value: "zzzpass4",
+ label: "zzzuser4",
+ style: "loginWithOrigin",
+ comment: { comment: EXACT_ORIGIN_MATCH_COMMENT },
+ },
+ {
+ value: "httppass",
+ label: "httpuser",
+ style: "loginWithOrigin",
+ comment: { comment: "mochi.test:8888 (My HTTP auth realm)" },
+ },
+ {
+ value: "testpass3",
+ label: "testuser3",
+ style: "loginWithOrigin",
+ comment: { comment: "sub.mochi.test:8888" },
+ },
+ {
+ value: "",
+ label: "View Saved Logins",
+ style: "loginsFooter",
+ comment: { formHostname: "mochi.test" },
+ },
+ ],
+ },
+ {
+ insecureFieldWarningEnabled: true,
+ isSecure: false,
+ hasBeenTypePassword: true,
+ matchingLogins,
+ items: [
+ {
+ value: "",
+ label:
+ "This connection is not secure. Logins entered here could be compromised. Learn More",
+ style: "insecureWarning",
+ comment: "",
+ },
+ {
+ value: "emptypass1",
+ label: LABEL_NO_USERNAME,
+ style: "loginWithOrigin",
+ comment: { comment: EXACT_ORIGIN_MATCH_COMMENT },
+ },
+ {
+ value: "temppass1",
+ label: "tempuser1",
+ style: "loginWithOrigin",
+ comment: { comment: EXACT_ORIGIN_MATCH_COMMENT },
+ },
+ {
+ value: "testpass2",
+ label: "testuser2",
+ style: "loginWithOrigin",
+ comment: { comment: EXACT_ORIGIN_MATCH_COMMENT },
+ },
+ {
+ value: "zzzpass4",
+ label: "zzzuser4",
+ style: "loginWithOrigin",
+ comment: { comment: EXACT_ORIGIN_MATCH_COMMENT },
+ },
+ {
+ value: "httppass",
+ label: "httpuser",
+ style: "loginWithOrigin",
+ comment: { comment: "mochi.test:8888 (My HTTP auth realm)" },
+ },
+ {
+ value: "testpass3",
+ label: "testuser3",
+ style: "loginWithOrigin",
+ comment: { comment: "sub.mochi.test:8888" },
+ },
+ {
+ value: "",
+ label: "View Saved Logins",
+ style: "loginsFooter",
+ comment: { formHostname: "mochi.test" },
+ },
+ ],
+ },
+ {
+ insecureFieldWarningEnabled: false,
+ isSecure: true,
+ hasBeenTypePassword: false,
+ matchingLogins,
+ items: [
+ {
+ value: "",
+ label: LABEL_NO_USERNAME,
+ style: "loginWithOrigin",
+ comment: { comment: EXACT_ORIGIN_MATCH_COMMENT },
+ },
+ {
+ value: "tempuser1",
+ label: "tempuser1",
+ style: "loginWithOrigin",
+ comment: { comment: EXACT_ORIGIN_MATCH_COMMENT },
+ },
+ {
+ value: "testuser2",
+ label: "testuser2",
+ style: "loginWithOrigin",
+ comment: { comment: EXACT_ORIGIN_MATCH_COMMENT },
+ },
+ {
+ value: "zzzuser4",
+ label: "zzzuser4",
+ style: "loginWithOrigin",
+ comment: { comment: EXACT_ORIGIN_MATCH_COMMENT },
+ },
+ {
+ value: "httpuser",
+ label: "httpuser",
+ style: "loginWithOrigin",
+ comment: { comment: "mochi.test:8888 (My HTTP auth realm)" },
+ },
+ {
+ value: "testuser3",
+ label: "testuser3",
+ style: "loginWithOrigin",
+ comment: { comment: "sub.mochi.test:8888" },
+ },
+ {
+ value: "",
+ label: "View Saved Logins",
+ style: "loginsFooter",
+ comment: { formHostname: "mochi.test" },
+ },
+ ],
+ },
+ {
+ insecureFieldWarningEnabled: false,
+ isSecure: false,
+ hasBeenTypePassword: false,
+ matchingLogins,
+ items: [
+ {
+ value: "",
+ label: LABEL_NO_USERNAME,
+ style: "loginWithOrigin",
+ comment: { comment: EXACT_ORIGIN_MATCH_COMMENT },
+ },
+ {
+ value: "tempuser1",
+ label: "tempuser1",
+ style: "loginWithOrigin",
+ comment: { comment: EXACT_ORIGIN_MATCH_COMMENT },
+ },
+ {
+ value: "testuser2",
+ label: "testuser2",
+ style: "loginWithOrigin",
+ comment: { comment: EXACT_ORIGIN_MATCH_COMMENT },
+ },
+ {
+ value: "zzzuser4",
+ label: "zzzuser4",
+ style: "loginWithOrigin",
+ comment: { comment: EXACT_ORIGIN_MATCH_COMMENT },
+ },
+ {
+ value: "httpuser",
+ label: "httpuser",
+ style: "loginWithOrigin",
+ comment: { comment: "mochi.test:8888 (My HTTP auth realm)" },
+ },
+ {
+ value: "testuser3",
+ label: "testuser3",
+ style: "loginWithOrigin",
+ comment: { comment: "sub.mochi.test:8888" },
+ },
+ {
+ value: "",
+ label: "View Saved Logins",
+ style: "loginsFooter",
+ comment: { formHostname: "mochi.test" },
+ },
+ ],
+ },
+ {
+ insecureFieldWarningEnabled: false,
+ isSecure: true,
+ hasBeenTypePassword: true,
+ matchingLogins,
+ items: [
+ {
+ value: "emptypass1",
+ label: LABEL_NO_USERNAME,
+ style: "loginWithOrigin",
+ comment: { comment: EXACT_ORIGIN_MATCH_COMMENT },
+ },
+ {
+ value: "temppass1",
+ label: "tempuser1",
+ style: "loginWithOrigin",
+ comment: { comment: EXACT_ORIGIN_MATCH_COMMENT },
+ },
+ {
+ value: "testpass2",
+ label: "testuser2",
+ style: "loginWithOrigin",
+ comment: { comment: EXACT_ORIGIN_MATCH_COMMENT },
+ },
+ {
+ value: "zzzpass4",
+ label: "zzzuser4",
+ style: "loginWithOrigin",
+ comment: { comment: EXACT_ORIGIN_MATCH_COMMENT },
+ },
+ {
+ value: "httppass",
+ label: "httpuser",
+ style: "loginWithOrigin",
+ comment: { comment: "mochi.test:8888 (My HTTP auth realm)" },
+ },
+ {
+ value: "testpass3",
+ label: "testuser3",
+ style: "loginWithOrigin",
+ comment: { comment: "sub.mochi.test:8888" },
+ },
+ {
+ value: "",
+ label: "View Saved Logins",
+ style: "loginsFooter",
+ comment: { formHostname: "mochi.test" },
+ },
+ ],
+ },
+ {
+ insecureFieldWarningEnabled: false,
+ isSecure: false,
+ hasBeenTypePassword: true,
+ matchingLogins,
+ items: [
+ {
+ value: "emptypass1",
+ label: LABEL_NO_USERNAME,
+ style: "loginWithOrigin",
+ comment: { comment: EXACT_ORIGIN_MATCH_COMMENT },
+ },
+ {
+ value: "temppass1",
+ label: "tempuser1",
+ style: "loginWithOrigin",
+ comment: { comment: EXACT_ORIGIN_MATCH_COMMENT },
+ },
+ {
+ value: "testpass2",
+ label: "testuser2",
+ style: "loginWithOrigin",
+ comment: { comment: EXACT_ORIGIN_MATCH_COMMENT },
+ },
+ {
+ value: "zzzpass4",
+ label: "zzzuser4",
+ style: "loginWithOrigin",
+ comment: { comment: EXACT_ORIGIN_MATCH_COMMENT },
+ },
+ {
+ value: "httppass",
+ label: "httpuser",
+ style: "loginWithOrigin",
+ comment: { comment: "mochi.test:8888 (My HTTP auth realm)" },
+ },
+ {
+ value: "testpass3",
+ label: "testuser3",
+ style: "loginWithOrigin",
+ comment: { comment: "sub.mochi.test:8888" },
+ },
+ {
+ value: "",
+ label: "View Saved Logins",
+ style: "loginsFooter",
+ comment: { formHostname: "mochi.test" },
+ },
+ ],
+ },
+ {
+ insecureFieldWarningEnabled: true,
+ isSecure: true,
+ hasBeenTypePassword: false,
+ matchingLogins,
+ items: [
+ {
+ value: "",
+ label: LABEL_NO_USERNAME,
+ style: "loginWithOrigin",
+ comment: { comment: EXACT_ORIGIN_MATCH_COMMENT },
+ },
+ {
+ value: "tempuser1",
+ label: "tempuser1",
+ style: "loginWithOrigin",
+ comment: { comment: EXACT_ORIGIN_MATCH_COMMENT },
+ },
+ {
+ value: "testuser2",
+ label: "testuser2",
+ style: "loginWithOrigin",
+ comment: { comment: EXACT_ORIGIN_MATCH_COMMENT },
+ },
+ {
+ value: "zzzuser4",
+ label: "zzzuser4",
+ style: "loginWithOrigin",
+ comment: { comment: EXACT_ORIGIN_MATCH_COMMENT },
+ },
+ {
+ value: "httpuser",
+ label: "httpuser",
+ style: "loginWithOrigin",
+ comment: { comment: "mochi.test:8888 (My HTTP auth realm)" },
+ },
+ {
+ value: "testuser3",
+ label: "testuser3",
+ style: "loginWithOrigin",
+ comment: { comment: "sub.mochi.test:8888" },
+ },
+ {
+ value: "",
+ label: "View Saved Logins",
+ style: "loginsFooter",
+ comment: { formHostname: "mochi.test" },
+ },
+ ],
+ },
+ {
+ insecureFieldWarningEnabled: true,
+ isSecure: false,
+ hasBeenTypePassword: false,
+ matchingLogins,
+ items: [
+ {
+ value: "",
+ label:
+ "This connection is not secure. Logins entered here could be compromised. Learn More",
+ style: "insecureWarning",
+ comment: "",
+ },
+ {
+ value: "",
+ label: LABEL_NO_USERNAME,
+ style: "loginWithOrigin",
+ comment: { comment: EXACT_ORIGIN_MATCH_COMMENT },
+ },
+ {
+ value: "tempuser1",
+ label: "tempuser1",
+ style: "loginWithOrigin",
+ comment: { comment: EXACT_ORIGIN_MATCH_COMMENT },
+ },
+ {
+ value: "testuser2",
+ label: "testuser2",
+ style: "loginWithOrigin",
+ comment: { comment: EXACT_ORIGIN_MATCH_COMMENT },
+ },
+ {
+ value: "zzzuser4",
+ label: "zzzuser4",
+ style: "loginWithOrigin",
+ comment: { comment: EXACT_ORIGIN_MATCH_COMMENT },
+ },
+ {
+ value: "httpuser",
+ label: "httpuser",
+ style: "loginWithOrigin",
+ comment: { comment: "mochi.test:8888 (My HTTP auth realm)" },
+ },
+ {
+ value: "testuser3",
+ label: "testuser3",
+ style: "loginWithOrigin",
+ comment: { comment: "sub.mochi.test:8888" },
+ },
+ {
+ value: "",
+ label: "View Saved Logins",
+ style: "loginsFooter",
+ comment: { formHostname: "mochi.test" },
+ },
+ ],
+ },
+ {
+ insecureFieldWarningEnabled: true,
+ isSecure: true,
+ hasBeenTypePassword: true,
+ matchingLogins,
+ items: [
+ {
+ value: "emptypass1",
+ label: LABEL_NO_USERNAME,
+ style: "loginWithOrigin",
+ comment: { comment: EXACT_ORIGIN_MATCH_COMMENT },
+ },
+ {
+ value: "temppass1",
+ label: "tempuser1",
+ style: "loginWithOrigin",
+ comment: { comment: EXACT_ORIGIN_MATCH_COMMENT },
+ },
+ {
+ value: "testpass2",
+ label: "testuser2",
+ style: "loginWithOrigin",
+ comment: { comment: EXACT_ORIGIN_MATCH_COMMENT },
+ },
+ {
+ value: "zzzpass4",
+ label: "zzzuser4",
+ style: "loginWithOrigin",
+ comment: { comment: EXACT_ORIGIN_MATCH_COMMENT },
+ },
+ {
+ value: "httppass",
+ label: "httpuser",
+ style: "loginWithOrigin",
+ comment: { comment: "mochi.test:8888 (My HTTP auth realm)" },
+ },
+ {
+ value: "testpass3",
+ label: "testuser3",
+ style: "loginWithOrigin",
+ comment: { comment: "sub.mochi.test:8888" },
+ },
+ {
+ value: "",
+ label: "View Saved Logins",
+ style: "loginsFooter",
+ comment: { formHostname: "mochi.test" },
+ },
+ ],
+ },
+ {
+ insecureFieldWarningEnabled: true,
+ isSecure: false,
+ hasBeenTypePassword: true,
+ matchingLogins,
+ items: [
+ {
+ value: "",
+ label:
+ "This connection is not secure. Logins entered here could be compromised. Learn More",
+ style: "insecureWarning",
+ comment: "",
+ },
+ {
+ value: "emptypass1",
+ label: LABEL_NO_USERNAME,
+ style: "loginWithOrigin",
+ comment: { comment: EXACT_ORIGIN_MATCH_COMMENT },
+ },
+ {
+ value: "temppass1",
+ label: "tempuser1",
+ style: "loginWithOrigin",
+ comment: { comment: EXACT_ORIGIN_MATCH_COMMENT },
+ },
+ {
+ value: "testpass2",
+ label: "testuser2",
+ style: "loginWithOrigin",
+ comment: { comment: EXACT_ORIGIN_MATCH_COMMENT },
+ },
+ {
+ value: "zzzpass4",
+ label: "zzzuser4",
+ style: "loginWithOrigin",
+ comment: { comment: EXACT_ORIGIN_MATCH_COMMENT },
+ },
+ {
+ value: "httppass",
+ label: "httpuser",
+ style: "loginWithOrigin",
+ comment: { comment: "mochi.test:8888 (My HTTP auth realm)" },
+ },
+ {
+ value: "testpass3",
+ label: "testuser3",
+ style: "loginWithOrigin",
+ comment: { comment: "sub.mochi.test:8888" },
+ },
+ {
+ value: "",
+ label: "View Saved Logins",
+ style: "loginsFooter",
+ comment: { formHostname: "mochi.test" },
+ },
+ ],
+ },
+ {
+ insecureFieldWarningEnabled: false,
+ isSecure: true,
+ hasBeenTypePassword: false,
+ matchingLogins,
+ items: [
+ {
+ value: "",
+ label: LABEL_NO_USERNAME,
+ style: "loginWithOrigin",
+ comment: { comment: EXACT_ORIGIN_MATCH_COMMENT },
+ },
+ {
+ value: "tempuser1",
+ label: "tempuser1",
+ style: "loginWithOrigin",
+ comment: { comment: EXACT_ORIGIN_MATCH_COMMENT },
+ },
+ {
+ value: "testuser2",
+ label: "testuser2",
+ style: "loginWithOrigin",
+ comment: { comment: EXACT_ORIGIN_MATCH_COMMENT },
+ },
+ {
+ value: "zzzuser4",
+ label: "zzzuser4",
+ style: "loginWithOrigin",
+ comment: { comment: EXACT_ORIGIN_MATCH_COMMENT },
+ },
+ {
+ value: "httpuser",
+ label: "httpuser",
+ style: "loginWithOrigin",
+ comment: { comment: "mochi.test:8888 (My HTTP auth realm)" },
+ },
+ {
+ value: "testuser3",
+ label: "testuser3",
+ style: "loginWithOrigin",
+ comment: { comment: "sub.mochi.test:8888" },
+ },
+ {
+ value: "",
+ label: "View Saved Logins",
+ style: "loginsFooter",
+ comment: { formHostname: "mochi.test" },
+ },
+ ],
+ },
+ {
+ insecureFieldWarningEnabled: false,
+ isSecure: false,
+ hasBeenTypePassword: false,
+ matchingLogins: [],
+ items: [
+ {
+ value: "",
+ label: "View Saved Logins",
+ style: "loginsFooter",
+ comment: { formHostname: "mochi.test" },
+ },
+ ],
+ },
+ {
+ insecureFieldWarningEnabled: false,
+ isSecure: false,
+ hasBeenTypePassword: false,
+ matchingLogins: [],
+ searchString: "foo",
+ items: [
+ {
+ value: "",
+ label: "View Saved Logins",
+ style: "loginsFooter",
+ comment: { formHostname: "mochi.test" },
+ },
+ ],
+ },
+ {
+ insecureFieldWarningEnabled: false,
+ isSecure: false,
+ hasBeenTypePassword: false,
+ matchingLogins,
+ items: [
+ {
+ value: "",
+ label: LABEL_NO_USERNAME,
+ style: "loginWithOrigin",
+ comment: { comment: EXACT_ORIGIN_MATCH_COMMENT },
+ },
+ {
+ value: "tempuser1",
+ label: "tempuser1",
+ style: "loginWithOrigin",
+ comment: { comment: EXACT_ORIGIN_MATCH_COMMENT },
+ },
+ {
+ value: "testuser2",
+ label: "testuser2",
+ style: "loginWithOrigin",
+ comment: { comment: EXACT_ORIGIN_MATCH_COMMENT },
+ },
+ {
+ value: "zzzuser4",
+ label: "zzzuser4",
+ style: "loginWithOrigin",
+ comment: { comment: EXACT_ORIGIN_MATCH_COMMENT },
+ },
+ {
+ value: "httpuser",
+ label: "httpuser",
+ style: "loginWithOrigin",
+ comment: { comment: "mochi.test:8888 (My HTTP auth realm)" },
+ },
+ {
+ value: "testuser3",
+ label: "testuser3",
+ style: "loginWithOrigin",
+ comment: { comment: "sub.mochi.test:8888" },
+ },
+ {
+ value: "",
+ label: "View Saved Logins",
+ style: "loginsFooter",
+ comment: { formHostname: "mochi.test" },
+ },
+ ],
+ },
+ {
+ insecureFieldWarningEnabled: false,
+ isSecure: true,
+ hasBeenTypePassword: true,
+ matchingLogins,
+ items: [
+ {
+ value: "emptypass1",
+ label: LABEL_NO_USERNAME,
+ style: "loginWithOrigin",
+ comment: { comment: EXACT_ORIGIN_MATCH_COMMENT },
+ },
+ {
+ value: "temppass1",
+ label: "tempuser1",
+ style: "loginWithOrigin",
+ comment: { comment: EXACT_ORIGIN_MATCH_COMMENT },
+ },
+ {
+ value: "testpass2",
+ label: "testuser2",
+ style: "loginWithOrigin",
+ comment: { comment: EXACT_ORIGIN_MATCH_COMMENT },
+ },
+ {
+ value: "zzzpass4",
+ label: "zzzuser4",
+ style: "loginWithOrigin",
+ comment: { comment: EXACT_ORIGIN_MATCH_COMMENT },
+ },
+ {
+ value: "httppass",
+ label: "httpuser",
+ style: "loginWithOrigin",
+ comment: { comment: "mochi.test:8888 (My HTTP auth realm)" },
+ },
+ {
+ value: "testpass3",
+ label: "testuser3",
+ style: "loginWithOrigin",
+ comment: { comment: "sub.mochi.test:8888" },
+ },
+ {
+ value: "",
+ label: "View Saved Logins",
+ style: "loginsFooter",
+ comment: { formHostname: "mochi.test" },
+ },
+ ],
+ },
+ {
+ insecureFieldWarningEnabled: false,
+ isSecure: false,
+ hasBeenTypePassword: true,
+ matchingLogins,
+ items: [
+ {
+ value: "emptypass1",
+ label: LABEL_NO_USERNAME,
+ style: "loginWithOrigin",
+ comment: { comment: EXACT_ORIGIN_MATCH_COMMENT },
+ },
+ {
+ value: "temppass1",
+ label: "tempuser1",
+ style: "loginWithOrigin",
+ comment: { comment: EXACT_ORIGIN_MATCH_COMMENT },
+ },
+ {
+ value: "testpass2",
+ label: "testuser2",
+ style: "loginWithOrigin",
+ comment: { comment: EXACT_ORIGIN_MATCH_COMMENT },
+ },
+ {
+ value: "zzzpass4",
+ label: "zzzuser4",
+ style: "loginWithOrigin",
+ comment: { comment: EXACT_ORIGIN_MATCH_COMMENT },
+ },
+ {
+ value: "httppass",
+ label: "httpuser",
+ style: "loginWithOrigin",
+ comment: { comment: "mochi.test:8888 (My HTTP auth realm)" },
+ },
+ {
+ value: "testpass3",
+ label: "testuser3",
+ style: "loginWithOrigin",
+ comment: { comment: "sub.mochi.test:8888" },
+ },
+ {
+ value: "",
+ label: "View Saved Logins",
+ style: "loginsFooter",
+ comment: { formHostname: "mochi.test" },
+ },
+ ],
+ },
+ {
+ insecureFieldWarningEnabled: true,
+ isSecure: true,
+ hasBeenTypePassword: true,
+ matchingLogins: [],
+ items: [
+ {
+ value: "",
+ label: "View Saved Logins",
+ style: "loginsFooter",
+ comment: { formHostname: "mochi.test" },
+ },
+ ],
+ },
+ {
+ insecureFieldWarningEnabled: true,
+ isSecure: true,
+ hasBeenTypePassword: true,
+ matchingLogins: [],
+ searchString: "foo",
+ items: [],
+ },
+ {
+ generatedPassword: "9ljgfd4shyktb45",
+ insecureFieldWarningEnabled: true,
+ isSecure: true,
+ hasBeenTypePassword: true,
+ matchingLogins: [],
+ items: [
+ {
+ value: "9ljgfd4shyktb45",
+ label: "Use a Securely Generated Password",
+ style: "generatedPassword",
+ comment: {
+ generatedPassword: "9ljgfd4shyktb45",
+ willAutoSaveGeneratedPassword: false,
+ },
+ },
+ {
+ value: "",
+ label: "View Saved Logins",
+ style: "loginsFooter",
+ comment: { formHostname: "mochi.test" },
+ },
+ ],
+ },
+ {
+ description:
+ "willAutoSaveGeneratedPassword should propagate to the comment",
+ generatedPassword: "9ljgfd4shyktb45",
+ willAutoSaveGeneratedPassword: true,
+ insecureFieldWarningEnabled: true,
+ isSecure: true,
+ hasBeenTypePassword: true,
+ matchingLogins: [],
+ items: [
+ {
+ value: "9ljgfd4shyktb45",
+ label: "Use a Securely Generated Password",
+ style: "generatedPassword",
+ comment: {
+ generatedPassword: "9ljgfd4shyktb45",
+ willAutoSaveGeneratedPassword: true,
+ },
+ },
+ {
+ value: "",
+ label: "View Saved Logins",
+ style: "loginsFooter",
+ comment: { formHostname: "mochi.test" },
+ },
+ ],
+ },
+ {
+ description:
+ "If a generated password is passed then show it even if there is a search string. This handles when forcing the generation option from the context menu of a non-empty field",
+ generatedPassword: "9ljgfd4shyktb45",
+ insecureFieldWarningEnabled: true,
+ isSecure: true,
+ hasBeenTypePassword: true,
+ matchingLogins: [],
+ searchString: "9ljgfd4shyktb45",
+ items: [
+ {
+ value: "9ljgfd4shyktb45",
+ label: "Use a Securely Generated Password",
+ style: "generatedPassword",
+ comment: {
+ generatedPassword: "9ljgfd4shyktb45",
+ willAutoSaveGeneratedPassword: false,
+ },
+ },
+ {
+ value: "",
+ label: "View Saved Logins",
+ style: "loginsFooter",
+ comment: { formHostname: "mochi.test" },
+ },
+ ],
+ },
+ {
+ description: "secure username field on sub.mochi.test",
+ formOrigin: "https://sub.mochi.test:8888",
+ insecureFieldWarningEnabled: true,
+ isSecure: true,
+ hasBeenTypePassword: false,
+ matchingLogins,
+ items: [
+ {
+ value: "testuser3",
+ label: "testuser3",
+ style: "loginWithOrigin",
+ comment: { comment: EXACT_ORIGIN_MATCH_COMMENT },
+ },
+ {
+ value: "",
+ label: LABEL_NO_USERNAME,
+ style: "loginWithOrigin",
+ comment: { comment: "mochi.test:8888" },
+ },
+ {
+ value: "tempuser1",
+ label: "tempuser1",
+ style: "loginWithOrigin",
+ comment: { comment: "mochi.test:8888" },
+ },
+ {
+ value: "testuser2",
+ label: "testuser2",
+ style: "loginWithOrigin",
+ comment: { comment: "mochi.test:8888" },
+ },
+ {
+ value: "zzzuser4",
+ label: "zzzuser4",
+ style: "loginWithOrigin",
+ comment: { comment: "mochi.test:8888" },
+ },
+ {
+ value: "httpuser",
+ label: "httpuser",
+ style: "loginWithOrigin",
+ comment: { comment: "mochi.test:8888 (My HTTP auth realm)" },
+ },
+ {
+ value: "",
+ label: "View Saved Logins",
+ style: "loginsFooter",
+ comment: { formHostname: "mochi.test" },
+ },
+ ],
+ },
+ {
+ description: "secure password field on sub.mochi.test",
+ formOrigin: "https://sub.mochi.test:8888",
+ insecureFieldWarningEnabled: true,
+ isSecure: true,
+ hasBeenTypePassword: true,
+ matchingLogins,
+ items: [
+ {
+ value: "testpass3",
+ label: "testuser3",
+ style: "loginWithOrigin",
+ comment: { comment: EXACT_ORIGIN_MATCH_COMMENT },
+ },
+ {
+ value: "emptypass1",
+ label: LABEL_NO_USERNAME,
+ style: "loginWithOrigin",
+ comment: { comment: "mochi.test:8888" },
+ },
+ {
+ value: "temppass1",
+ label: "tempuser1",
+ style: "loginWithOrigin",
+ comment: { comment: "mochi.test:8888" },
+ },
+ {
+ value: "testpass2",
+ label: "testuser2",
+ style: "loginWithOrigin",
+ comment: { comment: "mochi.test:8888" },
+ },
+ {
+ value: "zzzpass4",
+ label: "zzzuser4",
+ style: "loginWithOrigin",
+ comment: { comment: "mochi.test:8888" },
+ },
+ {
+ value: "httppass",
+ label: "httpuser",
+ style: "loginWithOrigin",
+ comment: { comment: "mochi.test:8888 (My HTTP auth realm)" },
+ },
+ {
+ value: "",
+ label: "View Saved Logins",
+ style: "loginsFooter",
+ comment: { formHostname: "mochi.test" },
+ },
+ ],
+ },
+ {
+ description: "schemeUpgrades: false",
+ formOrigin: "https://mochi.test:8888",
+ schemeUpgrades: false,
+ isSecure: true,
+ hasBeenTypePassword: false,
+ matchingLogins,
+ items: [
+ {
+ value: "",
+ label: LABEL_NO_USERNAME,
+ style: "loginWithOrigin",
+ comment: { comment: EXACT_ORIGIN_MATCH_COMMENT },
+ },
+ {
+ value: "tempuser1",
+ label: "tempuser1",
+ style: "loginWithOrigin",
+ comment: { comment: EXACT_ORIGIN_MATCH_COMMENT },
+ },
+ {
+ value: "testuser2",
+ label: "testuser2",
+ style: "loginWithOrigin",
+ comment: { comment: EXACT_ORIGIN_MATCH_COMMENT },
+ },
+ {
+ value: "zzzuser4",
+ label: "zzzuser4",
+ style: "loginWithOrigin",
+ comment: { comment: "mochi.test:8888" },
+ },
+ {
+ value: "httpuser",
+ label: "httpuser",
+ style: "loginWithOrigin",
+ comment: { comment: "mochi.test:8888 (My HTTP auth realm)" },
+ },
+ {
+ value: "testuser3",
+ label: "testuser3",
+ style: "loginWithOrigin",
+ comment: { comment: "sub.mochi.test:8888" },
+ },
+ {
+ value: "",
+ label: "View Saved Logins",
+ style: "loginsFooter",
+ comment: { formHostname: "mochi.test" },
+ },
+ ],
+ },
+ ];
+
+ LoginHelper.createLogger("LoginAutoCompleteResult");
+ Services.prefs.setBoolPref("signon.showAutoCompleteFooter", true);
+ Services.prefs.setBoolPref("signon.showAutoCompleteOrigins", true);
+
+ expectedResults.forEach((pattern, testIndex) => {
+ info(`expectedResults[${testIndex}]`);
+ info(JSON.stringify(pattern, null, 2));
+ Services.prefs.setBoolPref(
+ PREF_INSECURE_FIELD_WARNING_ENABLED,
+ pattern.insecureFieldWarningEnabled
+ );
+ Services.prefs.setBoolPref(
+ PREF_SCHEME_UPGRADES,
+ "schemeUpgrades" in pattern ? pattern.schemeUpgrades : true
+ );
+ let actual = new LoginAutoCompleteResult(
+ pattern.searchString || "",
+ pattern.matchingLogins,
+ pattern.formOrigin || "https://mochi.test:8888",
+ {
+ hostname: "mochi.test",
+ generatedPassword: pattern.generatedPassword,
+ willAutoSaveGeneratedPassword: !!pattern.willAutoSaveGeneratedPassword,
+ isSecure: pattern.isSecure,
+ hasBeenTypePassword: pattern.hasBeenTypePassword,
+ telemetryEventData: { searchStartTimeMS: testIndex },
+ }
+ );
+ equal(
+ actual.matchCount,
+ pattern.items.length,
+ `${testIndex}: Check matching row count`
+ );
+ pattern.items.forEach((item, index) => {
+ equal(
+ actual.getValueAt(index),
+ item.value,
+ `${testIndex}: Value ${index}`
+ );
+ equal(
+ actual.getLabelAt(index),
+ item.label,
+ `${testIndex}: Label ${index}`
+ );
+ equal(
+ actual.getStyleAt(index),
+ item.style,
+ `${testIndex}: Style ${index}`
+ );
+ let actualComment = actual.getCommentAt(index);
+ if (typeof item.comment == "object") {
+ let parsedComment = JSON.parse(actualComment);
+ for (let [key, val] of Object.entries(item.comment)) {
+ equal(
+ parsedComment[key],
+ val,
+ `${testIndex}: Comment.${key} ${index}`
+ );
+ }
+ } else {
+ equal(actualComment, item.comment, `${testIndex}: Comment ${index}`);
+ }
+ });
+
+ if (pattern.items.length) {
+ 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),
+ /Index out of range\./
+ );
+ }
+ });
+});
diff --git a/toolkit/components/passwordmgr/test/unit/test_loginsBackup.js b/toolkit/components/passwordmgr/test/unit/test_loginsBackup.js
new file mode 100644
index 0000000000..cff660afb2
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_loginsBackup.js
@@ -0,0 +1,215 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Tests if logins-backup.json is used correctly in the event that logins.json is missing or corrupt.
+ */
+
+"use strict";
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "LoginStore",
+ "resource://gre/modules/LoginStore.jsm"
+);
+const { TestUtils } = ChromeUtils.import(
+ "resource://testing-common/TestUtils.jsm"
+);
+const { TelemetryTestUtils } = ChromeUtils.import(
+ "resource://testing-common/TelemetryTestUtils.jsm"
+);
+
+const rawLogin1 = {
+ id: 1,
+ hostname: "http://www.example.com",
+ httpRealm: null,
+ formSubmitURL: "http://www.example.com",
+ 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,
+};
+
+const rawLogin2 = {
+ id: 2,
+ hostname: "http://www.example2.com",
+ httpRealm: null,
+ formSubmitURL: "http://www.example2.com",
+ usernameField: "field_2" + String.fromCharCode(533, 537, 7570, 345),
+ passwordField: "field_2" + String.fromCharCode(421, 259, 349, 537),
+ encryptedUsername: "(test2)",
+ encryptedPassword: "(test2)",
+ guid: "(test2)",
+ encType: Ci.nsILoginManagerCrypto.ENCTYPE_SDR,
+ timeCreated: Date.now(),
+ timeLastUsed: Date.now(),
+ timePasswordChanged: Date.now(),
+ timesUsed: 1,
+};
+
+// Enable the collection (during test) for all products so even products
+// that don't collect the data will be able to run the test without failure.
+Services.prefs.setBoolPref(
+ "toolkit.telemetry.testing.overrideProductsCheck",
+ true
+);
+
+/**
+ * Tests that logins-backup.json can be used by JSONFile.load() when logins.json is missing or cannot be read.
+ */
+add_task(async function test_logins_store_missing_or_corrupt_with_backup() {
+ let loginsStorePath = OS.Path.join(
+ OS.Constants.Path.profileDir,
+ "logins.json"
+ );
+ let loginsStoreBackup = OS.Path.join(
+ OS.Constants.Path.profileDir,
+ "logins-backup.json"
+ );
+
+ // Get store.data ready.
+ let store = new LoginStore(loginsStorePath, loginsStoreBackup);
+ await store.load();
+
+ // Files should not exist at start up.
+ Assert.equal(false, await OS.File.exists(store.path));
+ Assert.equal(false, await OS.File.exists(store._options.backupTo));
+
+ // Add logins to create logins.json and logins-backup.json.
+ store.data.logins.push(rawLogin1);
+ await store._save();
+ Assert.equal(true, await OS.File.exists(store.path));
+
+ store.data.logins.push(rawLogin2);
+ await store._save();
+ Assert.equal(true, await OS.File.exists(store._options.backupTo));
+
+ // Remove logins.json and see if logins-backup.json will be used.
+ await OS.File.remove(store.path);
+ store.data.logins = [];
+ store.dataReady = false;
+ Assert.equal(false, await OS.File.exists(store.path));
+ Assert.equal(true, await OS.File.exists(store._options.backupTo));
+
+ // Clear any telemetry events recorded in the jsonfile category previously.
+ Services.telemetry.clearEvents();
+
+ await store.load();
+
+ // Important to check here if logins.json is restored as expected
+ // after it went missing.
+ await OS.File.exists(store.path);
+
+ Assert.equal(true, await OS.File.exists(store._options.backupTo));
+ Assert.equal(
+ store.data.logins.length,
+ 1,
+ "Logins backup was used successfully when logins.json was missing"
+ );
+
+ TelemetryTestUtils.assertEvents(
+ [
+ ["jsonfile", "load", "logins"],
+ ["jsonfile", "load", "logins", "used_backup"],
+ ],
+ {},
+ { clear: true }
+ );
+ info(
+ "Telemetry was recorded accurately when logins-backup.json is used when logins.json was missing"
+ );
+
+ // Corrupt the logins.json file.
+ let string = '{"logins":[{"hostname":"http://www.example.com","id":1,';
+ await OS.File.writeAtomic(store.path, new TextEncoder().encode(string), {
+ tmpPath: store.path + ".tmp",
+ });
+
+ // Clear events recorded in the jsonfile category previously.
+ Services.telemetry.clearEvents();
+
+ // Try to load the corrupt file.
+ store.data.logins = [];
+ store.dataReady = false;
+ await store.load();
+
+ // logins.json.corrupt should be created.
+ await OS.File.exists(store.path + ".corrupt");
+
+ // Important to check here if logins.json is restored as expected
+ // after it was corrupted.
+ await OS.File.exists(store.path);
+
+ // Data should be loaded from logins-backup.json.
+ Assert.equal(true, await OS.File.exists(store._options.backupTo));
+ Assert.equal(
+ store.data.logins.length,
+ 1,
+ "Logins backup was used successfully when logins.json was corrupt"
+ );
+
+ TelemetryTestUtils.assertEvents(
+ [
+ ["jsonfile", "load", "logins", ""],
+ ["jsonfile", "load", "logins", "invalid_json"],
+ ["jsonfile", "load", "logins", "used_backup"],
+ ],
+ {},
+ { clear: true }
+ );
+ info(
+ "Telemetry was recorded accurately when logins-backup.json is used when logins.json was corrupt"
+ );
+
+ // Clean up before we start the second part of the test.
+ await OS.File.remove(store.path + ".corrupt");
+
+ // Test that the backup file can be used by JSONFile.ensureDataReady() correctly when logins.json is missing.
+ // Remove logins.json
+ await OS.File.remove(store.path);
+ store.data.logins = [];
+ store.dataReady = false;
+ Assert.equal(false, await OS.File.exists(store.path));
+ Assert.equal(true, await OS.File.exists(store._options.backupTo));
+
+ store.ensureDataReady();
+
+ // Important to check here if logins.json is restored as expected
+ // after it went missing.
+ await OS.File.exists(store.path);
+
+ Assert.equal(true, await OS.File.exists(store._options.backupTo));
+ await TestUtils.waitForCondition(() => {
+ return store.data.logins.length == 1;
+ });
+
+ // Test that the backup file is used by JSONFile.ensureDataReady() when logins.json is corrupt.
+ // Corrupt the logins.json file.
+ await OS.File.writeAtomic(store.path, new TextEncoder().encode(string), {
+ tmpPath: store.path + ".tmp",
+ });
+
+ // Try to load the corrupt file.
+ store.data.logins = [];
+ store.dataReady = false;
+ store.ensureDataReady();
+
+ // logins.json.corrupt should be created.
+ Assert.equal(true, await OS.File.exists(store.path + ".corrupt"));
+
+ // Important to check here if logins.json is restored as expected
+ // after it was corrupted.
+ await OS.File.exists(store.path);
+
+ // Data should be loaded from logins-backup.json.
+ Assert.equal(true, await OS.File.exists(store._options.backupTo));
+ await TestUtils.waitForCondition(() => {
+ return store.data.logins.length == 1;
+ });
+});
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 0000000000..642faaa1b5
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_logins_change.js
@@ -0,0 +1,548 @@
+/* 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
+
+const MAX_DATE_MS = 8640000000000000;
+
+/**
+ * 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({ origin: "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({
+ origin: aLoginInfo.origin,
+ formActionOrigin: aLoginInfo.formActionOrigin,
+ 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 formActionOrigin.
+ *
+ * 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 formActionOrigin and a null value in
+ * httpRealm is also supported for storage at the moment.
+ */
+add_task(function test_invalid_httpRealm_formActionOrigin() {
+ // httpRealm === null, formActionOrigin === null
+ checkLoginInvalid(
+ TestData.formLogin({ formActionOrigin: null }),
+ /without a httpRealm or formActionOrigin/
+ );
+
+ // httpRealm === "", formActionOrigin === null
+ checkLoginInvalid(
+ TestData.authLogin({ httpRealm: "" }),
+ /without a httpRealm or formActionOrigin/
+ );
+
+ // httpRealm === null, formActionOrigin === ""
+ // TODO: This is not enforced for now.
+ // checkLoginInvalid(TestData.formLogin({ formActionOrigin: "" }),
+ // /without a httpRealm or formActionOrigin/);
+
+ // httpRealm === "", formActionOrigin === ""
+ let login = TestData.formLogin({ formActionOrigin: "" });
+ login.httpRealm = "";
+ checkLoginInvalid(login, /both a httpRealm and formActionOrigin/);
+
+ // !!httpRealm, !!formActionOrigin
+ login = TestData.formLogin();
+ login.httpRealm = "The HTTP Realm";
+ checkLoginInvalid(login, /both a httpRealm and formActionOrigin/);
+
+ // httpRealm === "", !!formActionOrigin
+ login = TestData.formLogin();
+ login.httpRealm = "";
+ checkLoginInvalid(login, /both a httpRealm and formActionOrigin/);
+
+ // !!httpRealm, formActionOrigin === ""
+ login = TestData.authLogin();
+ login.formActionOrigin = "";
+ checkLoginInvalid(login, /both a httpRealm and formActionOrigin/);
+});
+
+/**
+ * Tests null or empty values in required login properties.
+ */
+add_task(function test_missing_properties() {
+ checkLoginInvalid(
+ TestData.formLogin({ origin: null }),
+ /null or empty origin/
+ );
+
+ checkLoginInvalid(TestData.formLogin({ origin: "" }), /null or empty origin/);
+
+ 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({ origin: "http://null\0X.example.com" }),
+ TestData.authLogin({ httpRealm: "realm\0" }),
+ TestData.formLogin({ formActionOrigin: "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_removeAllUserFacingLogins() {
+ for (let loginInfo of TestData.loginList()) {
+ Services.logins.addLogin(loginInfo);
+ }
+ Services.logins.removeAllUserFacingLogins();
+ LoginTestUtils.checkLogins([]);
+
+ // The function should also work when there are no logins to delete.
+ Services.logins.removeAllUserFacingLogins();
+});
+
+/**
+ * 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 origin.
+ Services.logins.modifyLogin(updatedLoginInfo, differentLoginInfo);
+ LoginTestUtils.checkLogins([differentLoginInfo]);
+
+ // It is now possible to add a login with the old type and origin.
+ 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({
+ origin: differentLoginInfo.origin,
+ formActionOrigin: differentLoginInfo.formActionOrigin,
+ 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,
+ })
+ ),
+ /No matching logins/
+ );
+
+ // The login can be changed to have a different type and origin.
+ Services.logins.modifyLogin(updatedLoginInfo, differentLoginProperties);
+ LoginTestUtils.checkLogins([differentLoginInfo]);
+
+ // It is now possible to add a login with the old type and origin.
+ 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: 17,
+ },
+ {
+ keyset: ["origin", "username"],
+ results: 21,
+ },
+ {
+ keyset: ["origin", "username", "password"],
+ results: 22,
+ },
+ {
+ keyset: ["origin", "username", "password", "formActionOrigin"],
+ results: 27,
+ },
+ ];
+
+ 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({
+ formActionOrigin: "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."
+ );
+});
+
+/**
+ * Tests handling when adding a login with bad date values
+ */
+add_task(function test_addLogin_badDates() {
+ LoginTestUtils.clearData();
+
+ let now = Date.now();
+ let defaultLoginDates = {
+ timeCreated: now,
+ timeLastUsed: now,
+ timePasswordChanged: now,
+ };
+
+ let defaultsLogin = TestData.formLogin();
+ for (let pname of ["timeCreated", "timeLastUsed", "timePasswordChanged"]) {
+ Assert.ok(!defaultsLogin[pname]);
+ }
+ Assert.ok(
+ !!Services.logins.addLogin(defaultsLogin),
+ "Sanity check adding defaults formLogin"
+ );
+ Services.logins.removeAllUserFacingLogins();
+
+ // 0 is a valid date in this context - new nsLoginInfo timestamps init to 0
+ for (let pname of ["timeCreated", "timeLastUsed", "timePasswordChanged"]) {
+ let loginInfo = TestData.formLogin(
+ Object.assign({}, defaultLoginDates, {
+ [pname]: 0,
+ })
+ );
+ Assert.ok(
+ !!Services.logins.addLogin(loginInfo),
+ "Check 0 value for " + pname
+ );
+ Services.logins.removeAllUserFacingLogins();
+ }
+
+ // negative dates get clamped to 0 and are ok
+ for (let pname of ["timeCreated", "timeLastUsed", "timePasswordChanged"]) {
+ let loginInfo = TestData.formLogin(
+ Object.assign({}, defaultLoginDates, {
+ [pname]: -1,
+ })
+ );
+ Assert.ok(
+ !!Services.logins.addLogin(loginInfo),
+ "Check -1 value for " + pname
+ );
+ Services.logins.removeAllUserFacingLogins();
+ }
+
+ // out-of-range dates will throw
+ for (let pname of ["timeCreated", "timeLastUsed", "timePasswordChanged"]) {
+ let loginInfo = TestData.formLogin(
+ Object.assign({}, defaultLoginDates, {
+ [pname]: MAX_DATE_MS + 1,
+ })
+ );
+ Assert.throws(
+ () => Services.logins.addLogin(loginInfo),
+ /invalid date properties/
+ );
+ Assert.equal(Services.logins.getAllLogins().length, 0);
+ }
+
+ LoginTestUtils.checkLogins([]);
+});
+
+/**
+ * Tests handling when adding multiple logins with bad date values
+ */
+add_task(async function test_addLogins_badDates() {
+ LoginTestUtils.clearData();
+
+ let defaultsLogin = TestData.formLogin({
+ username: "defaults",
+ });
+
+ // -11644473600000 is the value you get if you convert Dec 31 1600 16:07:02 to unix epoch time
+ let timeCreatedLogin = TestData.formLogin({
+ username: "tc",
+ timeCreated: -11644473600000,
+ });
+
+ let timeLastUsedLogin = TestData.formLogin({
+ username: "tlu",
+ timeLastUsed: -11644473600000,
+ });
+
+ let timePasswordChangedLogin = TestData.formLogin({
+ username: "tpc",
+ timePasswordChanged: -11644473600000,
+ });
+
+ await Services.logins.addLogins([
+ defaultsLogin,
+ timeCreatedLogin,
+ timeLastUsedLogin,
+ timePasswordChangedLogin,
+ ]);
+
+ // none of the logins with invalid dates should have been added
+ let savedLogins = Services.logins.getAllLogins();
+ Assert.equal(savedLogins.length, 1);
+
+ Services.logins.removeAllUserFacingLogins();
+});
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 0000000000..2d6ec432cd
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_logins_decrypt_failure.js
@@ -0,0 +1,176 @@
+/* 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.initPassword("");
+}
+
+// Tests
+
+/**
+ * Resets the master password after some logins were added to the database.
+ */
+add_task(async 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.
+ Assert.equal(Services.logins.getAllLogins().length, 0);
+ Assert.equal(
+ (await Services.logins.getAllLoginsAsync()).length,
+ 0,
+ "getAllLoginsAsync length"
+ );
+ Assert.equal(Services.logins.findLogins("", "", "").length, 0);
+ Assert.equal(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.
+ Assert.equal(Services.logins.countLogins("", "", ""), logins.length);
+
+ // Equivalent logins can be added.
+ for (let loginInfo of logins) {
+ Services.logins.addLogin(loginInfo);
+ }
+ LoginTestUtils.checkLogins(logins);
+ Assert.equal(
+ (await Services.logins.getAllLoginsAsync()).length,
+ logins.length,
+ "getAllLoginsAsync length"
+ );
+ Assert.equal(Services.logins.countLogins("", "", ""), logins.length * 2);
+
+ // Finding logins doesn't return the non-decryptable duplicates.
+ Assert.equal(
+ Services.logins.findLogins("http://www.example.com", "", "").length,
+ 1
+ );
+ let matchData = newPropertyBag({ origin: "http://www.example.com" });
+ Assert.equal(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);
+ }
+ Assert.equal(Services.logins.getAllLogins().length, 0);
+ Assert.equal(Services.logins.countLogins("", "", ""), logins.length);
+
+ // Removing all logins removes the non-decryptable entries also.
+ Services.logins.removeAllUserFacingLogins();
+ Assert.equal(Services.logins.getAllLogins().length, 0);
+ Assert.equal(Services.logins.countLogins("", "", ""), 0);
+});
+
+// Bug 621846 - If a login has a GUID but can't be decrypted, a search for
+// that GUID will (correctly) fail. Ensure we can add a new login with that
+// same GUID.
+add_task(function test_add_logins_with_decrypt_failure() {
+ // a login with a GUID.
+ let login = new LoginInfo(
+ "http://www.example2.com",
+ "http://www.example2.com",
+ null,
+ "the username",
+ "the password for www.example.com",
+ "form_field_username",
+ "form_field_password"
+ );
+
+ login.QueryInterface(Ci.nsILoginMetaInfo);
+ login.guid = "{4bc50d2f-dbb6-4aa3-807c-c4c2065a2c35}";
+
+ // A different login but with the same GUID.
+ let loginDupeGuid = new LoginInfo(
+ "http://www.example3.com",
+ "http://www.example3.com",
+ null,
+ "the username",
+ "the password",
+ "form_field_username",
+ "form_field_password"
+ );
+ loginDupeGuid.QueryInterface(Ci.nsILoginMetaInfo);
+ loginDupeGuid.guid = login.guid;
+
+ Services.logins.addLogin(login);
+
+ // We can search for this login by GUID.
+ let searchProp = Cc["@mozilla.org/hash-property-bag;1"].createInstance(
+ Ci.nsIWritablePropertyBag2
+ );
+ searchProp.setPropertyAsAUTF8String("guid", login.guid);
+
+ equal(Services.logins.searchLogins(searchProp).length, 1);
+
+ // We should fail to re-add it as it remains good.
+ Assert.throws(
+ () => Services.logins.addLogin(login),
+ /This login already exists./
+ );
+ // We should fail to re-add a different login with the same GUID.
+ Assert.throws(
+ () => Services.logins.addLogin(loginDupeGuid),
+ /specified GUID already exists/
+ );
+
+ // This makes the existing login non-decryptable.
+ resetMasterPassword();
+
+ // We can no longer find it in our search.
+ equal(Services.logins.searchLogins(searchProp).length, 0);
+
+ // So we should be able to re-add a login with that same GUID.
+ Services.logins.addLogin(login);
+ equal(Services.logins.searchLogins(searchProp).length, 1);
+
+ Services.logins.removeAllUserFacingLogins();
+});
+
+// Test the "syncID" metadata works as expected on decryption failure.
+add_task(async function test_sync_metadata_with_decrypt_failure() {
+ // And some sync metadata
+ await Services.logins.setSyncID("sync-id");
+ await Services.logins.setLastSync(123);
+ equal(await Services.logins.getSyncID(), "sync-id");
+ equal(await Services.logins.getLastSync(), 123);
+
+ // This makes the existing login and syncID non-decryptable.
+ resetMasterPassword();
+
+ // The syncID is now null.
+ equal(await Services.logins.getSyncID(), null);
+ // The sync timestamp isn't impacted.
+ equal(await Services.logins.getLastSync(), 123);
+
+ // But we should be able to set it again.
+ await Services.logins.setSyncID("new-id");
+ equal(await Services.logins.getSyncID(), "new-id");
+});
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 0000000000..7aae2a5161
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_logins_metainfo.js
@@ -0,0 +1,302 @@
+/* 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"
+);
+
+const gLooksLikeUUIDRegex = /^\{\w{8}-\w{4}-\w{4}-\w{4}-\w{12}\}$/;
+
+/**
+ * Retrieves the only login among the current data that matches the origin of
+ * the given nsILoginInfo. In case there is more than one login for the
+ * origin, the test fails.
+ */
+function retrieveLoginMatching(aLoginInfo) {
+ let logins = Services.logins.findLogins(aLoginInfo.origin, "", "");
+ Assert.equal(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) {
+ Assert.notEqual(aActual, aExpected);
+
+ // Check the nsILoginInfo properties.
+ Assert.ok(aActual.equals(aExpected));
+
+ // Check the nsILoginMetaInfo properties.
+ Assert.equal(aActual.guid, aExpected.guid);
+ Assert.equal(aActual.timeCreated, aExpected.timeCreated);
+ Assert.equal(aActual.timeLastUsed, aExpected.timeLastUsed);
+ Assert.equal(aActual.timePasswordChanged, aExpected.timePasswordChanged);
+ Assert.equal(aActual.timesUsed, aExpected.timesUsed);
+}
+
+/**
+ * nsILoginInfo instances with or without nsILoginMetaInfo properties.
+ */
+let gLoginInfo1;
+let gLoginInfo2;
+let gLoginInfo3;
+
+/**
+ * nsILoginInfo instances reloaded with all the nsILoginMetaInfo properties.
+ * These are often used to provide the reference values to test against.
+ */
+let gLoginMetaInfo1;
+let gLoginMetaInfo2;
+let 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({
+ origin: "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.
+ Assert.equal(gLoginInfo1.guid, null);
+ Assert.equal(gLoginInfo1.timeCreated, 0);
+ Assert.equal(gLoginInfo1.timeLastUsed, 0);
+ Assert.equal(gLoginInfo1.timePasswordChanged, 0);
+ Assert.equal(gLoginInfo1.timesUsed, 0);
+
+ // A login with valid metadata should have been stored.
+ gLoginMetaInfo1 = retrieveLoginMatching(gLoginInfo1);
+ Assert.ok(gLooksLikeUUIDRegex.test(gLoginMetaInfo1.guid));
+ let creationTime = gLoginMetaInfo1.timeCreated;
+ LoginTestUtils.assertTimeIsAboutNow(creationTime);
+ Assert.equal(gLoginMetaInfo1.timeLastUsed, creationTime);
+ Assert.equal(gLoginMetaInfo1.timePasswordChanged, creationTime);
+ Assert.equal(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({
+ origin: "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);
+ Assert.equal(gLoginMetaInfo1.guid, newUUIDValue);
+ Assert.equal(gLoginMetaInfo1.timeCreated, newTimeMs);
+ Assert.equal(gLoginMetaInfo1.timeLastUsed, newTimeMs + 2);
+ Assert.equal(gLoginMetaInfo1.timePasswordChanged, newTimeMs + 1);
+ Assert.equal(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);
+ Assert.equal(gLoginMetaInfo2.password, gLoginInfo2.password);
+ Assert.equal(gLoginMetaInfo2.timeCreated, originalLogin.timeCreated);
+ Assert.equal(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);
+ Assert.equal(gLoginMetaInfo2.password, gLoginInfo2.password);
+ Assert.equal(gLoginMetaInfo2.timeCreated, originalLogin.timeCreated);
+ Assert.equal(gLoginMetaInfo2.timeLastUsed, originalLogin.timeLastUsed);
+ Assert.equal(gLoginMetaInfo2.timePasswordChanged, newTimeMs);
+
+ // Check the special timesUsedIncrement property.
+ Services.logins.modifyLogin(
+ gLoginInfo2,
+ newPropertyBag({
+ timesUsedIncrement: 2,
+ })
+ );
+
+ gLoginMetaInfo2 = retrieveLoginMatching(gLoginInfo2);
+ Assert.equal(gLoginMetaInfo2.timeCreated, originalLogin.timeCreated);
+ Assert.equal(gLoginMetaInfo2.timeLastUsed, originalLogin.timeLastUsed);
+ Assert.equal(gLoginMetaInfo2.timePasswordChanged, newTimeMs);
+ Assert.equal(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,
+ })
+ );
+ Assert.equal(logins.length, 1);
+ let foundLogin = logins[0].QueryInterface(Ci.nsILoginMetaInfo);
+ assertMetaInfoEqual(foundLogin, gLoginMetaInfo1);
+
+ // Find by timestamp.
+ logins = Services.logins.searchLogins(
+ newPropertyBag({
+ timePasswordChanged: gLoginMetaInfo2.timePasswordChanged,
+ })
+ );
+ Assert.equal(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,
+ })
+ );
+ Assert.equal(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(async function test_storage_metainfo() {
+ await 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 0000000000..e6cb47047b
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_logins_search.js
@@ -0,0 +1,234 @@
+/**
+ * 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) {
+ info("Testing searchLogins for " + JSON.stringify(aQuery));
+
+ let expectedLogins = buildExpectedLogins(aQuery);
+ Assert.equal(expectedLogins.length, aExpectedCount);
+
+ let logins = Services.logins.searchLogins(newPropertyBag(aQuery));
+ LoginTestUtils.assertLoginListsEqual(logins, expectedLogins);
+}
+
+/**
+ * Tests findLogins, searchLogins, and countLogins with the same query.
+ *
+ * @param aQuery
+ * The "origin", "formActionOrigin", 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) {
+ info("Testing all search functions for " + JSON.stringify(aQuery));
+
+ let expectedLogins = buildExpectedLogins(aQuery);
+ Assert.equal(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 origin = "origin" in aQuery ? aQuery.origin : "";
+ let formActionOrigin =
+ "formActionOrigin" in aQuery ? aQuery.formActionOrigin : "";
+ let httpRealm = "httpRealm" in aQuery ? aQuery.httpRealm : "";
+
+ // Test findLogins.
+ let logins = Services.logins.findLogins(origin, formActionOrigin, httpRealm);
+ LoginTestUtils.assertLoginListsEqual(logins, expectedLogins);
+
+ // Test countLogins.
+ let count = Services.logins.countLogins(origin, formActionOrigin, httpRealm);
+ Assert.equal(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({}, 27);
+
+ // Find all form logins, then all authentication logins.
+ checkAllSearches({ httpRealm: null }, 17);
+ checkAllSearches({ formActionOrigin: null }, 10);
+
+ // Find all form logins on one host, then all authentication logins.
+ checkAllSearches({ origin: "http://www4.example.com", httpRealm: null }, 3);
+ checkAllSearches(
+ { origin: "http://www2.example.org", formActionOrigin: null },
+ 2
+ );
+
+ // Verify that scheme and subdomain are distinct in the origin.
+ checkAllSearches({ origin: "http://www.example.com" }, 1);
+ checkAllSearches({ origin: "https://www.example.com" }, 1);
+ checkAllSearches({ origin: "https://example.com" }, 1);
+ checkAllSearches({ origin: "http://www3.example.com" }, 3);
+
+ // Verify that scheme and subdomain are distinct in formActionOrigin.
+ checkAllSearches({ formActionOrigin: "http://www.example.com" }, 2);
+ checkAllSearches({ formActionOrigin: "https://www.example.com" }, 2);
+ checkAllSearches({ formActionOrigin: "http://example.com" }, 1);
+
+ // Find by formActionOrigin on a single host.
+ checkAllSearches(
+ {
+ origin: "http://www3.example.com",
+ formActionOrigin: "http://www.example.com",
+ },
+ 1
+ );
+ checkAllSearches(
+ {
+ origin: "http://www3.example.com",
+ formActionOrigin: "https://www.example.com",
+ },
+ 1
+ );
+ checkAllSearches(
+ {
+ origin: "http://www3.example.com",
+ formActionOrigin: "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(
+ { origin: "http://example.net", httpRealm: "The HTTP Realm" },
+ 1
+ );
+ checkAllSearches(
+ { origin: "http://example.net", httpRealm: "The HTTP Realm Other" },
+ 1
+ );
+ checkAllSearches(
+ { origin: "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: "" }, 15);
+
+ // Find form logins with an empty usernameField.
+ checkSearchLogins({ httpRealm: null, usernameField: "" }, 5);
+
+ // Find logins with an empty usernameField on one host.
+ checkSearchLogins(
+ { origin: "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({ origin: "http://www.example.com" }, 1);
+ checkAllSearches({ origin: "http://www.example.com/" }, 0);
+ checkAllSearches({ origin: "example.com" }, 0);
+
+ checkAllSearches({ formActionOrigin: "http://www.example.com" }, 2);
+ checkAllSearches({ formActionOrigin: "http://www.example.com/" }, 0);
+ checkAllSearches({ formActionOrigin: "http://" }, 0);
+ checkAllSearches({ formActionOrigin: "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({ origin: "http://nonexistent.example.com" }, 0);
+ checkAllSearches(
+ { formActionOrigin: "http://www.example.com", httpRealm: "The HTTP Realm" },
+ 0
+ );
+
+ checkSearchLogins({ origin: "" }, 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 0000000000..ff60ceb1d5
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_maybeImportLogin.js
@@ -0,0 +1,368 @@
+"use strict";
+
+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";
+
+async function maybeImportLogins(logins) {
+ let summary = await LoginHelper.maybeImportLogins(logins);
+ return summary.filter(ir => ir.result == "added").map(ir => ir.login);
+}
+
+add_task(async function test_invalid_logins() {
+ let importedLogins = await maybeImportLogins([
+ {
+ username: USER1,
+ password: PASS1,
+ origin: "example.com", // Not an origin
+ formActionOrigin: HOST1,
+ },
+ {
+ username: USER1,
+ // no password
+ origin: HOST1,
+ formActionOrigin: HOST1,
+ },
+ {
+ username: USER2,
+ password: "", // Empty password
+ origin: HOST1,
+ formActionOrigin: HOST1,
+ },
+ ]);
+ Assert.equal(
+ importedLogins.length,
+ 0,
+ `Return value should indicate no imported login: ${JSON.stringify(
+ importedLogins,
+ null,
+ 2
+ )}`
+ );
+ let savedLogins = Services.logins.getAllLogins();
+ Assert.equal(
+ savedLogins.length,
+ 0,
+ `Should have no logins in storage: ${JSON.stringify(savedLogins, null, 2)}`
+ );
+ Services.logins.removeAllUserFacingLogins();
+});
+
+add_task(async function test_new_logins() {
+ let [importedLogin] = await maybeImportLogins([
+ {
+ username: USER1,
+ password: PASS1,
+ origin: HOST1 + "/",
+ formActionOrigin: HOST1 + "/",
+ },
+ ]);
+ Assert.ok(importedLogin, "Return value should indicate imported login.");
+ let matchingLogins = LoginHelper.searchLoginsWithObject({ origin: HOST1 });
+ Assert.equal(
+ matchingLogins.length,
+ 1,
+ `There should be 1 login for ${HOST1}`
+ );
+
+ [importedLogin] = await maybeImportLogins([
+ {
+ username: USER1,
+ password: PASS1,
+ origin: HOST2,
+ formActionOrigin: HOST2,
+ },
+ ]);
+
+ Assert.ok(
+ importedLogin,
+ "Return value should indicate another imported login."
+ );
+ matchingLogins = LoginHelper.searchLoginsWithObject({ origin: HOST1 });
+ Assert.equal(
+ matchingLogins.length,
+ 1,
+ `There should still be 1 login for ${HOST1}`
+ );
+
+ matchingLogins = LoginHelper.searchLoginsWithObject({ origin: 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.removeAllUserFacingLogins();
+});
+
+add_task(async function test_duplicate_logins() {
+ let [importedLogin] = await maybeImportLogins([
+ {
+ username: USER1,
+ password: PASS1,
+ origin: HOST1,
+ formActionOrigin: HOST1,
+ },
+ ]);
+ Assert.ok(importedLogin, "Return value should indicate imported login.");
+ let matchingLogins = LoginHelper.searchLoginsWithObject({ origin: HOST1 });
+ Assert.equal(
+ matchingLogins.length,
+ 1,
+ `There should be 1 login for ${HOST1}`
+ );
+
+ [importedLogin] = await maybeImportLogins([
+ {
+ username: USER1,
+ password: PASS1,
+ origin: HOST1,
+ formActionOrigin: HOST1,
+ },
+ ]);
+ Assert.ok(
+ !importedLogin,
+ "Return value should indicate no new login was imported."
+ );
+ matchingLogins = LoginHelper.searchLoginsWithObject({ origin: HOST1 });
+ Assert.equal(
+ matchingLogins.length,
+ 1,
+ `There should still be 1 login for ${HOST1}`
+ );
+ Services.logins.removeAllUserFacingLogins();
+});
+
+add_task(async function test_different_passwords() {
+ let [importedLogin] = await maybeImportLogins([
+ {
+ username: USER1,
+ password: PASS1,
+ origin: HOST1,
+ formActionOrigin: HOST1,
+ timeCreated: new Date(Date.now() - 1000),
+ },
+ ]);
+ Assert.ok(importedLogin, "Return value should indicate imported login.");
+ let matchingLogins = LoginHelper.searchLoginsWithObject({ origin: 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] = await maybeImportLogins([
+ {
+ username: USER1,
+ password: PASS2,
+ origin: HOST1,
+ formActionOrigin: HOST1,
+ timeCreated: new Date(),
+ },
+ ]);
+ Assert.ok(
+ !importedLogin,
+ "Return value should not indicate imported login (as we updated an existing one)."
+ );
+ matchingLogins = LoginHelper.searchLoginsWithObject({ origin: 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] = await maybeImportLogins([
+ {
+ username: USER1,
+ password: PASS3,
+ origin: HOST1,
+ formActionOrigin: 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({ origin: 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.removeAllUserFacingLogins();
+});
+
+add_task(async function test_different_usernames_without_guid() {
+ let [importedLogin] = await maybeImportLogins([
+ {
+ username: USER1,
+ password: PASS1,
+ origin: HOST1,
+ formActionOrigin: HOST1,
+ },
+ ]);
+ Assert.ok(importedLogin, "Return value should indicate imported login.");
+ let matchingLogins = LoginHelper.searchLoginsWithObject({ origin: HOST1 });
+ Assert.equal(
+ matchingLogins.length,
+ 1,
+ `There should be 1 login for ${HOST1}`
+ );
+
+ [importedLogin] = await maybeImportLogins([
+ {
+ username: USER2,
+ password: PASS1,
+ origin: HOST1,
+ formActionOrigin: HOST1,
+ },
+ ]);
+ Assert.ok(
+ importedLogin,
+ "Return value should indicate another imported login."
+ );
+ matchingLogins = LoginHelper.searchLoginsWithObject({ origin: HOST1 });
+ Assert.equal(
+ matchingLogins.length,
+ 2,
+ `There should now be 2 logins for ${HOST1}`
+ );
+
+ Services.logins.removeAllUserFacingLogins();
+});
+
+add_task(async function test_different_usernames_with_guid() {
+ let [{ login: importedLogin }] = await LoginHelper.maybeImportLogins([
+ {
+ username: USER1,
+ password: PASS1,
+ origin: HOST1,
+ formActionOrigin: HOST1,
+ },
+ ]);
+ Assert.ok(importedLogin, "Return value should indicate imported login.");
+ let matchingLogins = LoginHelper.searchLoginsWithObject({ origin: HOST1 });
+ Assert.equal(
+ matchingLogins.length,
+ 1,
+ `There should be 1 login for ${HOST1}`
+ );
+
+ info("Changing both the origin and username using the GUID");
+ let importedLogins = await LoginHelper.maybeImportLogins([
+ {
+ username: USER2,
+ password: PASS1,
+ origin: HOST2,
+ formActionOrigin: HOST1,
+ guid: importedLogin.guid,
+ },
+ ]);
+ Assert.equal(
+ importedLogins[0].result,
+ "modified",
+ "Return value should indicate an update"
+ );
+ matchingLogins = LoginHelper.searchLoginsWithObject({ origin: HOST2 });
+ Assert.equal(
+ matchingLogins.length,
+ 1,
+ `The 1 login for ${HOST1} should have been updated`
+ );
+ let storageLogin = matchingLogins[0];
+ Assert.equal(storageLogin.guid, importedLogin.guid, "Check same guid");
+ Assert.equal(storageLogin.username, USER2, "Check username updated");
+ Assert.equal(storageLogin.origin, HOST2, "Check origin updated");
+
+ Services.logins.removeAllUserFacingLogins();
+});
+
+add_task(async function test_different_targets() {
+ let [importedLogin] = await maybeImportLogins([
+ {
+ username: USER1,
+ password: PASS1,
+ origin: HOST1,
+ formActionOrigin: HOST1,
+ },
+ ]);
+ Assert.ok(importedLogin, "Return value should indicate imported login.");
+ let matchingLogins = LoginHelper.searchLoginsWithObject({ origin: HOST1 });
+ Assert.equal(
+ matchingLogins.length,
+ 1,
+ `There should be 1 login for ${HOST1}`
+ );
+
+ // Not passing either a formActionOrigin or a httpRealm should be treated as
+ // the same as the previous login
+ [importedLogin] = await maybeImportLogins([
+ {
+ username: USER1,
+ password: PASS1,
+ origin: HOST1,
+ },
+ ]);
+ Assert.ok(
+ !importedLogin,
+ "Return value should NOT indicate imported login " +
+ "(because a missing formActionOrigin and httpRealm should be duped to the existing login)."
+ );
+ matchingLogins = LoginHelper.searchLoginsWithObject({ origin: HOST1 });
+ Assert.equal(
+ matchingLogins.length,
+ 1,
+ `There should still be 1 login for ${HOST1}`
+ );
+ Assert.equal(
+ matchingLogins[0].formActionOrigin,
+ HOST1,
+ "The form submission URL should have been kept."
+ );
+
+ [importedLogin] = await maybeImportLogins([
+ {
+ username: USER1,
+ password: PASS1,
+ origin: HOST1,
+ httpRealm: HOST1,
+ },
+ ]);
+ Assert.ok(
+ importedLogin,
+ "Return value should indicate another imported login " +
+ "as an httpRealm login shouldn't be duped."
+ );
+ matchingLogins = LoginHelper.searchLoginsWithObject({ origin: HOST1 });
+ Assert.equal(
+ matchingLogins.length,
+ 2,
+ `There should now be 2 logins for ${HOST1}`
+ );
+
+ Services.logins.removeAllUserFacingLogins();
+});
diff --git a/toolkit/components/passwordmgr/test/unit/test_module_LoginCSVImport.js b/toolkit/components/passwordmgr/test/unit/test_module_LoginCSVImport.js
new file mode 100644
index 0000000000..78b7121a87
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_module_LoginCSVImport.js
@@ -0,0 +1,684 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Tests the LoginCSVImport module.
+ */
+
+"use strict";
+
+const {
+ LoginCSVImport,
+ ImportFailedException,
+ ImportFailedErrorType,
+} = ChromeUtils.import("resource://gre/modules/LoginCSVImport.jsm");
+const { LoginExport } = ChromeUtils.import(
+ "resource://gre/modules/LoginExport.jsm"
+);
+const { TelemetryTestUtils: TTU } = ChromeUtils.import(
+ "resource://testing-common/TelemetryTestUtils.jsm"
+);
+
+// Enable the collection (during test) for all products so even products
+// that don't collect the data will be able to run the test without failure.
+Services.prefs.setBoolPref(
+ "toolkit.telemetry.testing.overrideProductsCheck",
+ true
+);
+
+/**
+ * Given an array of strings it creates a temporary CSV file that has them as content.
+ *
+ * @param {string[]} csvLines
+ * The lines that make up the CSV file.
+ * @param {string} extension
+ * Optional parameter. Either 'csv' or 'tsv'. Default is 'csv'.
+ * @returns {string} The path to the CSV file that was created.
+ */
+async function setupCsv(csvLines, extension) {
+ // Cleanup state.
+ TTU.getAndClearKeyedHistogram("FX_MIGRATION_LOGINS_QUANTITY");
+ TTU.getAndClearKeyedHistogram("FX_MIGRATION_LOGINS_IMPORT_MS");
+ TTU.getAndClearKeyedHistogram("FX_MIGRATION_LOGINS_JANK_MS");
+ Services.logins.removeAllUserFacingLogins();
+
+ let tmpFile = await LoginTestUtils.file.setupCsvFileWithLines(
+ csvLines,
+ extension
+ );
+ return tmpFile.path;
+}
+
+function checkMetaInfo(
+ actual,
+ expected,
+ props = ["timesUsed", "timeCreated", "timePasswordChanged", "timeLastUsed"]
+) {
+ for (let prop of props) {
+ // This will throw if not equal.
+ equal(actual[prop], expected[prop], `Check ${prop}`);
+ }
+ return true;
+}
+
+function checkLoginNewlyCreated(login) {
+ // These will throw if not equal.
+ LoginTestUtils.assertTimeIsAboutNow(login.timeCreated);
+ LoginTestUtils.assertTimeIsAboutNow(login.timePasswordChanged);
+ LoginTestUtils.assertTimeIsAboutNow(login.timeLastUsed);
+ return true;
+}
+
+/**
+ * Ensure that an import works with TSV.
+ */
+add_task(async function test_import_tsv() {
+ let tsvFilePath = await setupCsv(
+ [
+ "url\tusername\tpassword\thttpRealm\tformActionOrigin\tguid\ttimeCreated\ttimeLastUsed\ttimePasswordChanged",
+ `https://example.com:8080\tjoe@example.com\tqwerty\tMy realm\t""\t{5ec0d12f-e194-4279-ae1b-d7d281bb46f0}\t1589617814635\t1589710449871\t1589617846802`,
+ ],
+ "tsv"
+ );
+
+ await LoginCSVImport.importFromCSV(tsvFilePath);
+
+ LoginTestUtils.checkLogins(
+ [
+ TestData.authLogin({
+ formActionOrigin: null,
+ guid: "{5ec0d12f-e194-4279-ae1b-d7d281bb46f0}",
+ httpRealm: "My realm",
+ origin: "https://example.com:8080",
+ password: "qwerty",
+ passwordField: "",
+ timeCreated: 1589617814635,
+ timeLastUsed: 1589710449871,
+ timePasswordChanged: 1589617846802,
+ timesUsed: 1,
+ username: "joe@example.com",
+ usernameField: "",
+ }),
+ ],
+ "Check that a new login was added with the correct fields",
+ (a, e) => a.equals(e) && checkMetaInfo(a, e)
+ );
+});
+
+/**
+ * Ensure that an import fails if there is no username column in a TSV file.
+ */
+add_task(async function test_import_tsv_with_missing_columns() {
+ let csvFilePath = await setupCsv(
+ [
+ "url\tusernameTypo\tpassword\thttpRealm\tformActionOrigin\tguid\ttimeCreated\ttimeLastUsed\ttimePasswordChanged",
+ "https://example.com\tkramer@example.com\tqwerty\tMy realm\t\t{5ec0d12f-e194-4279-ae1b-d7d281bb46f7}\t1589617814635\t1589710449871\t1589617846802",
+ ],
+ "tsv"
+ );
+
+ await Assert.rejects(
+ LoginCSVImport.importFromCSV(csvFilePath),
+ /FILE_FORMAT_ERROR/,
+ "Ensure missing username throws"
+ );
+
+ LoginTestUtils.checkLogins(
+ [],
+ "Check that no login was added without finding columns"
+ );
+});
+
+/**
+ * Ensure that an import fails if there is no username column. We don't want
+ * to accidentally import duplicates due to a name mismatch for the username column.
+ */
+add_task(async function test_import_lacking_username_column() {
+ let csvFilePath = await setupCsv([
+ "url,usernameTypo,password,httpRealm,formActionOrigin,guid,timeCreated,timeLastUsed,timePasswordChanged",
+ `https://example.com,joe@example.com,qwerty,My realm,,{5ec0d12f-e194-4279-ae1b-d7d281bb46f0},1589617814635,1589710449871,1589617846802`,
+ ]);
+
+ await Assert.rejects(
+ LoginCSVImport.importFromCSV(csvFilePath),
+ /FILE_FORMAT_ERROR/,
+ "Ensure missing username throws"
+ );
+
+ LoginTestUtils.checkLogins(
+ [],
+ "Check that no login was added without finding a username column"
+ );
+});
+
+/**
+ * Ensure that an import fails if there are two headings that map to one login field.
+ */
+add_task(async function test_import_with_duplicate_columns() {
+ // Two origin columns (url & login_uri).
+ // One row has different values and the other has the same.
+ let csvFilePath = await setupCsv([
+ "url,login_uri,username,login_password",
+ "https://example.com/path,https://example.com,john@example.com,azerty",
+ "https://mozilla.org,https://mozilla.org,jdoe@example.com,qwerty",
+ ]);
+
+ await LoginCSVImport.importFromCSV(csvFilePath);
+
+ LoginTestUtils.checkLogins(
+ [
+ TestData.formLogin({
+ formActionOrigin: "",
+ httpRealm: null,
+ origin: "https://mozilla.org",
+ password: "qwerty",
+ passwordField: "",
+ timesUsed: 1,
+ username: "jdoe@example.com",
+ usernameField: "",
+ }),
+ ],
+ "Check that no login was added with duplicate columns of differing values"
+ );
+});
+
+/**
+ * Ensure that import is allowed with only origin, username, password and that
+ * one can mix and match column naming between conventions from different
+ * password managers (so that we better support new/unknown password managers).
+ */
+add_task(async function test_import_minimal_with_mixed_naming() {
+ let csvFilePath = await setupCsv([
+ "url,username,login_password",
+ "ftp://example.com,john@example.com,azerty",
+ ]);
+
+ await LoginCSVImport.importFromCSV(csvFilePath);
+ LoginTestUtils.checkLogins(
+ [
+ TestData.formLogin({
+ formActionOrigin: "",
+ httpRealm: null,
+ origin: "ftp://example.com",
+ password: "azerty",
+ passwordField: "",
+ timesUsed: 1,
+ username: "john@example.com",
+ usernameField: "",
+ }),
+ ],
+ "Check that a new login was added with the correct fields",
+ (a, e) =>
+ a.equals(e) &&
+ checkMetaInfo(a, e, ["timesUsed"]) &&
+ checkLoginNewlyCreated(a)
+ );
+});
+
+/**
+ * Imports login data from the latest Firefox CSV file for various logins from
+ * LoginTestUtils.testData.loginList().
+ */
+add_task(async function test_import_from_firefox_various_latest() {
+ await setupCsv([]);
+ info("Populate the login list for export");
+ let logins = LoginTestUtils.testData.loginList();
+ for (let loginInfo of logins) {
+ Services.logins.addLogin(loginInfo);
+ }
+
+ let tmpFilePath = FileTestUtils.getTempFile("logins.csv").path;
+ await LoginExport.exportAsCSV(tmpFilePath);
+
+ await LoginCSVImport.importFromCSV(tmpFilePath);
+
+ LoginTestUtils.checkLogins(
+ logins,
+ "Check that all of LoginTestUtils.testData.loginList can be re-imported"
+ );
+});
+
+/**
+ * Imports login data from a Firefox CSV file without quotes.
+ */
+add_task(async function test_import_from_firefox_auth() {
+ let csvFilePath = await setupCsv([
+ "url,username,password,httpRealm,formActionOrigin,guid,timeCreated,timeLastUsed,timePasswordChanged",
+ `https://example.com:8080,joe@example.com,qwerty,My realm,"",{5ec0d12f-e194-4279-ae1b-d7d281bb46f0},1589617814635,1589710449871,1589617846802`,
+ ]);
+
+ await LoginCSVImport.importFromCSV(csvFilePath);
+
+ LoginTestUtils.checkLogins(
+ [
+ TestData.authLogin({
+ formActionOrigin: null,
+ guid: "{5ec0d12f-e194-4279-ae1b-d7d281bb46f0}",
+ httpRealm: "My realm",
+ origin: "https://example.com:8080",
+ password: "qwerty",
+ passwordField: "",
+ timeCreated: 1589617814635,
+ timeLastUsed: 1589710449871,
+ timePasswordChanged: 1589617846802,
+ timesUsed: 1,
+ username: "joe@example.com",
+ usernameField: "",
+ }),
+ ],
+ "Check that a new login was added with the correct fields",
+ (a, e) => a.equals(e) && checkMetaInfo(a, e)
+ );
+});
+
+/**
+ * Imports login data from a Firefox CSV file with quotes.
+ */
+add_task(async function test_import_from_firefox_auth_with_quotes() {
+ let csvFilePath = await setupCsv([
+ '"url","username","password","httpRealm","formActionOrigin","guid","timeCreated","timeLastUsed","timePasswordChanged"',
+ '"https://example.com","joe@example.com","qwerty2","My realm",,"{5ec0d12f-e194-4279-ae1b-d7d281bb46f0}","1589617814635","1589710449871","1589617846802"',
+ ]);
+
+ await LoginCSVImport.importFromCSV(csvFilePath);
+
+ LoginTestUtils.checkLogins(
+ [
+ TestData.authLogin({
+ formActionOrigin: null,
+ httpRealm: "My realm",
+ origin: "https://example.com",
+ password: "qwerty2",
+ passwordField: "",
+ timeCreated: 1589617814635,
+ timeLastUsed: 1589710449871,
+ timePasswordChanged: 1589617846802,
+ timesUsed: 1,
+ username: "joe@example.com",
+ usernameField: "",
+ }),
+ ],
+ "Check that a new login was added with the correct fields",
+ (a, e) => a.equals(e) && checkMetaInfo(a, e)
+ );
+});
+
+/**
+ * Imports login data from a Firefox CSV file where only cells containing a comma are quoted.
+ */
+add_task(async function test_import_from_firefox_auth_some_quoted_fields() {
+ let csvFilePath = await setupCsv([
+ "url,username,password,httpRealm,formActionOrigin,guid,timeCreated,timeLastUsed,timePasswordChanged",
+ 'https://example.com,joe@example.com,"one,two,tree","My realm",,{5ec0d12f-e194-4279-ae1b-d7d281bb46f0},1589617814635,1589710449871,1589617846802',
+ ]);
+
+ await LoginCSVImport.importFromCSV(csvFilePath);
+
+ LoginTestUtils.checkLogins(
+ [
+ TestData.authLogin({
+ formActionOrigin: null,
+ httpRealm: "My realm",
+ origin: "https://example.com",
+ password: "one,two,tree",
+ passwordField: "",
+ timeCreated: 1589617814635,
+ timePasswordChanged: 1589617846802,
+ timeLastUsed: 1589710449871,
+ timesUsed: 1,
+ username: "joe@example.com",
+ usernameField: "",
+ }),
+ ],
+ "Check that a new login was added with the correct fields",
+ (a, e) => a.equals(e) && checkMetaInfo(a, e)
+ );
+});
+
+/**
+ * Imports login data from a Firefox CSV file with an empty formActionOrigin and null httpRealm
+ */
+add_task(async function test_import_from_firefox_form_empty_formActionOrigin() {
+ let csvFilePath = await setupCsv([
+ "url,username,password,httpRealm,formActionOrigin,guid,timeCreated,timeLastUsed,timePasswordChanged",
+ "https://example.com,joe@example.com,s3cret1,,,{5ec0d12f-e194-4279-ae1b-d7d281bb46f0},1589617814636,1589710449872,1589617846803",
+ ]);
+
+ await LoginCSVImport.importFromCSV(csvFilePath);
+
+ LoginTestUtils.checkLogins(
+ [
+ TestData.formLogin({
+ formActionOrigin: "",
+ httpRealm: null,
+ origin: "https://example.com",
+ password: "s3cret1",
+ passwordField: "",
+ timeCreated: 1589617814636,
+ timePasswordChanged: 1589617846803,
+ timeLastUsed: 1589710449872,
+ timesUsed: 1,
+ username: "joe@example.com",
+ usernameField: "",
+ }),
+ ],
+ "Check that a new login was added with the correct fields",
+ (a, e) => a.equals(e) && checkMetaInfo(a, e)
+ );
+});
+
+/**
+ * Imports login data from a Firefox CSV file with a non-empty formActionOrigin and null httpRealm.
+ */
+add_task(async function test_import_from_firefox_form_with_formActionOrigin() {
+ let csvFilePath = await setupCsv([
+ "url,username,password,httpRealm,formActionOrigin,guid,timeCreated,timeLastUsed,timePasswordChanged",
+ "http://example.com,joe@example.com,s3cret1,,https://other.example.com,{5ec0d12f-e194-4279-ae1b-d7d281bb46f1},1589617814635,1589710449871,1589617846802",
+ ]);
+
+ await LoginCSVImport.importFromCSV(csvFilePath);
+
+ LoginTestUtils.checkLogins(
+ [
+ TestData.formLogin({
+ formActionOrigin: "https://other.example.com",
+ httpRealm: null,
+ origin: "http://example.com",
+ password: "s3cret1",
+ passwordField: "",
+ timeCreated: 1589617814635,
+ timePasswordChanged: 1589617846802,
+ timeLastUsed: 1589710449871,
+ timesUsed: 1,
+ username: "joe@example.com",
+ usernameField: "",
+ }),
+ ],
+ "Check that a new login was added with the correct fields",
+ (a, e) => a.equals(e) && checkMetaInfo(a, e)
+ );
+});
+
+/**
+ * Imports login data from a Bitwarden CSV file.
+ * `name` is ignored until bug 1433770.
+ */
+add_task(async function test_import_from_bitwarden_csv() {
+ let csvFilePath = await setupCsv([
+ "folder,favorite,type,name,notes,fields,login_uri,login_username,login_password,login_totp",
+ `,,note,jane's note,"secret note, ignore me!",,,,,`,
+ ",,login,example.com,,,https://example.com/login,jane@example.com,secret_password",
+ ]);
+
+ await LoginCSVImport.importFromCSV(csvFilePath);
+
+ LoginTestUtils.checkLogins(
+ [
+ TestData.formLogin({
+ formActionOrigin: "",
+ httpRealm: null,
+ origin: "https://example.com",
+ password: "secret_password",
+ passwordField: "",
+ timesUsed: 1,
+ username: "jane@example.com",
+ usernameField: "",
+ }),
+ ],
+ "Check that a new Bitwarden login was added with the correct fields",
+ (a, e) =>
+ a.equals(e) &&
+ checkMetaInfo(a, e, ["timesUsed"]) &&
+ checkLoginNewlyCreated(a)
+ );
+});
+
+/**
+ * Imports login data from a Chrome CSV file.
+ * `name` is ignored until bug 1433770.
+ */
+add_task(async function test_import_from_chrome_csv() {
+ let csvFilePath = await setupCsv([
+ "name,url,username,password",
+ "example.com,https://example.com/login,jane@example.com,secret_chrome_password",
+ ]);
+
+ await LoginCSVImport.importFromCSV(csvFilePath);
+
+ LoginTestUtils.checkLogins(
+ [
+ TestData.formLogin({
+ formActionOrigin: "",
+ httpRealm: null,
+ origin: "https://example.com",
+ password: "secret_chrome_password",
+ passwordField: "",
+ timesUsed: 1,
+ username: "jane@example.com",
+ usernameField: "",
+ }),
+ ],
+ "Check that a new Chrome login was added with the correct fields",
+ (a, e) =>
+ a.equals(e) &&
+ checkMetaInfo(a, e, ["timesUsed"]) &&
+ checkLoginNewlyCreated(a)
+ );
+});
+
+/**
+ * Imports login data from a KeepassXC CSV file.
+ * `Title` is ignored until bug 1433770.
+ */
+add_task(async function test_import_from_keepassxc_csv() {
+ let csvFilePath = await setupCsv([
+ `"Group","Title","Username","Password","URL","Notes"`,
+ `"NewDatabase/Internet","Amazing","test@example.com","<password>","https://example.org",""`,
+ ]);
+
+ await LoginCSVImport.importFromCSV(csvFilePath);
+
+ LoginTestUtils.checkLogins(
+ [
+ TestData.formLogin({
+ formActionOrigin: "",
+ httpRealm: null,
+ origin: "https://example.org",
+ password: "<password>",
+ passwordField: "",
+ timesUsed: 1,
+ username: "test@example.com",
+ usernameField: "",
+ }),
+ ],
+ "Check that a new KeepassXC login was added with the correct fields",
+ (a, e) =>
+ a.equals(e) &&
+ checkMetaInfo(a, e, ["timesUsed"]) &&
+ checkLoginNewlyCreated(a)
+ );
+});
+
+/**
+ * Imports login data summary contains added logins.
+ */
+add_task(async function test_import_summary_contains_added_login() {
+ let csvFilePath = await setupCsv([
+ "url,username,password,httpRealm,formActionOrigin,guid,timeCreated,timeLastUsed,timePasswordChanged",
+ "https://added.example.com,jane@example.com,added_passwordd,My realm,,{5ec0d12f-e194-4279-ae1b-d7d281bb0003},1589617814635,1589710449871,1589617846802",
+ ]);
+
+ let [added] = await LoginCSVImport.importFromCSV(csvFilePath);
+
+ equal(added.result, "added", `Check that the login was added`);
+});
+
+/**
+ * Imports login data summary contains modified logins.
+ */
+add_task(async function test_import_summary_contains_modified_login() {
+ let initialDataFile = await setupCsv([
+ "url,username,password,httpRealm,formActionOrigin,guid,timeCreated,timeLastUsed,timePasswordChanged",
+ "https://modifiedwithguid.example.com,jane@example.com,initial_password,My realm,,{5ec0d12f-e194-4279-ae1b-d7d281bb0001},1589617814635,1589710449871,1589617846802",
+ "https://modifiedwithoutguid.example.com,jane@example.com,initial_password,My realm,,,1589617814635,1589710449871,1589617846802",
+ ]);
+ await LoginCSVImport.importFromCSV(initialDataFile);
+
+ let csvFile = await LoginTestUtils.file.setupCsvFileWithLines([
+ "url,username,password,httpRealm,formActionOrigin,guid,timeCreated,timeLastUsed,timePasswordChanged",
+ "https://modified.example.com,jane@example.com,modified_password,My realm,,{5ec0d12f-e194-4279-ae1b-d7d281bb0001},1589617814635,1589710449871,1589617846999",
+ "https://modifiedwithoutguid.example.com,jane@example.com,modified_password,My realm,,,1589617814635,1589710449871,1589617846999",
+ ]);
+
+ let [
+ modifiedWithGuid,
+ modifiedWithoutGuid,
+ ] = await LoginCSVImport.importFromCSV(csvFile.path);
+
+ equal(
+ modifiedWithGuid.result,
+ "modified",
+ `Check that the login was modified when it had the same guid`
+ );
+ equal(
+ modifiedWithoutGuid.result,
+ "modified",
+ `Check that the login was modified when there was no guid data`
+ );
+});
+
+/**
+ * Imports login data summary contains unchanged logins.
+ */
+add_task(async function test_import_summary_contains_unchanged_login() {
+ let initialDataFile = await setupCsv([
+ "url,username,password,httpRealm,formActionOrigin,guid,timeCreated,timeLastUsed,timePasswordChanged",
+ "https://nochange.example.com,jane@example.com,nochange_password,My realm,,{5ec0d12f-e194-4279-ae1b-d7d281bb0002},1589617814635,1589710449871,1589617846802",
+ ]);
+ await LoginCSVImport.importFromCSV(initialDataFile);
+
+ let csvFile = await LoginTestUtils.file.setupCsvFileWithLines([
+ "url,username,password,httpRealm,formActionOrigin,guid,timeCreated,timeLastUsed,timePasswordChanged",
+ "https://nochange.example.com,jane@example.com,nochange_password,My realm,,{5ec0d12f-e194-4279-ae1b-d7d281bb0002},1589617814635,1589710449871,1589617846802",
+ ]);
+
+ let [noChange] = await LoginCSVImport.importFromCSV(csvFile.path);
+
+ equal(noChange.result, "no_change", `Check that the login was not changed`);
+});
+
+/**
+ * Imports login data summary contains logins with errors.
+ */
+add_task(async function test_import_summary_contains_logins_with_errors() {
+ let csvFilePath = await setupCsv([
+ "url,username,password,httpRealm,formActionOrigin,guid,timeCreated,timeLastUsed,timePasswordChanged",
+ "https://invalid.password.example.com,jane@example.com,,My realm,,{5ec0d12f-e194-4279-ae1b-d7d281bb0002},1589617814635,1589710449871,1589617846802",
+ ",jane@example.com,invalid_origin,My realm,,{5ec0d12f-e194-4279-ae1b-d7d281bb0005},1589617814635,1589710449871,1589617846802",
+ ]);
+ let [invalidPassword, invalidOrigin] = await LoginCSVImport.importFromCSV(
+ csvFilePath
+ );
+
+ equal(
+ invalidPassword.result,
+ "error_invalid_password",
+ `Check that the invalid password error is reported`
+ );
+ equal(
+ invalidOrigin.result,
+ "error_invalid_origin",
+ `Check that the invalid origin error is reported`
+ );
+});
+
+/**
+ * Imports login with wrong file format will have correct errorType.
+ */
+add_task(async function test_import_summary_with_bad_format() {
+ let csvFilePath = await setupCsv(["password", "123qwe!@#QWE"]);
+
+ await Assert.rejects(
+ LoginCSVImport.importFromCSV(csvFilePath),
+ /FILE_FORMAT_ERROR/,
+ "Check that the errorType is file format error"
+ );
+
+ LoginTestUtils.checkLogins(
+ [],
+ "Check that no login was added with bad format"
+ );
+});
+
+/**
+ * Imports login with wrong file type will have correct errorType.
+ */
+add_task(async function test_import_summary_with_non_csv_file() {
+ let csvFilePath = await setupCsv([
+ "<body>this is totally not a csv file</body>",
+ ]);
+
+ await Assert.rejects(
+ LoginCSVImport.importFromCSV(csvFilePath),
+ /FILE_FORMAT_ERROR/,
+ "Check that the errorType is file format error"
+ );
+
+ LoginTestUtils.checkLogins(
+ [],
+ "Check that no login was added with file of different format"
+ );
+});
+
+/**
+ * Imports login with wrong file type will have correct errorType.
+ */
+add_task(async function test_import_summary_with_url_user_multiple_values() {
+ let csvFilePath = await setupCsv([
+ "url,username,password,httpRealm,formActionOrigin,guid,timeCreated,timeLastUsed,timePasswordChanged",
+ "https://example.com,jane@example.com,password1,My realm",
+ "https://example.com,jane@example.com,password2,My realm",
+ ]);
+
+ let errorType;
+ try {
+ await LoginCSVImport.importFromCSV(csvFilePath);
+ } catch (e) {
+ if (e instanceof ImportFailedException) {
+ errorType = e.errorType;
+ }
+ }
+
+ equal(
+ errorType,
+ ImportFailedErrorType.CONFLICTING_VALUES_ERROR,
+ `Check that the errorType is file format error in case of duplicate entries`
+ );
+}).skip(); // TODO: Bug 1687852, resolve duplicates when importing
+
+/**
+ * Imports login with wrong file type will have correct errorType.
+ */
+add_task(async function test_import_summary_with_multiple_guid_values() {
+ let csvFilePath = await setupCsv([
+ "url,username,password,httpRealm,formActionOrigin,guid,timeCreated,timeLastUsed,timePasswordChanged",
+ "https://example1.com,jane1@example.com,password1,My realm,,{5ec0d12f-e194-4279-ae1b-d7d281bb0004},1589617814635,1589710449871,1589617846802",
+ "https://example2.com,jane2@example.com,password2,My realm,,{5ec0d12f-e194-4279-ae1b-d7d281bb0004},1589617814635,1589710449871,1589617846802",
+ ]);
+
+ let errorType;
+ try {
+ await LoginCSVImport.importFromCSV(csvFilePath);
+ } catch (e) {
+ if (e instanceof ImportFailedException) {
+ errorType = e.errorType;
+ }
+ }
+
+ equal(
+ errorType,
+ ImportFailedErrorType.CONFLICTING_VALUES_ERROR,
+ `Check that the errorType is file format error in case of duplicate entries`
+ );
+});
diff --git a/toolkit/components/passwordmgr/test/unit/test_module_LoginExport.js b/toolkit/components/passwordmgr/test/unit/test_module_LoginExport.js
new file mode 100644
index 0000000000..9172685f44
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_module_LoginExport.js
@@ -0,0 +1,217 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Tests the LoginExport module.
+ */
+
+"use strict";
+
+let { LoginExport } = ChromeUtils.import(
+ "resource://gre/modules/LoginExport.jsm"
+);
+let { sinon } = ChromeUtils.import("resource://testing-common/Sinon.jsm");
+
+/**
+ * Saves the logins to a temporary CSV file, reads the lines and returns the CSV lines.
+ * After extracting the CSV lines, it deletes the tmp file.
+ */
+async function exportAsCSVInTmpFile() {
+ let tmpFilePath = FileTestUtils.getTempFile("logins.csv").path;
+ await LoginExport.exportAsCSV(tmpFilePath);
+ let csvContent = await OS.File.read(tmpFilePath);
+ let csvString = new TextDecoder().decode(csvContent);
+ await OS.File.remove(tmpFilePath);
+ // CSV uses CRLF
+ return csvString.split(/\r\n/);
+}
+
+const COMMON_LOGIN_MODS = {
+ guid: "{5ec0d12f-e194-4279-ae1b-d7d281bb46f0}",
+ timeCreated: 1589617814635,
+ timeLastUsed: 1589710449871,
+ timePasswordChanged: 1589617846802,
+ timesUsed: 1,
+ username: "joe@example.com",
+ password: "qwerty",
+ origin: "https://example.com",
+};
+
+/**
+ * Generates a new login object with all the form login fields populated.
+ */
+function exportFormLogin(modifications) {
+ return LoginTestUtils.testData.formLogin({
+ ...COMMON_LOGIN_MODS,
+ formActionOrigin: "https://action.example.com",
+ ...modifications,
+ });
+}
+
+function exportAuthLogin(modifications) {
+ return LoginTestUtils.testData.authLogin({
+ ...COMMON_LOGIN_MODS,
+ httpRealm: "My realm",
+ ...modifications,
+ });
+}
+
+add_task(async function setup() {
+ let oldLogins = Services.logins;
+ Services.logins = { getAllLoginsAsync: sinon.stub() };
+ registerCleanupFunction(() => {
+ Services.logins = oldLogins;
+ });
+});
+
+add_task(async function test_buildCSVRow() {
+ let testObject = {
+ null: null,
+ emptyString: "",
+ number: 99,
+ string: "Foo",
+ };
+ Assert.deepEqual(
+ LoginExport._buildCSVRow(testObject, [
+ "null",
+ "emptyString",
+ "number",
+ "string",
+ ]),
+ ["", `""`, `"99"`, `"Foo"`],
+ "Check _buildCSVRow with different types"
+ );
+});
+
+add_task(async function test_no_new_properties_to_export() {
+ let login = exportFormLogin();
+ Assert.deepEqual(
+ Object.keys(login),
+ [
+ "QueryInterface",
+ "displayOrigin",
+ "origin",
+ "hostname",
+ "formActionOrigin",
+ "formSubmitURL",
+ "httpRealm",
+ "username",
+ "usernameField",
+ "password",
+ "passwordField",
+ "init",
+ "equals",
+ "matches",
+ "clone",
+ "guid",
+ "timeCreated",
+ "timeLastUsed",
+ "timePasswordChanged",
+ "timesUsed",
+ ],
+ "Check that no new properties were added to a login that should maybe be exported"
+ );
+});
+
+add_task(async function test_export_one_form_login() {
+ let login = exportFormLogin();
+ Services.logins.getAllLoginsAsync.returns([login]);
+
+ let rows = await exportAsCSVInTmpFile();
+
+ Assert.equal(
+ rows[0],
+ '"url","username","password","httpRealm","formActionOrigin","guid","timeCreated","timeLastUsed","timePasswordChanged"',
+ "checking csv headers"
+ );
+ Assert.equal(
+ rows[1],
+ '"https://example.com","joe@example.com","qwerty",,"https://action.example.com","{5ec0d12f-e194-4279-ae1b-d7d281bb46f0}","1589617814635","1589710449871","1589617846802"',
+ `checking login is saved as CSV row\n${JSON.stringify(login)}\n`
+ );
+});
+
+add_task(async function test_export_one_auth_login() {
+ let login = exportAuthLogin();
+ Services.logins.getAllLoginsAsync.returns([login]);
+
+ let rows = await exportAsCSVInTmpFile();
+
+ Assert.equal(
+ rows[0],
+ '"url","username","password","httpRealm","formActionOrigin","guid","timeCreated","timeLastUsed","timePasswordChanged"',
+ "checking csv headers"
+ );
+ Assert.equal(
+ rows[1],
+ '"https://example.com","joe@example.com","qwerty","My realm",,"{5ec0d12f-e194-4279-ae1b-d7d281bb46f0}","1589617814635","1589710449871","1589617846802"',
+ `checking login is saved as CSV row\n${JSON.stringify(login)}\n`
+ );
+});
+
+add_task(async function test_export_escapes_values() {
+ let login = exportFormLogin({
+ password: "!@#$%^&*()_+,'",
+ });
+ Services.logins.getAllLoginsAsync.returns([login]);
+
+ let rows = await exportAsCSVInTmpFile();
+
+ Assert.equal(
+ rows[1],
+ '"https://example.com","joe@example.com","!@#$%^&*()_+,\'",,"https://action.example.com","{5ec0d12f-e194-4279-ae1b-d7d281bb46f0}","1589617814635","1589710449871","1589617846802"',
+ `checking login correctly escapes CSV characters \n${JSON.stringify(login)}`
+ );
+});
+
+add_task(async function test_export_multiple_rows() {
+ let logins = await LoginTestUtils.testData.loginList();
+ // Note, because we're stubbing this method and avoiding the actual login manager logic,
+ // login de-duplication does not occur
+ Services.logins.getAllLoginsAsync.returns(logins);
+
+ let actualRows = await exportAsCSVInTmpFile();
+ let expectedRows = [
+ '"url","username","password","httpRealm","formActionOrigin","guid","timeCreated","timeLastUsed","timePasswordChanged"',
+ '"http://www.example.com","the username","the password for www.example.com",,"http://www.example.com",,,,',
+ '"https://www.example.com","the username","the password for https",,"https://www.example.com",,,,',
+ '"https://example.com","the username","the password for example.com",,"https://example.com",,,,',
+ '"http://www3.example.com","the username","the password",,"http://www.example.com",,,,',
+ '"http://www3.example.com","the username","the password",,"https://www.example.com",,,,',
+ '"http://www3.example.com","the username","the password",,"http://example.com",,,,',
+ '"http://www4.example.com","username one","password one",,"http://www4.example.com",,,,',
+ '"http://www4.example.com","username two","password two",,"http://www4.example.com",,,,',
+ '"http://www4.example.com","","password three",,"http://www4.example.com",,,,',
+ '"http://www5.example.com","multi username","multi password",,"http://www5.example.com",,,,',
+ '"http://www6.example.com","","12345",,"http://www6.example.com",,,,',
+ '"https://www7.example.com:8080","8080_username","8080_pass",,"https://www7.example.com:8080",,,,',
+ '"https://www7.example.com:8080","8080_username2","8080_pass2","My dev server",,,,,',
+ '"http://www.example.org","the username","the password","The HTTP Realm",,,,,',
+ '"ftp://ftp.example.org","the username","the password","ftp://ftp.example.org",,,,,',
+ '"http://www2.example.org","the username","the password","The HTTP Realm",,,,,',
+ '"http://www2.example.org","the username other","the password other","The HTTP Realm Other",,,,,',
+ '"http://example.net","the username","the password",,"http://example.net",,,,',
+ '"http://example.net","the username","the password",,"http://www.example.net",,,,',
+ '"http://example.net","username two","the password",,"http://www.example.net",,,,',
+ '"http://example.net","the username","the password","The HTTP Realm",,,,,',
+ '"http://example.net","username two","the password","The HTTP Realm Other",,,,,',
+ '"ftp://example.net","the username","the password","ftp://example.net",,,,,',
+ '"chrome://example_extension","the username","the password one","Example Login One",,,,,',
+ '"chrome://example_extension","the username","the password two","Example Login Two",,,,,',
+ '"file://","file: username","file: password",,"file://",,,,',
+ '"https://js.example.com","javascript: username","javascript: password",,"javascript:",,,,',
+ ];
+
+ Assert.equal(actualRows.length, expectedRows.length, "Check number of lines");
+ for (let i = 0; i < logins.length; i++) {
+ let login = logins[i];
+ Assert.equal(
+ actualRows[i],
+ expectedRows[i],
+ `checking CSV correctly writes row at index=${i} \n${JSON.stringify(
+ login
+ )}\n`
+ );
+ }
+});
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 0000000000..c6ff788407
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_module_LoginStore.js
@@ -0,0 +1,342 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Tests the LoginStore object.
+ */
+
+"use strict";
+
+// Globals
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "LoginStore",
+ "resource://gre/modules/LoginStore.jsm"
+);
+
+const TEST_STORE_FILE_NAME = "test-logins.json";
+
+const MAX_DATE_MS = 8640000000000000;
+
+// Tests
+
+/**
+ * Saves login data to a file, then reloads it.
+ */
+add_task(async 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.
+ await storeForSave.load();
+
+ let rawLoginData = {
+ id: storeForSave.data.nextId++,
+ hostname: "http://www.example.com",
+ httpRealm: null,
+ formSubmitURL: "http://www.example.com",
+ 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);
+
+ await storeForSave._save();
+
+ // Test the asynchronous initialization path.
+ let storeForLoad = new LoginStore(storeForSave.path);
+ await storeForLoad.load();
+
+ Assert.equal(storeForLoad.data.logins.length, 1);
+ Assert.deepEqual(storeForLoad.data.logins[0], rawLoginData);
+
+ // Test the synchronous initialization path.
+ storeForLoad = new LoginStore(storeForSave.path);
+ storeForLoad.ensureDataReady();
+
+ Assert.equal(storeForLoad.data.logins.length, 1);
+ Assert.deepEqual(storeForLoad.data.logins[0], rawLoginData);
+});
+
+/**
+ * Checks that loading from a missing file results in empty arrays.
+ */
+add_task(async function test_load_empty() {
+ let store = new LoginStore(getTempFile(TEST_STORE_FILE_NAME).path);
+
+ Assert.equal(false, await OS.File.exists(store.path));
+
+ await store.load();
+
+ Assert.equal(false, await OS.File.exists(store.path));
+
+ Assert.equal(store.data.logins.length, 0);
+});
+
+/**
+ * Checks that saving empty data still overwrites any existing file.
+ */
+add_task(async function test_save_empty() {
+ let store = new LoginStore(getTempFile(TEST_STORE_FILE_NAME).path);
+
+ await store.load();
+
+ let createdFile = await OS.File.open(store.path, { create: true });
+ await createdFile.close();
+
+ await store._save();
+
+ Assert.ok(await 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(async 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",' +
+ '"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"]}';
+
+ await OS.File.writeAtomic(store.path, new TextEncoder().encode(string), {
+ tmpPath: store.path + ".tmp",
+ });
+
+ await store.load();
+
+ Assert.equal(store.data.logins.length, 1);
+ Assert.deepEqual(store.data.logins[0], {
+ id: 1,
+ hostname: "http://www.example.com",
+ httpRealm: null,
+ formSubmitURL: "http://www.example.com",
+ usernameField: "usernameField",
+ passwordField: "passwordField",
+ encryptedUsername: "(test)",
+ encryptedPassword: "(test)",
+ guid: "(test)",
+ encType: Ci.nsILoginManagerCrypto.ENCTYPE_SDR,
+ timeCreated: 1262304000000,
+ timeLastUsed: 1262390400000,
+ timePasswordChanged: 1262476800000,
+ timesUsed: 1,
+ });
+});
+
+/**
+ * Loads login data from a malformed JSON string.
+ */
+add_task(async 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,';
+
+ await OS.File.writeAtomic(store.path, new TextEncoder().encode(string), {
+ tmpPath: store.path + ".tmp",
+ });
+
+ await store.load();
+
+ // A backup file should have been created.
+ Assert.ok(await OS.File.exists(store.path + ".corrupt"));
+ await OS.File.remove(store.path + ".corrupt");
+
+ // The store should be ready to accept new data.
+ Assert.equal(store.data.logins.length, 0);
+});
+
+/**
+ * Loads login data from a malformed JSON string, using the synchronous
+ * initialization path.
+ */
+add_task(async 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,';
+
+ await OS.File.writeAtomic(store.path, new TextEncoder().encode(string), {
+ tmpPath: store.path + ".tmp",
+ });
+
+ store.ensureDataReady();
+
+ // A backup file should have been created.
+ Assert.ok(await OS.File.exists(store.path + ".corrupt"));
+ await OS.File.remove(store.path + ".corrupt");
+
+ // The store should be ready to accept new data.
+ Assert.equal(store.data.logins.length, 0);
+});
+
+/**
+ * Fix bad dates when loading login data
+ */
+add_task(async function test_load_bad_dates() {
+ let rawLoginData = {
+ encType: 1,
+ encryptedPassword: "(test)",
+ encryptedUsername: "(test)",
+ formSubmitURL: "https://www.example.com",
+ guid: "{2a97313f-873b-4048-9a3d-4f442b46c1e5}",
+ hostname: "https://www.example.com",
+ httpRealm: null,
+ id: 1,
+ passwordField: "pass",
+ timesUsed: 1,
+ usernameField: "email",
+ };
+ let rawStoreData = {
+ dismissedBreachAlertsByLoginGUID: {},
+ logins: [],
+ nextId: 2,
+ potentiallyVulnerablePasswords: [],
+ version: 2,
+ };
+
+ /**
+ * test that:
+ * - bogus (0 or out-of-range) date values in any of the date fields are replaced with the
+ * earliest time marked by the other date fields
+ * - bogus bogus (0 or out-of-range) date values in all date fields are replaced with the time of import
+ */
+ let tests = [
+ {
+ name: "Out-of-range time values",
+ savedProps: {
+ timePasswordChanged: MAX_DATE_MS + 1,
+ timeLastUsed: MAX_DATE_MS + 1,
+ timeCreated: MAX_DATE_MS + 1,
+ },
+ expectedProps: {
+ timePasswordChanged: "now",
+ timeLastUsed: "now",
+ timeCreated: "now",
+ },
+ },
+ {
+ name: "All zero time values",
+ savedProps: {
+ timePasswordChanged: 0,
+ timeLastUsed: 0,
+ timeCreated: 0,
+ },
+ expectedProps: {
+ timePasswordChanged: "now",
+ timeLastUsed: "now",
+ timeCreated: "now",
+ },
+ },
+ {
+ name: "Only timeCreated has value",
+ savedProps: {
+ timePasswordChanged: 0,
+ timeLastUsed: 0,
+ timeCreated: 946713600000,
+ },
+ expectedProps: {
+ timePasswordChanged: 946713600000,
+ timeLastUsed: 946713600000,
+ timeCreated: 946713600000,
+ },
+ },
+ {
+ name: "timeCreated has 0 value",
+ savedProps: {
+ timePasswordChanged: 946713600000,
+ timeLastUsed: 946713600000,
+ timeCreated: 0,
+ },
+ expectedProps: {
+ timePasswordChanged: 946713600000,
+ timeLastUsed: 946713600000,
+ timeCreated: 946713600000,
+ },
+ },
+ {
+ name: "timeCreated has out-of-range value",
+ savedProps: {
+ timePasswordChanged: 946713600000,
+ timeLastUsed: 946713600000,
+ timeCreated: MAX_DATE_MS + 1,
+ },
+ expectedProps: {
+ timePasswordChanged: 946713600000,
+ timeLastUsed: 946713600000,
+ timeCreated: 946713600000,
+ },
+ },
+ {
+ name: "Use earliest time for missing value",
+ savedProps: {
+ timePasswordChanged: 0,
+ timeLastUsed: 946713600000,
+ timeCreated: 946540800000,
+ },
+ expectedProps: {
+ timePasswordChanged: 946540800000,
+ timeLastUsed: 946713600000,
+ timeCreated: 946540800000,
+ },
+ },
+ ];
+
+ for (let testData of tests) {
+ let store = new LoginStore(getTempFile(TEST_STORE_FILE_NAME).path);
+ let string = JSON.stringify(
+ Object.assign({}, rawStoreData, {
+ logins: [Object.assign({}, rawLoginData, testData.savedProps)],
+ })
+ );
+ await OS.File.writeAtomic(store.path, new TextEncoder().encode(string), {
+ tmpPath: store.path + ".tmp",
+ });
+ let now = Date.now();
+ await store.load();
+
+ Assert.equal(
+ store.data.logins.length,
+ 1,
+ `${testData.name}: Expected a single login`
+ );
+
+ let login = store.data.logins[0];
+ for (let pname of ["timeCreated", "timeLastUsed", "timePasswordChanged"]) {
+ if (testData.expectedProps[pname] === "now") {
+ Assert.ok(
+ login[pname] >= now,
+ `${testData.name}: Check ${pname} is at/near now`
+ );
+ } else {
+ Assert.equal(
+ login[pname],
+ testData.expectedProps[pname],
+ `${testData.name}: Check expected ${pname}`
+ );
+ }
+ }
+ Assert.equal(store.data.version, 3, "Check version was bumped");
+ }
+});
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 0000000000..b81d01717d
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_notifications.js
@@ -0,0 +1,193 @@
+/**
+ * Tests notifications dispatched when modifying stored logins.
+ */
+
+let expectedNotification;
+let expectedData;
+
+let TestObserver = {
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIObserver",
+ "nsISupportsWeakReference",
+ ]),
+
+ observe(subject, topic, data) {
+ Assert.equal(topic, "passwordmgr-storage-changed");
+ Assert.equal(data, expectedNotification);
+
+ switch (data) {
+ case "addLogin":
+ Assert.ok(subject instanceof Ci.nsILoginInfo);
+ Assert.ok(subject instanceof Ci.nsILoginMetaInfo);
+ Assert.ok(expectedData.equals(subject)); // nsILoginInfo.equals()
+ break;
+ case "modifyLogin":
+ Assert.ok(subject instanceof Ci.nsIArray);
+ Assert.equal(subject.length, 2);
+ let oldLogin = subject.queryElementAt(0, Ci.nsILoginInfo);
+ let newLogin = subject.queryElementAt(1, Ci.nsILoginInfo);
+ Assert.ok(expectedData[0].equals(oldLogin)); // nsILoginInfo.equals()
+ Assert.ok(expectedData[1].equals(newLogin));
+ break;
+ case "removeLogin":
+ Assert.ok(subject instanceof Ci.nsILoginInfo);
+ Assert.ok(subject instanceof Ci.nsILoginMetaInfo);
+ Assert.ok(expectedData.equals(subject)); // nsILoginInfo.equals()
+ break;
+ case "removeAllLogins":
+ Assert.ok(subject instanceof Ci.nsIArray);
+ break;
+ case "hostSavingEnabled":
+ case "hostSavingDisabled":
+ Assert.ok(subject instanceof Ci.nsISupportsString);
+ Assert.equal(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() {
+ let testnum = 0;
+ let testdesc = "Setup of nsLoginInfo test-users";
+
+ try {
+ let testuser1 = new LoginInfo(
+ "http://testhost1",
+ "",
+ null,
+ "dummydude",
+ "itsasecret",
+ "put_user_here",
+ "put_pw_here"
+ );
+
+ let testuser2 = new LoginInfo(
+ "http://testhost2",
+ "",
+ null,
+ "dummydude2",
+ "itsasecret2",
+ "put_user2_here",
+ "put_pw2_here"
+ );
+
+ Services.obs.addObserver(TestObserver, "passwordmgr-storage-changed");
+
+ /* ========== 1 ========== */
+ testnum = 1;
+ testdesc = "Initial connection to storage module";
+
+ /* ========== 2 ========== */
+ testnum++;
+ testdesc = "addLogin";
+
+ expectedNotification = "addLogin";
+ expectedData = testuser1;
+ Services.logins.addLogin(testuser1);
+ LoginTestUtils.checkLogins([testuser1]);
+ Assert.equal(expectedNotification, null); // check that observer got a notification
+
+ /* ========== 3 ========== */
+ testnum++;
+ testdesc = "modifyLogin";
+
+ expectedNotification = "modifyLogin";
+ expectedData = [testuser1, testuser2];
+ Services.logins.modifyLogin(testuser1, testuser2);
+ Assert.equal(expectedNotification, null);
+ LoginTestUtils.checkLogins([testuser2]);
+
+ /* ========== 4 ========== */
+ testnum++;
+ testdesc = "removeLogin";
+
+ expectedNotification = "removeLogin";
+ expectedData = testuser2;
+ Services.logins.removeLogin(testuser2);
+ Assert.equal(expectedNotification, null);
+ LoginTestUtils.checkLogins([]);
+
+ /* ========== 5 ========== */
+ testnum++;
+ testdesc = "removeAllLogins";
+
+ expectedNotification = "removeAllLogins";
+ expectedData = null;
+ Services.logins.removeAllLogins();
+ Assert.equal(expectedNotification, null);
+ LoginTestUtils.checkLogins([]);
+
+ /* ========== 6 ========== */
+ testnum++;
+ testdesc = "removeAllLogins (again)";
+
+ expectedNotification = "addLogin";
+ expectedData = testuser1;
+ Services.logins.addLogin(testuser1);
+
+ expectedNotification = "removeAllLogins";
+ expectedData = null;
+ Services.logins.removeAllLogins();
+ Assert.equal(expectedNotification, null);
+ LoginTestUtils.checkLogins([]);
+
+ /* ========== 7 ========== */
+ testnum++;
+ testdesc = "setLoginSavingEnabled / false";
+
+ expectedNotification = "hostSavingDisabled";
+ expectedData = "http://site.com";
+ Services.logins.setLoginSavingEnabled("http://site.com", false);
+ Assert.equal(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);
+ Assert.equal(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);
+ Assert.equal(expectedNotification, null);
+ LoginTestUtils.checkLogins([]);
+
+ /* ========== 10 ========== */
+ testnum++;
+ testdesc = "setLoginSavingEnabled / true (again)";
+
+ expectedNotification = "hostSavingEnabled";
+ expectedData = "http://site.com";
+ Services.logins.setLoginSavingEnabled("http://site.com", true);
+ Assert.equal(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 0000000000..253556232e
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_recipes_add.js
@@ -0,0 +1,288 @@
+/* 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(async 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 = await initPromise1;
+ Assert.ok(
+ recipesParent instanceof LoginRecipesParent,
+ "Check init return value"
+ );
+ Assert.strictEqual(
+ recipesParent._recipesByHost.size,
+ 0,
+ "Initially 0 recipes"
+ );
+});
+
+add_task(async function test_get_missing_host() {
+ let recipesParent = await RecipeHelpers.initNewParent();
+ let exampleRecipes = recipesParent.getRecipesForHost("example.invalid");
+ Assert.strictEqual(
+ exampleRecipes.size,
+ 0,
+ "Check recipe count for example.invalid"
+ );
+});
+
+add_task(async function test_add_get_simple_host() {
+ let recipesParent = await 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(async function test_add_get_non_standard_port_host() {
+ let recipesParent = await 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(async function test_add_multiple_hosts() {
+ let recipesParent = await 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(async function test_add_pathRegex() {
+ let recipesParent = await 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(async function test_add_selectors() {
+ let recipesParent = await 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(async function test_add_missing_prop() {
+ let recipesParent = await RecipeHelpers.initNewParent();
+ Assert.throws(
+ () => recipesParent.add({}),
+ /required/,
+ "Some properties are required"
+ );
+});
+
+add_task(async function test_add_unknown_prop() {
+ let recipesParent = await RecipeHelpers.initNewParent();
+ Assert.throws(
+ () =>
+ recipesParent.add({
+ unknownProp: true,
+ }),
+ /supported/,
+ "Unknown properties should cause an error to help with typos"
+ );
+});
+
+add_task(async function test_add_invalid_hosts() {
+ let recipesParent = await RecipeHelpers.initNewParent();
+ Assert.throws(
+ () =>
+ recipesParent.add({
+ hosts: 404,
+ }),
+ /array/,
+ "hosts should be an array"
+ );
+});
+
+add_task(async function test_add_empty_host_array() {
+ let recipesParent = await RecipeHelpers.initNewParent();
+ Assert.throws(
+ () =>
+ recipesParent.add({
+ hosts: [],
+ }),
+ /array/,
+ "hosts should be a non-empty array"
+ );
+});
+
+add_task(async function test_add_pathRegex_non_regexp() {
+ let recipesParent = await RecipeHelpers.initNewParent();
+ Assert.throws(
+ () =>
+ recipesParent.add({
+ hosts: ["example.com"],
+ pathRegex: "foo",
+ }),
+ /regular expression/,
+ "pathRegex should be a RegExp"
+ );
+});
+
+add_task(async function test_add_usernameSelector_non_string() {
+ let recipesParent = await RecipeHelpers.initNewParent();
+ Assert.throws(
+ () =>
+ recipesParent.add({
+ hosts: ["example.com"],
+ usernameSelector: 404,
+ }),
+ /string/,
+ "usernameSelector should be a string"
+ );
+});
+
+add_task(async function test_add_passwordSelector_non_string() {
+ let recipesParent = await 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 0000000000..673bb50851
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_recipes_content.js
@@ -0,0 +1,53 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Test filtering recipes in LoginRecipesContent.
+ */
+
+"use strict";
+
+add_task(async 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_remote_recipes.js b/toolkit/components/passwordmgr/test/unit/test_remote_recipes.js
new file mode 100644
index 0000000000..4197865f09
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_remote_recipes.js
@@ -0,0 +1,159 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Tests retrieving remote LoginRecipes in the parent process.
+ */
+
+"use strict";
+
+const { RemoteSettings } = ChromeUtils.import(
+ "resource://services-settings/remote-settings.js",
+ {}
+);
+
+const REMOTE_SETTINGS_COLLECTION = "password-recipes";
+
+add_task(async function test_init_remote_recipe() {
+ const db = await RemoteSettings(REMOTE_SETTINGS_COLLECTION).db;
+ const record1 = {
+ id: "some-fake-ID",
+ hosts: ["www.testDomain.com"],
+ description: "Some description here",
+ usernameSelector: "#username",
+ };
+ await db.create(record1);
+ let parent = new LoginRecipesParent({ defaults: true });
+
+ let recipesParent = await parent.initializationPromise;
+ Assert.ok(
+ recipesParent instanceof LoginRecipesParent,
+ "Check initialization promise value which should be an instance of LoginRecipesParent"
+ );
+ Assert.strictEqual(
+ recipesParent._recipesByHost.size,
+ 1,
+ "Initially 1 recipe based on our test record"
+ );
+ let rsClient = recipesParent._rsClient;
+
+ recipesParent.reset();
+ await recipesParent.initializationPromise;
+ Assert.ok(
+ recipesParent instanceof LoginRecipesParent,
+ "Ensure that the instance of LoginRecipesParent has not changed after resetting"
+ );
+ Assert.strictEqual(
+ rsClient,
+ recipesParent._rsClient,
+ "Resetting recipes should not modify the rs client"
+ );
+ Assert.strictEqual(
+ recipesParent._recipesByHost.size,
+ 1,
+ "Initially 1 recipe based on our test record"
+ );
+ await db.clear();
+});
+
+add_task(async function test_add_recipe_sync() {
+ const db = await RemoteSettings(REMOTE_SETTINGS_COLLECTION).db;
+ const record1 = {
+ id: "some-fake-ID",
+ hosts: ["www.testDomain.com"],
+ description: "Some description here",
+ usernameSelector: "#username",
+ };
+ await db.create(record1);
+ let parent = new LoginRecipesParent({ defaults: true });
+ let recipesParent = await parent.initializationPromise;
+
+ const record2 = {
+ id: "some-fake-ID-2",
+ hosts: ["www.testDomain2.com"],
+ description: "Some description here. Wow it changed!",
+ usernameSelector: "#username",
+ };
+ const payload = {
+ current: [record1, record2],
+ created: [record2],
+ updated: [],
+ deleted: [],
+ };
+ await RemoteSettings(REMOTE_SETTINGS_COLLECTION).emit("sync", {
+ data: payload,
+ });
+ Assert.strictEqual(
+ recipesParent._recipesByHost.size,
+ 2,
+ "New recipe from sync event added successfully"
+ );
+ await db.clear();
+});
+
+add_task(async function test_remove_recipe_sync() {
+ const db = await RemoteSettings(REMOTE_SETTINGS_COLLECTION).db;
+ const record1 = {
+ id: "some-fake-ID",
+ hosts: ["www.testDomain.com"],
+ description: "Some description here",
+ usernameSelector: "#username",
+ };
+ await db.create(record1);
+ let parent = new LoginRecipesParent({ defaults: true });
+ let recipesParent = await parent.initializationPromise;
+
+ const deletePayload = {
+ current: [],
+ created: [],
+ updated: [],
+ deleted: [record1],
+ };
+ await RemoteSettings(REMOTE_SETTINGS_COLLECTION).emit("sync", {
+ data: deletePayload,
+ });
+ Assert.strictEqual(
+ recipesParent._recipesByHost.size,
+ 0,
+ "Recipes successfully deleted on sync event"
+ );
+ await db.clear();
+});
+
+add_task(async function test_malformed_recipes_in_db() {
+ const db = await RemoteSettings(REMOTE_SETTINGS_COLLECTION).db;
+ const malformedRecord = {
+ id: "some-ID",
+ hosts: ["www.testDomain.com"],
+ description: "Some description here",
+ usernameSelector: "#username",
+ fieldThatDoesNotExist: "value",
+ };
+ await db.create(malformedRecord);
+ let parent = new LoginRecipesParent({ defaults: true });
+ try {
+ await parent.initializationPromise;
+ } catch (e) {
+ Assert.ok(
+ e == "There were 1 recipe error(s)",
+ "It should throw an error because of field that does not match the schema"
+ );
+ }
+
+ await db.clear();
+ const missingHostsRecord = {
+ id: "some-ID",
+ description: "Some description here",
+ usernameSelector: "#username",
+ };
+ await db.create(missingHostsRecord);
+ parent = new LoginRecipesParent({ defaults: true });
+ try {
+ await parent.initializationPromise;
+ } catch (e) {
+ Assert.ok(
+ e == "There were 1 recipe error(s)",
+ "It should throw an error because of missing hosts field"
+ );
+ }
+});
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 0000000000..47b7e84080
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_search_schemeUpgrades.js
@@ -0,0 +1,241 @@
+/**
+ * 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 (["origin", "formActionOrigin"].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) {
+ info("Testing searchLogins for " + JSON.stringify(aQuery));
+
+ let expectedLogins = buildExpectedLogins(aQuery);
+ Assert.equal(expectedLogins.length, aExpectedCount);
+
+ let logins = Services.logins.searchLogins(newPropertyBag(aQuery));
+ 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_origin() {
+ // Origin-only
+ checkSearch(
+ {
+ origin: HTTPS_ORIGIN,
+ },
+ 1
+ );
+ checkSearch(
+ {
+ origin: HTTPS_ORIGIN,
+ schemeUpgrades: false,
+ },
+ 1
+ );
+ checkSearch(
+ {
+ origin: HTTPS_ORIGIN,
+ schemeUpgrades: undefined,
+ },
+ 1
+ );
+ checkSearch(
+ {
+ origin: HTTPS_ORIGIN,
+ schemeUpgrades: true,
+ },
+ 2
+ );
+});
+
+/**
+ * Same as above but replacing origin with formActionOrigin.
+ */
+add_task(function test_search_schemeUpgrades_formActionOrigin() {
+ checkSearch(
+ {
+ formActionOrigin: HTTPS_ORIGIN,
+ },
+ 2
+ );
+ checkSearch(
+ {
+ formActionOrigin: HTTPS_ORIGIN,
+ schemeUpgrades: false,
+ },
+ 2
+ );
+ checkSearch(
+ {
+ formActionOrigin: HTTPS_ORIGIN,
+ schemeUpgrades: undefined,
+ },
+ 2
+ );
+ checkSearch(
+ {
+ formActionOrigin: HTTPS_ORIGIN,
+ schemeUpgrades: true,
+ },
+ 4
+ );
+});
+
+add_task(function test_search_schemeUpgrades_origin_formActionOrigin() {
+ checkSearch(
+ {
+ formActionOrigin: HTTPS_ORIGIN,
+ origin: HTTPS_ORIGIN,
+ },
+ 1
+ );
+ checkSearch(
+ {
+ formActionOrigin: HTTPS_ORIGIN,
+ origin: HTTPS_ORIGIN,
+ schemeUpgrades: false,
+ },
+ 1
+ );
+ checkSearch(
+ {
+ formActionOrigin: HTTPS_ORIGIN,
+ origin: HTTPS_ORIGIN,
+ schemeUpgrades: undefined,
+ },
+ 1
+ );
+ checkSearch(
+ {
+ formActionOrigin: HTTPS_ORIGIN,
+ origin: HTTPS_ORIGIN,
+ schemeUpgrades: true,
+ },
+ 2
+ );
+ checkSearch(
+ {
+ formActionOrigin: HTTPS_ORIGIN,
+ origin: HTTPS_ORIGIN,
+ schemeUpgrades: true,
+ usernameField: "form_field_username",
+ },
+ 2
+ );
+ checkSearch(
+ {
+ formActionOrigin: HTTPS_ORIGIN,
+ origin: HTTPS_ORIGIN,
+ passwordField: "form_field_password",
+ schemeUpgrades: true,
+ usernameField: "form_field_username",
+ },
+ 2
+ );
+ checkSearch(
+ {
+ formActionOrigin: HTTPS_ORIGIN,
+ origin: 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(
+ {
+ formActionOrigin: HTTPS_ORIGIN,
+ origin: HTTP3_ORIGIN,
+ httpRealm: null,
+ schemeUpgrades: false,
+ },
+ 1
+ );
+ checkSearch(
+ {
+ formActionOrigin: HTTPS_ORIGIN,
+ origin: HTTP3_ORIGIN,
+ httpRealm: null,
+ schemeUpgrades: true,
+ },
+ 2
+ );
+});
+
+/**
+ * schemeUpgrades shouldn't cause downgrades
+ */
+add_task(function test_search_schemeUpgrades_downgrade() {
+ checkSearch(
+ {
+ formActionOrigin: HTTP_ORIGIN,
+ origin: HTTP_ORIGIN,
+ },
+ 1
+ );
+ info(
+ "The same number should be found with schemeUpgrades since we're searching for HTTP"
+ );
+ checkSearch(
+ {
+ formActionOrigin: HTTP_ORIGIN,
+ origin: HTTP_ORIGIN,
+ schemeUpgrades: true,
+ },
+ 1
+ );
+});
diff --git a/toolkit/components/passwordmgr/test/unit/test_shadowHTTPLogins.js b/toolkit/components/passwordmgr/test/unit/test_shadowHTTPLogins.js
new file mode 100644
index 0000000000..544c850f96
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_shadowHTTPLogins.js
@@ -0,0 +1,86 @@
+/**
+ * Test LoginHelper.shadowHTTPLogins
+ */
+
+"use strict";
+
+const DOMAIN1_HTTP_TO_HTTP_U1_P1 = TestData.formLogin({});
+const DOMAIN1_HTTP_TO_HTTP_U2_P1 = TestData.formLogin({
+ username: "user2",
+});
+const DOMAIN1_HTTPS_TO_HTTPS_U1_P1 = TestData.formLogin({
+ origin: "https://www3.example.com",
+ formActionOrigin: "https://login.example.com",
+});
+const DOMAIN1_HTTPS_TO_HTTPS_U1_P2 = TestData.formLogin({
+ origin: "https://www3.example.com",
+ formActionOrigin: "https://login.example.com",
+ password: "password two",
+});
+const DOMAIN1_HTTP_TO_HTTP_U1_P2 = TestData.formLogin({
+ password: "password two",
+});
+const DOMAIN1_HTTP_TO_HTTP_U1_P1_DIFFERENT_PORT = TestData.formLogin({
+ origin: "http://www3.example.com:8080",
+});
+const DOMAIN2_HTTP_TO_HTTP_U1_P1 = TestData.formLogin({
+ origin: "http://different.example.com",
+});
+const DOMAIN2_HTTPS_TO_HTTPS_U1_P1 = TestData.formLogin({
+ origin: "https://different.example.com",
+ formActionOrigin: "https://login.example.com",
+});
+
+add_task(function test_shadowHTTPLogins() {
+ let testcases = [
+ {
+ description: "same hostPort, same username, different scheme",
+ logins: [DOMAIN1_HTTPS_TO_HTTPS_U1_P1, DOMAIN1_HTTP_TO_HTTP_U1_P1],
+ expected: [DOMAIN1_HTTPS_TO_HTTPS_U1_P1],
+ },
+ {
+ description: "different passwords, different scheme",
+ logins: [DOMAIN1_HTTPS_TO_HTTPS_U1_P1, DOMAIN1_HTTP_TO_HTTP_U1_P2],
+ expected: [DOMAIN1_HTTPS_TO_HTTPS_U1_P1],
+ },
+ {
+ description: "both https, same username, different password",
+ logins: [DOMAIN1_HTTPS_TO_HTTPS_U1_P1, DOMAIN1_HTTPS_TO_HTTPS_U1_P2],
+ expected: [DOMAIN1_HTTPS_TO_HTTPS_U1_P1, DOMAIN1_HTTPS_TO_HTTPS_U1_P2],
+ },
+ {
+ description: "same origin, different port, different scheme",
+ logins: [
+ DOMAIN1_HTTPS_TO_HTTPS_U1_P1,
+ DOMAIN1_HTTP_TO_HTTP_U1_P1_DIFFERENT_PORT,
+ ],
+ expected: [
+ DOMAIN1_HTTPS_TO_HTTPS_U1_P1,
+ DOMAIN1_HTTP_TO_HTTP_U1_P1_DIFFERENT_PORT,
+ ],
+ },
+ {
+ description: "different origin, different scheme",
+ logins: [DOMAIN1_HTTPS_TO_HTTPS_U1_P1, DOMAIN2_HTTP_TO_HTTP_U1_P1],
+ expected: [DOMAIN1_HTTPS_TO_HTTPS_U1_P1, DOMAIN2_HTTP_TO_HTTP_U1_P1],
+ },
+ {
+ description: "different username, different scheme",
+ logins: [DOMAIN1_HTTPS_TO_HTTPS_U1_P1, DOMAIN1_HTTP_TO_HTTP_U2_P1],
+ expected: [DOMAIN1_HTTPS_TO_HTTPS_U1_P1, DOMAIN1_HTTP_TO_HTTP_U2_P1],
+ },
+ ];
+
+ for (let tc of testcases) {
+ info(tc.description);
+ let actual = LoginHelper.shadowHTTPLogins(tc.logins);
+ Assert.strictEqual(
+ actual.length,
+ tc.expected.length,
+ `Check result length`
+ );
+ for (let [i, login] of tc.expected.entries()) {
+ Assert.strictEqual(actual[i], login, `Check index ${i}`);
+ }
+ }
+});
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 0000000000..71456ee761
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_storage.js
@@ -0,0 +1,95 @@
+/* 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
+
+async function reloadAndCheckLoginsGen(aExpectedLogins) {
+ await LoginTestUtils.reloadData();
+ LoginTestUtils.checkLogins(aExpectedLogins);
+ LoginTestUtils.clearData();
+}
+
+// Tests
+
+/**
+ * Tests addLogin with valid non-ASCII characters.
+ */
+add_task(async function test_storage_addLogin_nonascii() {
+ let origin = "http://" + String.fromCharCode(355) + ".example.com";
+
+ // Store the strings "user" and "pass" using similarly looking glyphs.
+ let loginInfo = TestData.formLogin({
+ origin,
+ formActionOrigin: origin,
+ 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);
+ await reloadAndCheckLoginsGen([loginInfo]);
+
+ // Store the string "test" using similarly looking glyphs.
+ loginInfo = TestData.authLogin({
+ httpRealm: String.fromCharCode(355, 277, 349, 357),
+ });
+ Services.logins.addLogin(loginInfo);
+ await reloadAndCheckLoginsGen([loginInfo]);
+});
+
+/**
+ * Tests addLogin with newline characters in the username and password.
+ */
+add_task(async function test_storage_addLogin_newlines() {
+ let loginInfo = TestData.formLogin({
+ username: "user\r\nname",
+ password: "password\r\n",
+ });
+ Services.logins.addLogin(loginInfo);
+ await 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(async function test_storage_addLogin_dot() {
+ let loginInfo = TestData.formLogin({ origin: ".", passwordField: "." });
+ Services.logins.addLogin(loginInfo);
+ await reloadAndCheckLoginsGen([loginInfo]);
+
+ loginInfo = TestData.authLogin({ httpRealm: "." });
+ Services.logins.addLogin(loginInfo);
+ await reloadAndCheckLoginsGen([loginInfo]);
+});
+
+/**
+ * Tests addLogin with parentheses in origins.
+ *
+ * These tests exist to verify the legacy "signons.txt" storage format.
+ */
+add_task(async 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({ origin: "http://parens(.example.com" }),
+ TestData.authLogin({ origin: "http://parens).example.com" }),
+ TestData.authLogin({ origin: "http://parens(example).example.com" }),
+ TestData.authLogin({ origin: "http://parens)example(.example.com" }),
+ ];
+ for (let loginInfo of loginList) {
+ Services.logins.addLogin(loginInfo);
+ }
+ await reloadAndCheckLoginsGen(loginList);
+});
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 0000000000..d3cb0377b0
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_telemetry.js
@@ -0,0 +1,201 @@
+/* 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 { TestUtils } = ChromeUtils.import(
+ "resource://testing-common/TestUtils.jsm"
+);
+
+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.
+const 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),
+ formActionOrigin: 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.
+ */
+async function triggerStatisticsCollection() {
+ Services.obs.notifyObservers(null, "gather-telemetry", "" + gReferenceTimeMs);
+ await TestUtils.topicObserved("passwordmgr-gather-telemetry-complete");
+}
+
+/**
+ * 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 [range, value] of Object.entries(snapshot.values)) {
+ if (value > 0) {
+ actualNonZeroRanges[range] = value;
+ }
+ }
+
+ // These are stringified to visualize the differences between the values.
+ info("Testing histogram: " + histogramId);
+ Assert.equal(
+ 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;
+ registerCleanupFunction(function() {
+ Services.telemetry.canRecordExtended = oldCanRecord;
+ });
+
+ let uniqueNumber = 1;
+ for (let loginModifications of StatisticsTestData) {
+ loginModifications.origin = `http://${uniqueNumber++}.example.com`;
+ let login;
+ if (typeof loginModifications.httpRealm != "undefined") {
+ login = TestData.authLogin(loginModifications);
+ } else {
+ login = TestData.formLogin(loginModifications);
+ }
+ Services.logins.addLogin(login);
+ }
+});
+
+/**
+ * Tests the collection of statistics related to login metadata.
+ */
+add_task(async function test_logins_statistics() {
+ // Repeat the operation twice to test that histograms are not accumulated.
+ for (let pass of [1, 2]) {
+ info(`pass ${pass}`);
+ await 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(async 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);
+ await triggerStatisticsCollection();
+ testHistogram("PWMGR_BLOCKLIST_NUM_SITES", { 1: 1 });
+
+ Services.logins.setLoginSavingEnabled("http://www.example.com", true);
+ await triggerStatisticsCollection();
+ testHistogram("PWMGR_BLOCKLIST_NUM_SITES", { 0: 1 });
+});
+
+/**
+ * Tests the collection of statistics related to general settings.
+ */
+add_task(async function test_settings_statistics() {
+ let oldRememberSignons = Services.prefs.getBoolPref("signon.rememberSignons");
+ registerCleanupFunction(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);
+
+ await 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_vulnerable_passwords.js b/toolkit/components/passwordmgr/test/unit/test_vulnerable_passwords.js
new file mode 100644
index 0000000000..b477d526b2
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_vulnerable_passwords.js
@@ -0,0 +1,47 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function setup() {
+ await Services.logins.initializationPromise;
+});
+
+add_task(async function test_vulnerable_password_methods() {
+ const storageJSON = Services.logins.wrappedJSObject._storage.wrappedJSObject;
+
+ let logins = TestData.loginList();
+ Assert.greater(logins.length, 0, "Initial logins length should be > 0.");
+
+ for (let loginInfo of logins) {
+ Services.logins.addLogin(loginInfo);
+ Assert.ok(
+ !storageJSON.isPotentiallyVulnerablePassword(loginInfo),
+ "No logins should be vulnerable until addVulnerablePasswords is called."
+ );
+ }
+
+ const vulnerableLogin = logins.shift();
+ storageJSON.addPotentiallyVulnerablePassword(vulnerableLogin);
+
+ Assert.ok(
+ storageJSON.isPotentiallyVulnerablePassword(vulnerableLogin),
+ "Login should be vulnerable after calling addVulnerablePassword."
+ );
+ for (let loginInfo of logins) {
+ Assert.ok(
+ !storageJSON.isPotentiallyVulnerablePassword(loginInfo),
+ "No other logins should be vulnerable when addVulnerablePassword is called" +
+ " with a single argument"
+ );
+ }
+
+ storageJSON.clearAllPotentiallyVulnerablePasswords();
+
+ for (let loginInfo of logins) {
+ Assert.ok(
+ !storageJSON.isPotentiallyVulnerablePassword(loginInfo),
+ "No logins should be vulnerable when clearAllPotentiallyVulnerablePasswords is called."
+ );
+ }
+});
diff --git a/toolkit/components/passwordmgr/test/unit/xpcshell.ini b/toolkit/components/passwordmgr/test/unit/xpcshell.ini
new file mode 100644
index 0000000000..a1c343fa37
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/xpcshell.ini
@@ -0,0 +1,66 @@
+[DEFAULT]
+head = head.js
+skip-if = os == "android" || toolkit == "android" # Not supported on GV because we can't add/remove from storage.
+support-files = data/**
+
+# Test logins.json file access, not applicable to Android.
+[test_module_LoginStore.js]
+skip-if = os == "android"
+[test_loginsBackup.js]
+skip-if = os == "android"
+
+# The following tests apply to any storage back-end that supports add/modify/remove.
+[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_displayOrigin.js]
+[test_doLoginsMatch.js]
+[test_getFormFields.js]
+[test_getPasswordFields.js]
+[test_getPasswordOrigin.js]
+[test_getUserNameAndPasswordFields.js]
+[test_isOriginMatching.js]
+[test_isProbablyANewPasswordField.js]
+[test_isUsernameFieldType.js]
+[test_legacy_empty_formActionOrigin.js]
+[test_LoginManagerParent_doAutocompleteSearch.js]
+skip-if = os == "android" # Password generation not packaged/used on Android
+[test_LoginManagerParent_getGeneratedPassword.js]
+skip-if = os == "android" # Password generation not packaged/used on Android
+[test_LoginManagerParent_onPasswordEditedOrGenerated.js]
+skip-if = os == "android" # Password generation not packaged/used on Android
+[test_LoginManagerParent_searchAndDedupeLogins.js]
+skip-if = os == "android" # schemeUpgrades aren't supported
+[test_LoginManagerPrompter_getUsernameSuggestions.js]
+skip-if = os == "android" # Tests desktop's prompter
+[test_legacy_validation.js]
+[test_login_autocomplete_result.js]
+skip-if = os == "android"
+[test_logins_change.js]
+[test_logins_decrypt_failure.js]
+skip-if = os == "android" # Bug 1171687: Needs fixing on Android
+[test_logins_metainfo.js]
+[test_logins_search.js]
+[test_maybeImportLogin.js]
+skip-if = os == "android" # Only used by migrator, which isn't on Android
+[test_module_LoginCSVImport.js]
+[test_module_LoginExport.js]
+skip-if = os == "android" # there is no export for android
+[test_notifications.js]
+[test_OSCrypto_win.js]
+skip-if = os != "win"
+[test_PasswordGenerator.js]
+skip-if = os == "android" # Not packaged/used on Android
+[test_recipes_add.js]
+[test_recipes_content.js]
+[test_remote_recipes.js]
+skip-if = os == "android"
+[test_search_schemeUpgrades.js]
+[test_shadowHTTPLogins.js]
+[test_storage.js]
+[test_telemetry.js]
+[test_vulnerable_passwords.js]
+skip-if = os == "android" # Not implemented for storage-mozStorage