summaryrefslogtreecommitdiffstats
path: root/toolkit/components/passwordmgr/test/unit
diff options
context:
space:
mode:
Diffstat (limited to 'toolkit/components/passwordmgr/test/unit')
-rw-r--r--toolkit/components/passwordmgr/test/unit/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.js134
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_CSVParser.js254
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_LoginManagerParent_doAutocompleteSearch.js148
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_LoginManagerParent_getGeneratedPassword.js176
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_LoginManagerParent_onPasswordEditedOrGenerated.js1141
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_LoginManagerParent_searchAndDedupeLogins.js207
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_LoginManagerPrompter_getUsernameSuggestions.js188
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_OSCrypto_win.js138
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_PasswordGenerator.js122
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_PasswordRulesManager_generatePassword.js523
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_context_menu.js345
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_dedupeLogins.js411
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_disabled_hosts.js223
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_displayOrigin.js43
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_doLoginsMatch.js57
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_findRelatedRealms.js156
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_getFormFields.js572
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_getPasswordFields.js308
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_getPasswordOrigin.js35
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_getUserNameAndPasswordFields.js177
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_getUsernameFieldFromUsernameOnlyForm.js179
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_isInferredLoginForm.js98
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_isInferredUsernameField.js222
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_isOriginMatching.js177
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_isProbablyANewPasswordField.js192
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_isUsernameFieldType.js160
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_legacy_empty_formActionOrigin.js126
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_legacy_validation.js94
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_login_autocomplete_result.js735
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_loginsBackup.js218
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_logins_change.js628
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_logins_decrypt_failure.js172
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_logins_metainfo.js295
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_logins_search.js232
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_maybeImportLogin.js368
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_module_LoginCSVImport.js870
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_module_LoginExport.js219
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_module_LoginManager.js37
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_module_LoginStore.js337
-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.js162
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_search_schemeUpgrades.js239
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_shadowHTTPLogins.js82
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_storage.js93
-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.ini74
62 files changed, 12149 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..8c2dc53f66
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/head.js
@@ -0,0 +1,134 @@
+/**
+ * Provides infrastructure for automated login components tests.
+ */
+
+"use strict";
+
+// Globals
+
+const { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+const { LoginRecipesContent, LoginRecipesParent } = ChromeUtils.importESModule(
+ "resource://gre/modules/LoginRecipes.sys.mjs"
+);
+const { LoginHelper } = ChromeUtils.importESModule(
+ "resource://gre/modules/LoginHelper.sys.mjs"
+);
+const { FileTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/FileTestUtils.sys.mjs"
+);
+const { LoginTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/LoginTestUtils.sys.mjs"
+);
+const { MockDocument } = ChromeUtils.importESModule(
+ "resource://testing-common/MockDocument.sys.mjs"
+);
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(this, {
+ DownloadPaths: "resource://gre/modules/DownloadPaths.sys.mjs",
+ FileUtils: "resource://gre/modules/FileUtils.sys.mjs",
+});
+
+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";
+const RELATED_REALMS_ENABLED_PREF = "signon.relatedRealms.enabled";
+const IMPROVED_PASSWORD_RULES_PREF = "signon.improvedPasswordRules.enabled";
+/**
+ * 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_setup(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 IOUtils.copy(
+ do_get_file(`data/${keyDBName}`).path,
+ PathUtils.join(PathUtils.profileDir, keyDBName)
+ );
+
+ // Ensure that the service and the storage module are initialized.
+ await Services.logins.initializationPromise;
+ Services.prefs.setBoolPref(RELATED_REALMS_ENABLED_PREF, true);
+ if (LoginHelper.relatedRealmsEnabled) {
+ // Ensure that there is a mocked Remote Settings database for the
+ // "websites-with-shared-credential-backends" collection
+ await LoginTestUtils.remoteSettings.setupWebsitesWithSharedCredentials();
+ }
+});
+
+add_setup(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_CSVParser.js b/toolkit/components/passwordmgr/test/unit/test_CSVParser.js
new file mode 100644
index 0000000000..d680d8daf2
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_CSVParser.js
@@ -0,0 +1,254 @@
+/* Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { CSV } = ChromeUtils.importESModule(
+ "resource://gre/modules/CSV.sys.mjs"
+);
+
+const TEST_CASES = [
+ {
+ description:
+ "string with fields with no special characters gets parsed correctly",
+ csvString: `
+url,username,password
+https://example.com/,testusername,testpassword
+`,
+ expectedHeaderLine: ["url", "username", "password"],
+ expectedParsedLines: [
+ {
+ url: "https://example.com/",
+ username: "testusername",
+ password: "testpassword",
+ },
+ ],
+ delimiter: ",",
+ throwsError: false,
+ },
+ {
+ description:
+ "string with fields enclosed in quotes with no special characters gets parsed correctly",
+ csvString: `
+"url","username","password"
+"https://example.com/","testusername","testpassword"
+`,
+ expectedHeaderLine: ["url", "username", "password"],
+ expectedParsedLines: [
+ {
+ url: "https://example.com/",
+ username: "testusername",
+ password: "testpassword",
+ },
+ ],
+ delimiter: ",",
+ throwsError: false,
+ },
+ {
+ description: "empty fields gets parsed correctly",
+ csvString: `
+"url","username","password"
+"https://example.com/","",""
+`,
+ expectedHeaderLine: ["url", "username", "password"],
+ expectedParsedLines: [
+ {
+ url: "https://example.com/",
+ username: "",
+ password: "",
+ },
+ ],
+ delimiter: ",",
+ throwsError: false,
+ },
+ {
+ description: "string with commas in fields gets parsed correctly",
+ csvString: `
+url,username,password
+https://example.com/,"test,usern,ame","tes,,tpassword"
+`,
+ expectedHeaderLine: ["url", "username", "password"],
+ expectedParsedLines: [
+ {
+ url: "https://example.com/",
+ username: "test,usern,ame",
+ password: "tes,,tpassword",
+ },
+ ],
+ delimiter: ",",
+ throwsError: false,
+ },
+ {
+ description: "string with line break in fields gets parsed correctly",
+ csvString: `
+url,username,password
+https://example.com/,"test\nusername","\ntestpass\n\nword"
+`,
+ expectedHeaderLine: ["url", "username", "password"],
+ expectedParsedLines: [
+ {
+ url: "https://example.com/",
+ username: "test\nusername",
+ password: "\ntestpass\n\nword",
+ },
+ ],
+ delimiter: ",",
+ throwsError: false,
+ },
+ {
+ description: "string with quotation mark in fields gets parsed correctly",
+ csvString: `
+url,username,password
+https://example.com/,"testusern""ame","test""""pass""word"
+`,
+ expectedHeaderLine: ["url", "username", "password"],
+ expectedParsedLines: [
+ {
+ url: "https://example.com/",
+ username: 'testusern"ame',
+ password: 'test""pass"word',
+ },
+ ],
+ delimiter: ",",
+ throwsError: false,
+ },
+ {
+ description: "tsv string with tab as delimiter gets parsed correctly",
+ csvString: `
+url\tusername\tpassword
+https://example.com/\ttestusername\ttestpassword
+`,
+ expectedHeaderLine: ["url", "username", "password"],
+ expectedParsedLines: [
+ {
+ url: "https://example.com/",
+ username: "testusername",
+ password: "testpassword",
+ },
+ ],
+ delimiter: "\t",
+ throwsError: false,
+ },
+ {
+ description: "string with CR LF as line breaks gets parsed correctly",
+ csvString:
+ "url,username,password\r\nhttps://example.com/,testusername,testpassword\r\n",
+ expectedHeaderLine: ["url", "username", "password"],
+ expectedParsedLines: [
+ {
+ url: "https://example.com/",
+ username: "testusername",
+ password: "testpassword",
+ },
+ ],
+ delimiter: ",",
+ throwsError: false,
+ },
+ {
+ description:
+ "string without line break at the end of the file gets parsed correctly",
+ csvString: `
+url,username,password
+https://example.com/,testusername,testpassword`,
+ expectedHeaderLine: ["url", "username", "password"],
+ expectedParsedLines: [
+ {
+ url: "https://example.com/",
+ username: "testusername",
+ password: "testpassword",
+ },
+ ],
+ delimiter: ",",
+ throwsError: false,
+ },
+ {
+ description:
+ "multiple line breaks at the beginning, in the middle or at the end of a string are trimmed and not parsed as empty rows",
+ csvString: `
+\r\r
+url,username,password
+\n\n
+https://example.com/,testusername,testpassword
+\n\r
+`,
+ expectedHeaderLine: ["url", "username", "password"],
+ expectedParsedLines: [
+ {
+ url: "https://example.com/",
+ username: "testusername",
+ password: "testpassword",
+ },
+ ],
+ delimiter: ",",
+ throwsError: false,
+ },
+ {
+ description:
+ "throws error when after a field, that is enclosed in quotes, follow any invalid characters (doesn't follow csv standard - RFC 4180)",
+ csvString: `
+ url,username,password
+ https://example.com/,"testusername"outside,testpassword
+ `,
+ delimiter: ",",
+ throwsError: true,
+ },
+ {
+ description:
+ "throws error when the closing quotation mark for a field is missing (doesn't follow csv standard - RFC 4180)",
+ csvString: `
+url,"username,password
+https://example.com/,testusername,testpassword
+`,
+ delimiter: ",",
+ throwsError: true,
+ },
+ {
+ description:
+ "parsing empty csv file results in empty header line and empty parsedLines",
+ csvString: "",
+ expectedHeaderLine: [],
+ expectedParsedLines: [],
+ delimiter: ",",
+ throwsError: false,
+ },
+ {
+ description:
+ "parsing csv file with only header line results in empty parsedLines",
+ csvString: "url,username,password\n",
+ expectedHeaderLine: ["url", "username", "password"],
+ expectedParsedLines: [],
+ delimiter: ",",
+ throwsError: false,
+ },
+];
+
+async function parseCSVStringAndValidateResult(test) {
+ info(`Test case: ${test.description}`);
+ if (test.throwsError) {
+ Assert.throws(
+ () => CSV.parse(test.csvString, test.delimiter),
+ /Stopped parsing because of wrong csv format/
+ );
+ } else {
+ let [resultHeaderLine, resultParsedLines] = CSV.parse(
+ test.csvString,
+ test.delimiter
+ );
+ Assert.deepEqual(
+ resultHeaderLine,
+ test.expectedHeaderLine,
+ "Header line check"
+ );
+ Assert.deepEqual(
+ resultParsedLines,
+ test.expectedParsedLines,
+ "Parsed lines check"
+ );
+ }
+}
+
+add_task(function test_csv_parsing_results() {
+ TEST_CASES.forEach(testCase => {
+ parseCSVStringAndValidateResult(testCase);
+ });
+});
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..2e182a7064
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_LoginManagerParent_doAutocompleteSearch.js
@@ -0,0 +1,148 @@
+/**
+ * Test LoginManagerParent.doAutocompleteSearch()
+ */
+
+"use strict";
+
+const { sinon } = ChromeUtils.importESModule(
+ "resource://testing-common/Sinon.sys.mjs"
+);
+const { LoginManagerParent } = ChromeUtils.importESModule(
+ "resource://gre/modules/LoginManagerParent.sys.mjs"
+);
+
+// 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_setup(async () => {
+ Services.prefs.setBoolPref("signon.generation.available", true);
+ Services.prefs.setBoolPref("signon.generation.enabled", true);
+
+ await LoginTestUtils.remoteSettings.setupImprovedPasswordRules();
+
+ sinon
+ .stub(LoginManagerParent._browsingContextGlobal, "get")
+ .withArgs(123)
+ .callsFake(() => {
+ return {
+ currentWindowGlobal: {
+ documentPrincipal:
+ Services.scriptSecurityManager.createContentPrincipalFromOrigin(
+ "https://www.example.com^userContextId=1"
+ ),
+ documentURI: Services.io.newURI("https://www.example.com"),
+ },
+ };
+ });
+});
+
+add_task(async function test_generated_noLogins() {
+ let LMP = new LoginManagerParent();
+ LMP.useBrowsingContext(123);
+
+ Assert.ok(LMP.doAutocompleteSearch, "doAutocompleteSearch exists");
+
+ let result1 = await LMP.doAutocompleteSearch(
+ "https://example.com",
+ NEW_PASSWORD_TEMPLATE_ARG
+ );
+ equal(result1.logins.length, 0, "no logins");
+ Assert.ok(result1.generatedPassword, "has a generated password");
+ equal(result1.generatedPassword.length, 15, "generated password length");
+ Assert.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"
+ );
+ Assert.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);
+
+ Assert.ok(LMP.doAutocompleteSearch, "doAutocompleteSearch exists");
+
+ let result1 = await LMP.doAutocompleteSearch(
+ "https://example.com",
+ NEW_PASSWORD_TEMPLATE_ARG
+ );
+ equal(result1.logins.length, 1, "1 login");
+ Assert.ok(result1.generatedPassword, "has a generated password");
+ equal(result1.generatedPassword.length, 15, "generated password length");
+ Assert.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..72503723b8
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_LoginManagerParent_getGeneratedPassword.js
@@ -0,0 +1,176 @@
+/**
+ * Test LoginManagerParent.getGeneratedPassword()
+ */
+
+"use strict";
+
+const { sinon } = ChromeUtils.importESModule(
+ "resource://testing-common/Sinon.sys.mjs"
+);
+const { LoginManagerParent } = ChromeUtils.importESModule(
+ "resource://gre/modules/LoginManagerParent.sys.mjs"
+);
+
+function simulateNavigationInTheFrame(newOrigin) {
+ LoginManagerParent._browsingContextGlobal.get.restore();
+ sinon
+ .stub(LoginManagerParent._browsingContextGlobal, "get")
+ .withArgs(99)
+ .callsFake(() => {
+ return {
+ currentWindowGlobal: {
+ documentPrincipal:
+ Services.scriptSecurityManager.createContentPrincipalFromOrigin(
+ `https://${newOrigin}^userContextId=2`
+ ),
+ documentURI: Services.io.newURI("https://www.example.com"),
+ },
+ };
+ });
+}
+
+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);
+
+ // Setup the improved rules collection since the improved password rules
+ // pref is on by default. Otherwise any interaciton with LMP.getGeneratedPassword()
+ // will take a long time to complete
+ if (LoginHelper.improvedPasswordRulesEnabled) {
+ await LoginTestUtils.remoteSettings.setupImprovedPasswordRules();
+ }
+
+ let LMP = new LoginManagerParent();
+ LMP.useBrowsingContext(99);
+
+ Assert.ok(LMP.getGeneratedPassword, "LMP.getGeneratedPassword exists");
+ equal(
+ LoginManagerParent.getGeneratedPasswordsByPrincipalOrigin().size,
+ 0,
+ "Empty cache to start"
+ );
+
+ equal(await LMP.getGeneratedPassword(), null, "Null with no BrowsingContext");
+
+ Assert.ok(
+ LoginManagerParent._browsingContextGlobal,
+ "Check _browsingContextGlobal exists"
+ );
+ Assert.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"
+ ),
+ documentURI: Services.io.newURI("https://www.example.com"),
+ },
+ };
+ });
+ Assert.ok(
+ LoginManagerParent._browsingContextGlobal.get(99),
+ "Checking BrowsingContext.get(99) stub"
+ );
+ let password1 = await 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 = await LMP.getGeneratedPassword();
+ equal(
+ password1,
+ password2,
+ "Same password should be returned for the same origin"
+ );
+
+ // Updating autosaved login to have username will reset generated password
+ const autoSavedLogin = await LoginTestUtils.addLogin({
+ origin: "https://www.example.com^userContextId=6",
+ username: "",
+ password: password1,
+ });
+ const updatedLogin = autoSavedLogin.clone();
+ updatedLogin.username = "anyone";
+ await LoginTestUtils.modifyLogin(autoSavedLogin, updatedLogin);
+ password2 = await LMP.getGeneratedPassword();
+ notEqual(
+ password1,
+ password2,
+ "New password should be returned for the same origin after login saved"
+ );
+
+ simulateNavigationInTheFrame("www.mozilla.org");
+ let password3 = await LMP.getGeneratedPassword();
+ notEqual(
+ password2,
+ password3,
+ "Different password for a different origin for the same BC"
+ );
+ equal(
+ password3.length,
+ LoginTestUtils.generation.LENGTH,
+ "Check password3 length"
+ );
+
+ simulateNavigationInTheFrame("bank.biz");
+ let password4 = await LMP.getGeneratedPassword({ inputMaxLength: 5 });
+ notEqual(
+ password4,
+ password2,
+ "Different password for a different origin for the same BC"
+ );
+ notEqual(
+ password4,
+ password3,
+ "Different password for a different origin for the same BC"
+ );
+ equal(password4.length, 5, "password4 length is limited by input.maxLength");
+
+ info("Now checks cases where null should be returned");
+
+ Services.prefs.setBoolPref("signon.rememberSignons", false);
+ equal(
+ await LMP.getGeneratedPassword(),
+ null,
+ "Prevented when pwmgr disabled"
+ );
+ Services.prefs.setBoolPref("signon.rememberSignons", true);
+
+ Services.prefs.setBoolPref("signon.generation.available", false);
+ equal(await LMP.getGeneratedPassword(), null, "Prevented when unavailable");
+ Services.prefs.setBoolPref("signon.generation.available", true);
+
+ Services.prefs.setBoolPref("signon.generation.enabled", false);
+ equal(await LMP.getGeneratedPassword(), null, "Prevented when disabled");
+ Services.prefs.setBoolPref("signon.generation.enabled", true);
+
+ LMP.useBrowsingContext(123);
+ equal(
+ await 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..2573dcd4af
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_LoginManagerParent_onPasswordEditedOrGenerated.js
@@ -0,0 +1,1141 @@
+/**
+ * Test LoginManagerParent._onPasswordEditedOrGenerated()
+ */
+
+"use strict";
+
+const { sinon } = ChromeUtils.importESModule(
+ "resource://testing-common/Sinon.sys.mjs"
+);
+const { LoginManagerParent } = ChromeUtils.importESModule(
+ "resource://gre/modules/LoginManagerParent.sys.mjs"
+);
+const { LoginManagerPrompter } = ChromeUtils.importESModule(
+ "resource://gre/modules/LoginManagerPrompter.sys.mjs"
+);
+
+const { TestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TestUtils.sys.mjs"
+);
+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();
+ Assert.ok(LMP._getPrompter.calledTwice, "Checking _getPrompter stub");
+ Assert.ok(
+ fakePromptToSavePassword.calledOnce,
+ "Checking fakePromptToSavePassword stub"
+ );
+ Assert.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,
+ };
+}
+
+async function stubGeneratedPasswordForBrowsingContextId(id) {
+ Assert.ok(
+ LoginManagerParent._browsingContextGlobal,
+ "Check _browsingContextGlobal exists"
+ );
+ Assert.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"
+ ),
+ documentURI: Services.io.newURI("https://www.example.com"),
+ },
+ 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;
+ },
+ };
+ });
+ Assert.ok(
+ LoginManagerParent._browsingContextGlobal.get(id),
+ `Checking BrowsingContext.get(${id}) stub`
+ );
+
+ const generatedPassword = await LMP.getGeneratedPassword();
+ 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
+ );
+}
+
+async function startTestConditions(contextId) {
+ LMP.useBrowsingContext(contextId);
+
+ Assert.ok(
+ LMP._onPasswordEditedOrGenerated,
+ "LMP._onPasswordEditedOrGenerated exists"
+ );
+ equal(await 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_setup(async () => {
+ // 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);
+ await LoginTestUtils.remoteSettings.setupImprovedPasswordRules();
+});
+
+add_task(async function test_onPasswordEditedOrGenerated_generatedPassword() {
+ await startTestConditions(99);
+ let { generatedPassword } = await 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
+ );
+
+ Assert.ok(login.equals(expected), "Check added login");
+ Assert.ok(LMP._getPrompter.calledOnce, "Checking _getPrompter was called");
+ Assert.ok(
+ fakePromptToChangePassword.calledOnce,
+ "Checking promptToChangePassword was called"
+ );
+ Assert.ok(
+ fakePromptToChangePassword.getCall(0).args[3],
+ "promptToChangePassword had a truthy 'dismissed' argument"
+ );
+ Assert.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"
+ );
+ Assert.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;
+ Assert.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"
+ );
+ Assert.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;
+ Assert.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() {
+ await startTestConditions(99);
+ let { generatedPassword } = await 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
+ );
+
+ Assert.ok(login.equals(expected), "Check added login");
+ Assert.ok(LMP._getPrompter.calledOnce, "Checking _getPrompter was called");
+ Assert.ok(
+ fakePromptToChangePassword.calledOnce,
+ "Checking promptToChangePassword was called"
+ );
+ Assert.ok(
+ fakePromptToChangePassword.getCall(0).args[3],
+ "promptToChangePassword had a truthy 'dismissed' argument"
+ );
+ Assert.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"
+ );
+ Assert.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() {
+ await startTestConditions(99);
+ let { generatedPassword } = await 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
+ );
+
+ Assert.ok(login.equals(expected), "Check added login");
+ Assert.ok(LMP._getPrompter.calledOnce, "Checking _getPrompter was called");
+ Assert.ok(
+ fakePromptToChangePassword.calledOnce,
+ "Checking promptToChangePassword was called"
+ );
+ Assert.ok(
+ fakePromptToChangePassword.getCall(0).args[3],
+ "promptToChangePassword had a truthy 'dismissed' argument"
+ );
+ Assert.ok(
+ fakePromptToChangePassword.getCall(0).args[4],
+ "promptToChangePassword had a truthy 'notifySaved' argument"
+ );
+
+ info("Checking the getNotification stub");
+ Assert.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"
+ );
+ Assert.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);
+ Assert.ok(login.matches(loginWithUsername, false), "Check updated login");
+ equal(
+ Services.logins.getAllLogins().length,
+ 1,
+ "Should have 1 saved login still"
+ );
+
+ Assert.ok(
+ fakePopupNotifications.getNotification.calledOnce,
+ "getNotification was called"
+ );
+ Assert.ok(LMP._getPrompter.calledOnce, "Checking _getPrompter was called");
+ Assert.ok(
+ fakePromptToChangePassword.calledOnce,
+ "Checking promptToChangePassword was called"
+ );
+ Assert.ok(
+ fakePromptToChangePassword.getCall(0).args[3],
+ "promptToChangePassword had a truthy 'dismissed' argument"
+ );
+ // The generated password changed, so we expect notifySaved to be true
+ Assert.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"
+ );
+ Assert.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);
+ Assert.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");
+
+ Assert.ok(
+ fakePromptToChangePassword.calledOnce,
+ "Checking promptToChangePassword was called"
+ );
+ equal(
+ fakePromptToChangePassword.getCall(0).args[2].password,
+ newerPassword,
+ "promptToChangePassword had the updated password"
+ );
+ Assert.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() {
+ await startTestConditions(99);
+ await 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
+ );
+
+ Assert.ok(LMP._getPrompter.calledOnce, "Checking _getPrompter was called");
+ info("Checking the getNotification stub");
+ Assert.ok(
+ !fakePopupNotifications.getNotification.called,
+ "getNotification was not called"
+ );
+ Assert.ok(
+ fakePromptToSavePassword.calledOnce,
+ "Checking promptToSavePassword was called"
+ );
+ Assert.ok(
+ fakePromptToSavePassword.getCall(0).args[2],
+ "promptToSavePassword had a truthy 'dismissed' argument"
+ );
+ Assert.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
+ );
+
+ Assert.ok(LMP._getPrompter.calledOnce, "Checking _getPrompter was called");
+ info("Checking the getNotification stub");
+ Assert.ok(
+ fakePopupNotifications.getNotification.called,
+ "getNotification was called"
+ );
+ Assert.ok(
+ fakePromptToChangePassword.calledOnce,
+ "Checking promptToChangePassword was called"
+ );
+ Assert.ok(
+ fakePromptToChangePassword.getCall(0).args[3],
+ "promptToChangePassword had a truthy 'dismissed' argument"
+ );
+ Assert.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() {
+ await startTestConditions(99);
+ let { generatedPassword } = await 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"
+ );
+ Assert.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() {
+ await 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 } =
+ await 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);
+
+ Assert.ok(LMP._getPrompter.calledOnce, "Checking _getPrompter was called");
+ Assert.ok(
+ fakePromptToChangePassword.calledOnce,
+ "Checking promptToChangePassword was called"
+ );
+ Assert.ok(
+ fakePromptToChangePassword.getCall(0).args[3],
+ "promptToChangePassword had a truthy 'dismissed' argument"
+ );
+ Assert.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"
+ );
+ Assert.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);
+ Assert.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.
+ await 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 } =
+ await 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);
+
+ Assert.ok(LMP._getPrompter.calledOnce, "Checking _getPrompter was called");
+ Assert.ok(
+ fakePromptToChangePassword.notCalled,
+ "Checking promptToChangePassword wasn't called"
+ );
+ Assert.ok(
+ fakePromptToSavePassword.calledOnce,
+ "Checking promptToSavePassword was called"
+ );
+ Assert.ok(
+ fakePromptToSavePassword.getCall(0).args[2],
+ "promptToSavePassword had a truthy 'dismissed' argument"
+ );
+ Assert.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,
+ }
+ );
+ Assert.ok(
+ fakePromptToChangePassword.notCalled,
+ "Checking promptToChangePassword wasn't called"
+ );
+ Assert.ok(
+ fakePromptToSavePassword.calledTwice,
+ "Checking promptToSavePassword was called again"
+ );
+ Assert.ok(
+ fakePromptToSavePassword.getCall(1).args[2],
+ "promptToSavePassword had a truthy 'dismissed' argument"
+ );
+ Assert.ok(
+ !fakePromptToSavePassword.getCall(1).args[3],
+ "promptToSavePassword had a falsey 'notifySaved' argument"
+ );
+
+ let generatedPW =
+ LoginManagerParent.getGeneratedPasswordsByPrincipalOrigin().get(
+ "https://www.example.com^userContextId=6"
+ );
+ Assert.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);
+ Assert.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() {
+ await startTestConditions(99);
+ let login0Props = Object.assign({}, loginTemplate, {
+ username: "",
+ password: "qweqweq",
+ });
+ await LoginTestUtils.addLogin(login0Props);
+
+ let { generatedPassword: password1 } =
+ await 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,
+ })
+ );
+
+ Assert.ok(LMP._getPrompter.calledOnce, "Checking _getPrompter was called");
+ Assert.ok(
+ fakePromptToChangePassword.calledOnce,
+ "Checking promptToChangePassword was called"
+ );
+ Assert.ok(
+ fakePromptToChangePassword.getCall(0).args[2],
+ "promptToChangePassword had a truthy 'dismissed' argument"
+ );
+ Assert.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() {
+ await startTestConditions(99);
+ let login0Props = Object.assign({}, loginTemplate, {
+ username: "previoususer",
+ password: "qweqweq",
+ });
+ await LoginTestUtils.addLogin(login0Props);
+
+ let { generatedPassword: password1 } =
+ await 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,
+ })
+ );
+
+ Assert.ok(LMP._getPrompter.calledOnce, "Checking _getPrompter was called");
+ Assert.ok(
+ fakePromptToChangePassword.calledOnce,
+ "Checking promptToChangePassword was called"
+ );
+ Assert.ok(
+ fakePromptToChangePassword.getCall(0).args[2],
+ "promptToChangePassword had a truthy 'dismissed' argument"
+ );
+ Assert.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..33ee5bd04c
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_LoginManagerParent_searchAndDedupeLogins.js
@@ -0,0 +1,207 @@
+/**
+ * Test LoginManagerParent._searchAndDedupeLogins()
+ */
+
+"use strict";
+
+const { LoginManagerParent: LMP } = ChromeUtils.importESModule(
+ "resource://gre/modules/LoginManagerParent.sys.mjs"
+);
+
+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, 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();
+ }
+});
+
+add_task(async function test_reject_duplicates() {
+ const testcases = [
+ {
+ 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],
+ },
+ {
+ 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],
+ },
+ ];
+
+ for (const tc of testcases) {
+ info(tc.description);
+
+ const result = await Services.logins.addLogins(tc.logins);
+ Assert.equal(result.length, 1, "only single login added");
+
+ 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..d3a94ba08b
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_LoginManagerPrompter_getUsernameSuggestions.js
@@ -0,0 +1,188 @@
+const { LoginManagerPrompter } = ChromeUtils.importESModule(
+ "resource://gre/modules/LoginManagerPrompter.sys.mjs"
+);
+
+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");
+ });
+}
+
+async function _saveLogins(loginDatas) {
+ const logins = loginDatas.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;
+ });
+ await Services.logins.addLogins(logins);
+}
+
+function _compare(expectedArr, actualArr) {
+ Assert.ok(!!expectedArr, "Expect expectedArr to be truthy");
+ Assert.ok(!!actualArr, "Expect actualArr to be truthy");
+ Assert.ok(
+ expectedArr.length == actualArr.length,
+ "Expect expectedArr and actualArr to be the same length"
+ );
+ for (let i = 0; i < expectedArr.length; i++) {
+ const expected = expectedArr[i];
+ const actual = actualArr[i];
+
+ Assert.ok(
+ expected.text == actual.text,
+ `Expect element #${i} text to match. Expected: '${expected.text}', Actual '${actual.text}'`
+ );
+ Assert.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)}`);
+ await _saveLogins(testCase.savedLogins);
+
+ if (!testCase.isLoggedIn) {
+ // Primary Password should be enabled and locked
+ LoginTestUtils.primaryPassword.enable();
+ }
+
+ info("Computing results");
+ const result = await LoginManagerPrompter._getUsernameSuggestions(
+ LOGIN,
+ testCase.possibleUsernames
+ );
+
+ _compare(testCase.expectedSuggestions, result);
+
+ info("Cleaning up state");
+ if (!testCase.isLoggedIn) {
+ LoginTestUtils.primaryPassword.disable();
+ }
+ LoginTestUtils.clearData();
+}
+
+add_task(async function test_LoginManagerPrompter_getUsernameSuggestions() {
+ _setPrefs();
+ for (const 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..9545f5ea02
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_OSCrypto_win.js
@@ -0,0 +1,138 @@
+/**
+ * Tests the OSCrypto object.
+ */
+
+"use strict";
+
+// Globals
+
+ChromeUtils.defineESModuleGetters(this, {
+ OSCrypto: "resource://gre/modules/OSCrypto_win.sys.mjs",
+});
+
+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..a5537a4289
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_PasswordGenerator.js
@@ -0,0 +1,122 @@
+"use strict";
+
+const { PasswordGenerator } = ChromeUtils.importESModule(
+ "resource://gre/modules/PasswordGenerator.sys.mjs"
+);
+
+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"
+ );
+ Assert.ok(
+ Number.isSafeInteger(PasswordGenerator._randomUInt8Index(255)),
+ "Check integer returned"
+ );
+});
+
+add_task(async function test_generatePassword_classes() {
+ let password = PasswordGenerator.generatePassword(
+ /* REQUIRED_CHARACTER_CLASSES */
+
+ { length: 4 }
+ );
+ info(password);
+ equal(password.length, 4, "Check length is correct");
+ Assert.ok(
+ password.match(/[a-km-np-z]/),
+ "Minimal password should include at least one lowercase character"
+ );
+ Assert.ok(
+ password.match(/[A-HJ-NP-Z]/),
+ "Minimal password should include at least one uppercase character"
+ );
+ Assert.ok(
+ password.match(/[2-9]/),
+ "Minimal password should include at least one digit"
+ );
+ Assert.ok(
+ password.match(/[-~!@#$%^&*_+=)}:;"'>,.?\]]/),
+ "Minimal password should include at least one special character"
+ );
+ Assert.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({ length: 5 });
+ info(password);
+ equal(password.length, 5, "Check length is correct");
+
+ password = PasswordGenerator.generatePassword({ length: 3 });
+ equal(password.length, 4, "Minimum generated length is 4");
+
+ password = PasswordGenerator.generatePassword({ length: Math.pow(2, 8) });
+ equal(
+ password.length,
+ Math.pow(2, 8) - 1,
+ "Maximum generated length is Math.pow(2, 8) - 1 "
+ );
+
+ Assert.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");
+ Assert.ok(
+ password.match(/^[a-km-np-zA-HJ-NP-Z2-9-~!@#$%^&*_+=)}:;"'>,.?\]]{15}$/),
+ "All characters should be in the expected set"
+ );
+});
+
+add_task(
+ async function test_generatePassword_immutableDefaultRequiredClasses() {
+ // We need to escape our special characters since some of them
+ // have special meaning in regex.
+ let specialCharacters = PasswordGenerator._getSpecialCharacters();
+ let escapedSpecialCharacters = specialCharacters.replace(
+ /[.*\-+?^${}()|[\]\\]/g,
+ "\\$&"
+ );
+ specialCharacters = new RegExp(`[${escapedSpecialCharacters}]`);
+ let rules = new Map();
+ rules.set("required", ["special"]);
+ let password = PasswordGenerator.generatePassword({ rules });
+ equal(password.length, 15, "Check default length is correct");
+ Assert.ok(
+ password.match(specialCharacters),
+ "Password should include special character."
+ );
+ let allCharacters = new RegExp(
+ `[a-km-np-zA-HJ-NP-Z2-9 ${escapedSpecialCharacters}]{15}`
+ );
+ Assert.ok(
+ password.match(allCharacters),
+ "All characters should be in the expected set"
+ );
+ password = PasswordGenerator.generatePassword({});
+ equal(password.length, 15, "Check default length is correct");
+ Assert.ok(
+ password.match(specialCharacters),
+ "Password should include special character."
+ );
+ Assert.ok(
+ password.match(/^[a-km-np-zA-HJ-NP-Z2-9-~!@#$%^&*_+=)}:;"'>,.?\]]{15}$/),
+ "All characters, minus special characters, should be in the expected set"
+ );
+ }
+);
diff --git a/toolkit/components/passwordmgr/test/unit/test_PasswordRulesManager_generatePassword.js b/toolkit/components/passwordmgr/test/unit/test_PasswordRulesManager_generatePassword.js
new file mode 100644
index 0000000000..88520769cf
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_PasswordRulesManager_generatePassword.js
@@ -0,0 +1,523 @@
+/**
+ * Test PasswordRulesManager.generatePassword()
+ */
+
+"use strict";
+const { PasswordGenerator } = ChromeUtils.importESModule(
+ "resource://gre/modules/PasswordGenerator.sys.mjs"
+);
+const { PasswordRulesManagerParent } = ChromeUtils.importESModule(
+ "resource://gre/modules/PasswordRulesManager.sys.mjs"
+);
+const { PasswordRulesParser } = ChromeUtils.importESModule(
+ "resource://gre/modules/PasswordRulesParser.sys.mjs"
+);
+const { RemoteSettings } = ChromeUtils.importESModule(
+ "resource://services-settings/remote-settings.sys.mjs"
+);
+const { TelemetryTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TelemetryTestUtils.sys.mjs"
+);
+
+Services.prefs.setBoolPref(
+ "toolkit.telemetry.testing.overrideProductsCheck",
+ true
+);
+
+const IMPROVED_RULES_COLLECTION = "password-rules";
+
+function getRulesForRecord(records, baseOrigin) {
+ let rules;
+ for (let record of records) {
+ if (record.Domain === baseOrigin) {
+ rules = record["password-rules"];
+ break;
+ }
+ }
+ return rules;
+}
+
+add_task(async function test_verify_password_rules() {
+ const testCases = [
+ { maxlength: 12 },
+ { minlength: 4, maxlength: 32 },
+ { required: ["lower"] },
+ { required: ["upper"] },
+ { required: ["digit"] },
+ { required: ["special"] },
+ { required: ["*", "$", "@", "_", "B", "Q"] },
+ { required: ["lower", "upper", "special"] },
+ { "max-consecutive": 2 },
+ {
+ minlength: 8,
+ required: [
+ "digit",
+ [
+ "-",
+ " ",
+ "!",
+ '"',
+ "#",
+ "$",
+ "&",
+ "'",
+ "(",
+ ")",
+ "*",
+ "+",
+ ",",
+ ".",
+ ":",
+ ";",
+ "<",
+ "=",
+ ">",
+ "?",
+ "@",
+ "[",
+ "^",
+ "_",
+ "`",
+ "{",
+ "|",
+ "}",
+ "~",
+ "]",
+ ],
+ ],
+ },
+ { minlength: 8, maxlength: 16, required: ["lower", "upper", "digit"] },
+ {
+ minlength: 8,
+ maxlength: 20,
+ required: ["lower", "upper", "digit"],
+ "max-consecutive": 2,
+ },
+ ];
+
+ for (let test of testCases) {
+ let mapOfRules = new Map();
+ let rules = ``;
+ for (let testRules in test) {
+ mapOfRules.set(testRules, test[testRules]);
+ if (test[testRules] === "required" && test[testRules].includes("*$")) {
+ rules += `${testRules}: ${test[testRules].join("")}`;
+ } else {
+ rules += `${testRules}: ${test[testRules]};`;
+ }
+ }
+ let generatedPassword = PasswordGenerator.generatePassword({
+ rules: mapOfRules,
+ });
+ verifyPassword(rules, generatedPassword);
+ }
+});
+
+/**
+ * Note: We do not test the "allowed" property in these tests.
+ * This is because a password can still be valid even if there is not a character from
+ * the "allowed" list.
+ * If a character, or character class, is required, then it should be marked as such.
+ * */
+
+add_task(async function test_generatePassword_many_rules() {
+ // Force password generation to be enabled.
+ Services.prefs.setBoolPref("signon.generation.available", true);
+ Services.prefs.setBoolPref("signon.generation.enabled", true);
+ Services.prefs.setBoolPref("signon.improvedPasswordRules.enabled", true);
+ // TEST_ORIGIN emulates the browsingContext.currentWindowGlobal.documentURI variable in LoginManagerParent
+ // and so it should always be a correctly formed URI when working with
+ // the PasswordRulesParser and PasswordRulesManager modules
+ const TEST_ORIGIN = Services.io.newURI("https://example.com");
+
+ // TEST_BASE_ORIGIN is how each domain is stored in RemoteSettings, and so
+ // we need this in order to parse out the particular password rules we're verifying
+ const TEST_BASE_ORIGIN = "example.com";
+ await LoginTestUtils.remoteSettings.setupImprovedPasswordRules();
+ const records = await RemoteSettings(IMPROVED_RULES_COLLECTION).get();
+
+ let rules = getRulesForRecord(records, TEST_BASE_ORIGIN);
+
+ rules = PasswordRulesParser.parsePasswordRules(rules);
+ Assert.ok(rules.length, "Rules should exist after parsing");
+
+ let PRMP = new PasswordRulesManagerParent();
+ Assert.ok(PRMP.generatePassword, "PRMP.generatePassword exists");
+
+ let generatedPassword = await PRMP.generatePassword(TEST_ORIGIN);
+ Assert.ok(generatedPassword, "A password was generated");
+
+ verifyPassword(rules, generatedPassword);
+
+ await LoginTestUtils.remoteSettings.cleanImprovedPasswordRules();
+});
+
+add_task(async function test_generatePassword_all_characters_allowed() {
+ // Force password generation to be enabled.
+ Services.prefs.setBoolPref("signon.generation.available", true);
+ Services.prefs.setBoolPref("signon.generation.enabled", true);
+ Services.prefs.setBoolPref("signon.improvedPasswordRules.enabled", true);
+ // TEST_ORIGIN emulates the browsingContext.currentWindowGlobal.documentURI variable in LoginManagerParent
+ // and so it should always be a correctly formed URI when working with
+ // the PasswordRulesParser and PasswordRulesManager modules
+ const TEST_ORIGIN = Services.io.newURI("https://example.com");
+
+ // TEST_BASE_ORIGIN is how each domain is stored in RemoteSettings, and so
+ // we need this in order to parse out the particular password rules we're verifying
+ const TEST_BASE_ORIGIN = "example.com";
+ const TEST_RULES = "minlength: 6; maxlength: 12;";
+ await LoginTestUtils.remoteSettings.setupImprovedPasswordRules(
+ TEST_BASE_ORIGIN,
+ TEST_RULES
+ );
+ const records = await RemoteSettings(IMPROVED_RULES_COLLECTION).get();
+
+ let rules = getRulesForRecord(records, TEST_BASE_ORIGIN);
+
+ rules = PasswordRulesParser.parsePasswordRules(rules);
+ Assert.ok(rules.length, "Rules should exist after parsing");
+
+ let PRMP = new PasswordRulesManagerParent();
+ Assert.ok(PRMP.generatePassword, "PRMP.generatePassword exists");
+
+ let generatedPassword = await PRMP.generatePassword(TEST_ORIGIN);
+ Assert.ok(generatedPassword, "A password was generated");
+
+ verifyPassword(rules, generatedPassword);
+
+ await LoginTestUtils.remoteSettings.cleanImprovedPasswordRules();
+});
+
+add_task(async function test_generatePassword_required_special_character() {
+ // Force password generation to be enabled.
+ Services.prefs.setBoolPref("signon.generation.available", true);
+ Services.prefs.setBoolPref("signon.generation.enabled", true);
+ Services.prefs.setBoolPref("signon.improvedPasswordRules.enabled", true);
+ // TEST_ORIGIN emulates the browsingContext.currentWindowGlobal.documentURI variable in LoginManagerParent
+ // and so it should always be a correctly formed URI when working with
+ // the PasswordRulesParser and PasswordRulesManager modules
+ const TEST_ORIGIN = Services.io.newURI("https://example.com");
+
+ // TEST_BASE_ORIGIN is how each domain is stored in RemoteSettings, and so
+ // we need this in order to parse out the particular password rules we're verifying
+ const TEST_BASE_ORIGIN = "example.com";
+ const TEST_RULES = "required: special";
+ await LoginTestUtils.remoteSettings.setupImprovedPasswordRules(
+ TEST_BASE_ORIGIN,
+ TEST_RULES
+ );
+ const records = await RemoteSettings(IMPROVED_RULES_COLLECTION).get();
+
+ let rules = getRulesForRecord(records, TEST_BASE_ORIGIN);
+
+ rules = PasswordRulesParser.parsePasswordRules(rules);
+ Assert.ok(rules.length, "Rules should exist after parsing");
+
+ let PRMP = new PasswordRulesManagerParent();
+ Assert.ok(PRMP.generatePassword, "PRMP.generatePassword exists");
+
+ let generatedPassword = await PRMP.generatePassword(TEST_ORIGIN);
+ Assert.ok(generatedPassword, "A password was generated");
+
+ verifyPassword(TEST_RULES, generatedPassword);
+});
+
+add_task(
+ async function test_generatePassword_with_arbitrary_required_characters() {
+ // Force password generation to be enabled.
+ Services.prefs.setBoolPref("signon.generation.available", true);
+ Services.prefs.setBoolPref("signon.generation.enabled", true);
+ Services.prefs.setBoolPref("signon.improvedPasswordRules.enabled", true);
+ // TEST_ORIGIN emulates the browsingContext.currentWindowGlobal.documentURI variable in LoginManagerParent
+ // and so it should always be a correctly formed URI when working with
+ // the PasswordRulesParser and PasswordRulesManager modules
+ const TEST_ORIGIN = Services.io.newURI("https://example.com");
+
+ // TEST_BASE_ORIGIN is how each domain is stored in RemoteSettings, and so
+ // we need this in order to parse out the particular password rules we're verifying
+ const TEST_BASE_ORIGIN = "example.com";
+ const REQUIRED_ARBITRARY_CHARACTERS = "!#$@*()_+=";
+ // We use an extremely long password to ensure there are no invalid characters generated in the password.
+ // This ensures we exhaust all of "allRequiredCharacters" in PasswordGenerator.jsm.
+ // Otherwise, there's a small chance a "," may have been added to "allRequiredCharacters"
+ // which will generate an invalid password in this case.
+ const TEST_RULES = `required: [${REQUIRED_ARBITRARY_CHARACTERS}], upper, lower; maxlength: 255; minlength: 255;`;
+ await LoginTestUtils.remoteSettings.setupImprovedPasswordRules(
+ TEST_BASE_ORIGIN,
+ TEST_RULES
+ );
+ const records = await RemoteSettings(IMPROVED_RULES_COLLECTION).get();
+
+ let rules = getRulesForRecord(records, TEST_BASE_ORIGIN);
+
+ rules = PasswordRulesParser.parsePasswordRules(rules);
+ Assert.ok(rules.length, "Rules should exist after parsing");
+
+ let PRMP = new PasswordRulesManagerParent();
+ Assert.ok(PRMP.generatePassword, "PRMP.generatePassword exists");
+
+ let generatedPassword = await PRMP.generatePassword(TEST_ORIGIN);
+ generatedPassword = await PRMP.generatePassword(TEST_ORIGIN);
+ Assert.ok(generatedPassword, "A password was generated");
+
+ verifyPassword(TEST_RULES, generatedPassword);
+
+ let specialCharacters = PasswordGenerator._getSpecialCharacters();
+ let digits = PasswordGenerator._getDigits();
+ // Additional verification for this password case since
+ // we want to ensure no extra special characters and no digits are generated.
+ let disallowedSpecialCharacters = "";
+ for (let char of specialCharacters) {
+ if (!REQUIRED_ARBITRARY_CHARACTERS.includes(char)) {
+ disallowedSpecialCharacters += char;
+ }
+ }
+ for (let char of disallowedSpecialCharacters) {
+ Assert.ok(
+ !generatedPassword.includes(char),
+ "Password must not contain any disallowed special characters: " + char
+ );
+ }
+ for (let char of digits) {
+ Assert.ok(
+ !generatedPassword.includes(char),
+ "Password must not contain any digits: " + char
+ );
+ }
+ }
+);
+
+// Checks the "www4.prepaid.bankofamerica.com" case to ensure the rules are found
+add_task(async function test_generatePassword_subdomain_rule() {
+ const testCases = [
+ {
+ uri: "https://www4.test.example.com",
+ rulesDomain: "example.com",
+ shouldApplyPWRule: true,
+ },
+ {
+ uri: "https://test.example.com",
+ rulesDomain: "example.com",
+ shouldApplyPWRule: true,
+ },
+ {
+ uri: "https://example.com",
+ rulesDomain: "example.com",
+ shouldApplyPWRule: true,
+ },
+ {
+ uri: "https://www4.test.example.com",
+ rulesDomain: "test.example.com",
+ shouldApplyPWRule: true,
+ },
+ {
+ uri: "https://test.example.com",
+ rulesDomain: "test.example.com",
+ shouldApplyPWRule: true,
+ },
+ {
+ uri: "https://example.com",
+ rulesDomain: "test.example.com",
+ shouldApplyPWRule: false,
+ },
+ {
+ uri: "https://evil.com",
+ rulesDomain: "example.com",
+ shouldApplyPWRule: false,
+ },
+ {
+ uri: "https://evil.example.com",
+ rulesDomain: "test.example.com",
+ shouldApplyPWRule: false,
+ },
+ {
+ uri: "https://test.example.com.cn",
+ rulesDomain: "test.example.com",
+ shouldApplyPWRule: false,
+ },
+ {
+ uri: "https://eviltest.example.com",
+ rulesDomain: "test.example.com",
+ shouldApplyPWRule: false,
+ },
+ ];
+ const TEST_RULES = "required: special; maxlength: 12;";
+
+ for (let test of testCases) {
+ await LoginTestUtils.remoteSettings.setupImprovedPasswordRules(
+ test.rulesDomain,
+ TEST_RULES
+ );
+ const TEST_ORIGIN = Services.io.newURI(test.uri);
+ const TEST_BASE_ORIGIN = test.rulesDomain;
+ const records = await RemoteSettings(IMPROVED_RULES_COLLECTION).get();
+
+ let rules = getRulesForRecord(records, TEST_BASE_ORIGIN);
+
+ rules = PasswordRulesParser.parsePasswordRules(rules);
+ Assert.ok(rules.length, "Rules should exist after parsing");
+
+ let PRMP = new PasswordRulesManagerParent();
+
+ let generatedPassword = await PRMP.generatePassword(TEST_ORIGIN);
+ Assert.ok(
+ generatedPassword,
+ "A password was generated for URI: " + test.uri
+ );
+
+ // If a rule should be applied, we verify the password has all the required classes in the generated password.
+ if (test.shouldApplyPWRule) {
+ verifyPassword(rules, generatedPassword);
+ }
+ }
+});
+
+add_task(async function test_improved_password_rules_telemetry() {
+ // Force password generation to be enabled.
+ Services.prefs.setBoolPref("signon.generation.available", true);
+ Services.prefs.setBoolPref("signon.generation.enabled", true);
+ Services.prefs.setBoolPref("signon.improvedPasswordRules.enabled", true);
+
+ const IMPROVED_PASSWORD_GENERATION_HISTOGRAM =
+ "PWMGR_NUM_IMPROVED_GENERATED_PASSWORDS";
+
+ // Clear out the previous pings from this test
+ let snapshot = TelemetryTestUtils.getAndClearHistogram(
+ IMPROVED_PASSWORD_GENERATION_HISTOGRAM
+ );
+
+ // TEST_ORIGIN emulates the browsingContext.currentWindowGlobal.documentURI variable in LoginManagerParent
+ // and so it should always be a correctly formed URI when working with
+ // the PasswordRulesParser and PasswordRulesManager modules
+ let TEST_ORIGIN = Services.io.newURI("https://example.com");
+ await LoginTestUtils.remoteSettings.setupImprovedPasswordRules();
+
+ let PRMP = new PasswordRulesManagerParent();
+
+ // Generate a password with custom rules,
+ // so we should send a ping to the custom rules bucket (position 1).
+ let generatedPassword = await PRMP.generatePassword(TEST_ORIGIN);
+ Assert.ok(generatedPassword, "A password was generated");
+
+ TelemetryTestUtils.assertHistogram(snapshot, 1, 1);
+
+ TEST_ORIGIN = Services.io.newURI("https://otherexample.com");
+ // Generate a password with default rules,
+ // so we should send a ping to the default rules bucket (position 0).
+ snapshot = TelemetryTestUtils.getAndClearHistogram(
+ IMPROVED_PASSWORD_GENERATION_HISTOGRAM
+ );
+ generatedPassword = await PRMP.generatePassword(TEST_ORIGIN);
+ Assert.ok(generatedPassword, "A password was generated");
+
+ TelemetryTestUtils.assertHistogram(snapshot, 0, 1);
+});
+
+function checkCharacters(password, _characters) {
+ let containsCharacters = false;
+ let testString = _characters.join("");
+ for (let character of password) {
+ containsCharacters = testString.includes(character);
+ if (containsCharacters) {
+ return containsCharacters;
+ }
+ }
+ return containsCharacters;
+}
+
+function checkConsecutiveCharacters(generatePassword, value) {
+ let findMaximumRepeating = str => {
+ let max = 0;
+ for (let start = 0, end = 1; end < str.length; ) {
+ if (str[end] === str[start]) {
+ if (max < end - start + 1) {
+ max = end - start + 1;
+ if (max > value) {
+ return max;
+ }
+ }
+ end++;
+ } else {
+ start = end++;
+ }
+ }
+ return max;
+ };
+ let consecutiveCharacters = findMaximumRepeating(generatePassword);
+ if (consecutiveCharacters <= value) {
+ return true;
+ }
+ return false;
+}
+
+function verifyPassword(rules, generatedPassword) {
+ const UPPER_CASE_ALPHA = PasswordGenerator._getUpperCaseCharacters();
+ const LOWER_CASE_ALPHA = PasswordGenerator._getLowerCaseCharacters();
+ const DIGITS = PasswordGenerator._getDigits();
+ const SPECIAL_CHARACTERS = PasswordGenerator._getSpecialCharacters();
+ for (let rule of rules) {
+ let { _name, value } = rule;
+ if (_name === "required") {
+ for (let required of value) {
+ if (required._name === "upper") {
+ let _checkUppercase = new RegExp(`[${UPPER_CASE_ALPHA}]`);
+ Assert.ok(
+ generatedPassword.match(_checkUppercase),
+ "Password must include upper case letter"
+ );
+ } else if (required._name === "lower") {
+ let _checkLowercase = new RegExp(`[${LOWER_CASE_ALPHA}]`);
+ Assert.ok(
+ generatedPassword.match(_checkLowercase),
+ "Password must include lower case letter"
+ );
+ } else if (required._name === "digit") {
+ let _checkDigits = new RegExp(`[${DIGITS}]`);
+ Assert.ok(
+ generatedPassword.match(_checkDigits),
+ "Password must include digits"
+ );
+ generatedPassword.match(_checkDigits);
+ } else if (required._name === "special") {
+ // We need to escape our special characters since some of them
+ // have special meaning in regex.
+ let escapedSpecialCharacters = SPECIAL_CHARACTERS.replace(
+ /[.*\-+?^${}()|[\]\\]/g,
+ "\\$&"
+ );
+ let _checkSpecial = new RegExp(`[${escapedSpecialCharacters}]`);
+ Assert.ok(
+ generatedPassword.match(_checkSpecial),
+ "Password must include special character"
+ );
+ } else {
+ // Nested destructing of the value object in the characters case
+ let [{ _characters }] = value;
+
+ // We can't use regex to do a quick check here since the
+ // required characters could be characters that need to be escaped
+ // in order for the regex to work properly ([]"^...etc)
+ Assert.ok(
+ checkCharacters(generatedPassword, _characters),
+ `Password must contain one of the following characters: ${_characters}`
+ );
+ }
+ }
+ } else if (_name === "minlength") {
+ Assert.ok(
+ generatedPassword.length >= value,
+ `Password should have a minimum length of ${value}`
+ );
+ } else if (_name === "maxlength") {
+ Assert.ok(
+ generatedPassword.length <= value,
+ `Password should have a maximum length of ${value}`
+ );
+ } else if (_name === "max-consecutive") {
+ Assert.ok(
+ checkConsecutiveCharacters(generatedPassword, value),
+ `Password must not contain more than ${value} consecutive characters`
+ );
+ }
+ }
+}
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..56d4620338
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_context_menu.js
@@ -0,0 +1,345 @@
+/**
+ * Test the password manager context menu.
+ */
+
+"use strict";
+
+const { LoginManagerContextMenu } = ChromeUtils.importESModule(
+ "resource://gre/modules/LoginManagerContextMenu.sys.mjs"
+);
+
+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>";
+
+ await Services.logins.addLogins(savedLogins);
+
+ // 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..f0305e3c69
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_dedupeLogins.js
@@ -0,0 +1,411 @@
+/**
+ * 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 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_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..a42f066b3e
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_displayOrigin.js
@@ -0,0 +1,43 @@
+/* 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("file://")) {
+ // Fails to create the URL
+ 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_findRelatedRealms.js b/toolkit/components/passwordmgr/test/unit/test_findRelatedRealms.js
new file mode 100644
index 0000000000..b79a0ab4c4
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_findRelatedRealms.js
@@ -0,0 +1,156 @@
+/* Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { LoginRelatedRealmsParent } = ChromeUtils.importESModule(
+ "resource://gre/modules/LoginRelatedRealms.sys.mjs"
+);
+const { RemoteSettings } = ChromeUtils.importESModule(
+ "resource://services-settings/remote-settings.sys.mjs"
+);
+
+const REMOTE_SETTINGS_COLLECTION = "websites-with-shared-credential-backends";
+
+add_task(async function test_related_domain_matching() {
+ const client = RemoteSettings(REMOTE_SETTINGS_COLLECTION);
+ const records = await client.get();
+ console.log(records);
+
+ // Assumes that the test collection is a 2D array with one subarray
+ let relatedRealms = records[0].relatedRealms;
+ relatedRealms = relatedRealms.flat();
+ Assert.ok(relatedRealms);
+
+ let LRR = new LoginRelatedRealmsParent();
+
+ // We should not return unrelated realms
+ let result = await LRR.findRelatedRealms("https://not-example.com");
+ equal(result.length, 0, "Check that there were no related realms found");
+
+ // We should not return unrelated realms given an unrelated subdomain
+ result = await LRR.findRelatedRealms("https://sub.not-example.com");
+ equal(result.length, 0, "Check that there were no related realms found");
+ // We should return the related realms collection
+ result = await LRR.findRelatedRealms("https://sub.example.com");
+ equal(
+ result.length,
+ relatedRealms.length,
+ "Ensure that three related realms were found"
+ );
+
+ // We should return the related realms collection minus the base domain that we searched with
+ result = await LRR.findRelatedRealms("https://example.co.uk");
+ equal(
+ result.length,
+ relatedRealms.length - 1,
+ "Ensure that two related realms were found"
+ );
+});
+
+add_task(async function test_newly_synced_collection() {
+ // Initialize LoginRelatedRealmsParent so the sync handler is enabled
+ let LRR = new LoginRelatedRealmsParent();
+ await LRR.getSharedCredentialsCollection();
+
+ const client = RemoteSettings(REMOTE_SETTINGS_COLLECTION);
+ let records = await client.get();
+ const record1 = {
+ id: records[0].id,
+ relatedRealms: records[0].relatedRealms,
+ };
+
+ // Assumes that the test collection is a 2D array with one subarray
+ let originalRelatedRealms = records[0].relatedRealms;
+ originalRelatedRealms = originalRelatedRealms.flat();
+ Assert.ok(originalRelatedRealms);
+
+ const updatedRelatedRealms = ["completely-different.com", "example.com"];
+ const record2 = {
+ id: "some-other-ID",
+ relatedRealms: [updatedRelatedRealms],
+ };
+ const payload = {
+ current: [record2],
+ created: [record2],
+ updated: [],
+ deleted: [record1],
+ };
+ await RemoteSettings(REMOTE_SETTINGS_COLLECTION).emit("sync", {
+ data: payload,
+ });
+
+ let [{ id, relatedRealms }] = await LRR.getSharedCredentialsCollection();
+ equal(id, record2.id, "internal collection ID should be updated");
+ equal(
+ relatedRealms,
+ record2.relatedRealms,
+ "internal collection related realms should be updated"
+ );
+
+ // We should return only one result, and that result should be example.com
+ // NOT other-example.com or example.co.uk
+ let result = await LRR.findRelatedRealms("https://completely-different.com");
+ equal(
+ result.length,
+ updatedRelatedRealms.length - 1,
+ "Check that there is only one related realm found"
+ );
+ equal(
+ result[0],
+ "example.com",
+ "Ensure that the updated collection should only match example.com"
+ );
+});
+
+add_task(async function test_no_related_domains() {
+ await LoginTestUtils.remoteSettings.cleanWebsitesWithSharedCredentials();
+
+ const client = RemoteSettings(REMOTE_SETTINGS_COLLECTION);
+ let records = await client.get();
+
+ equal(records.length, 0, "Check that there are no related realms");
+
+ let LRR = new LoginRelatedRealmsParent();
+
+ Assert.ok(LRR.findRelatedRealms, "Ensure findRelatedRealms exists");
+
+ let result = await LRR.findRelatedRealms("https://example.com");
+ equal(result.length, 0, "Assert that there were no related realms found");
+});
+
+add_task(async function test_unrelated_subdomains() {
+ await LoginTestUtils.remoteSettings.cleanWebsitesWithSharedCredentials();
+ let testCollection = [
+ ["slpl.bibliocommons.com", "slpl.overdrive.com"],
+ ["springfield.overdrive.com", "coolcat.org"],
+ ];
+ await LoginTestUtils.remoteSettings.setupWebsitesWithSharedCredentials(
+ testCollection
+ );
+
+ let LRR = new LoginRelatedRealmsParent();
+ let result = await LRR.findRelatedRealms("https://evil.overdrive.com");
+ equal(result.length, 0, "Assert that there were no related realms found");
+
+ result = await LRR.findRelatedRealms("https://abc.slpl.bibliocommons.com");
+ equal(result.length, 2, "Assert that two related realms were found");
+ equal(result[0], testCollection[0][0]);
+ equal(result[1], testCollection[0][1]);
+
+ result = await LRR.findRelatedRealms("https://slpl.overdrive.com");
+ console.log("what is result: " + result);
+ equal(result.length, 1, "Assert that one related realm was found");
+ for (let item of result) {
+ notEqual(
+ item,
+ "coolcat.org",
+ "coolcat.org is not related to slpl.overdrive.com"
+ );
+ notEqual(
+ item,
+ "springfield.overdrive.com",
+ "springfield.overdrive.com is not related to slpl.overdrive.com"
+ );
+ }
+});
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..a04b497181
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_getFormFields.js
@@ -0,0 +1,572 @@
+/**
+ * Test for LoginFormState._getFormFields.
+ */
+
+"use strict";
+
+const { LoginFormFactory } = ChromeUtils.importESModule(
+ "resource://gre/modules/LoginFormFactory.sys.mjs"
+);
+
+const { LoginManagerChild } = ChromeUtils.importESModule(
+ "resource://gre/modules/LoginManagerChild.sys.mjs"
+);
+
+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],
+ extraTestPreferences: [],
+ },
+ {
+ 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: [],
+ extraTestPreferences: [],
+ },
+ {
+ 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],
+ extraTestPreferences: [],
+ },
+ {
+ 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],
+ extraTestPreferences: [],
+ },
+ {
+ 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],
+ extraTestPreferences: [],
+ },
+ {
+ 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],
+ extraTestPreferences: [],
+ },
+ {
+ 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],
+ extraTestPreferences: [],
+ },
+ {
+ 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: [],
+ extraTestPreferences: [],
+ },
+ {
+ 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],
+ extraTestPreferences: [],
+ },
+ {
+ 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],
+ extraTestPreferences: [],
+ },
+ {
+ 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],
+ extraTestPreferences: [],
+ },
+ {
+ 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],
+ extraTestPreferences: [],
+ },
+ {
+ 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],
+ extraTestPreferences: [],
+ },
+ {
+ 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],
+ extraTestPreferences: [],
+ },
+ {
+ 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],
+ extraTestPreferences: [],
+ },
+ {
+ 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],
+ extraTestPreferences: [],
+ },
+ {
+ 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: [],
+ extraTestPreferences: [],
+ },
+ // begin of getusername heuristic tests
+ {
+ description: "multiple non-username like input fields in a <form>",
+ document: `<form>
+ <input id="un1">
+ <input id="un2">
+ <input id="un3">
+ <input id="pw1" type=password>
+ </form>`,
+ returnedFieldIDs: {
+ usernameField: "un3",
+ newPasswordField: "pw1",
+ oldPasswordField: null,
+ },
+ skipEmptyFields: undefined,
+ extraTestEnvironments: [TESTENVIRONMENTS.filledPW1WithGeneratedPassword],
+ extraTestPreferences: [],
+ },
+ {
+ description:
+ "1 username input and multiple non-username like input in a <form>",
+ document: `<form>
+ <input id="un1">
+ <input id="un2" autocomplete="username">
+ <input id="un3">
+ <input id="pw1" type=password>
+ </form>`,
+ returnedFieldIDs: {
+ usernameField: "un2",
+ newPasswordField: "pw1",
+ oldPasswordField: null,
+ },
+ skipEmptyFields: undefined,
+ extraTestEnvironments: [TESTENVIRONMENTS.filledPW1WithGeneratedPassword],
+ extraTestPreferences: [],
+ },
+ {
+ description:
+ "1 email input and multiple non-username like input in a <form>",
+ document: `<form>
+ <input id="un1">
+ <input id="un2" autocomplete="email">
+ <input id="un3">
+ <input id="pw1" type=password>
+ </form>`,
+ returnedFieldIDs: {
+ usernameField: "un2",
+ newPasswordField: "pw1",
+ oldPasswordField: null,
+ },
+ skipEmptyFields: undefined,
+ extraTestEnvironments: [TESTENVIRONMENTS.filledPW1WithGeneratedPassword],
+ extraTestPreferences: [],
+ },
+ {
+ description:
+ "1 username & 1 email field, the email field is more close to the password",
+ document: `<form>
+ <input id="un1" autocomplete="username">
+ <input id="un2" autocomplete="email">
+ <input id="pw1" type=password>
+ </form>`,
+ returnedFieldIDs: {
+ usernameField: "un1",
+ newPasswordField: "pw1",
+ oldPasswordField: null,
+ },
+ skipEmptyFields: undefined,
+ extraTestEnvironments: [TESTENVIRONMENTS.filledPW1WithGeneratedPassword],
+ extraTestPreferences: [],
+ },
+ {
+ description:
+ "1 username and 1 email field, the username field is more close to the password",
+ document: `<form>
+ <input id="un1" autocomplete="email">
+ <input id="un2" autocomplete="username">
+ <input id="pw1" type=password>
+ </form>`,
+ returnedFieldIDs: {
+ usernameField: "un2",
+ newPasswordField: "pw1",
+ oldPasswordField: null,
+ },
+ skipEmptyFields: undefined,
+ extraTestEnvironments: [TESTENVIRONMENTS.filledPW1WithGeneratedPassword],
+ extraTestPreferences: [],
+ },
+ {
+ description: "2 username fields in a <form>",
+ document: `<form>
+ <input id="un1" autocomplete="username">
+ <input id="un2" autocomplete="username">
+ <input id="un3">
+ <input id="pw1" type=password>
+ </form>`,
+ returnedFieldIDs: {
+ usernameField: "un2",
+ newPasswordField: "pw1",
+ oldPasswordField: null,
+ },
+ skipEmptyFields: undefined,
+ extraTestEnvironments: [TESTENVIRONMENTS.filledPW1WithGeneratedPassword],
+ extraTestPreferences: [],
+ },
+ {
+ description: "2 email fields in a <form>",
+ document: `<form>
+ <input id="un1" autocomplete="email">
+ <input id="un2" autocomplete="email">
+ <input id="un3">
+ <input id="pw1" type=password>
+ </form>`,
+ returnedFieldIDs: {
+ usernameField: "un1",
+ newPasswordField: "pw1",
+ oldPasswordField: null,
+ },
+ skipEmptyFields: undefined,
+ extraTestEnvironments: [TESTENVIRONMENTS.filledPW1WithGeneratedPassword],
+ extraTestPreferences: [],
+ },
+ {
+ description: "the password field precedes the username field",
+ document: `<form>
+ <input id="un1">
+ <input id="pw1" type=password>
+ <input id="un2" autocomplete="username">
+ </form>`,
+ returnedFieldIDs: {
+ usernameField: "un1",
+ newPasswordField: "pw1",
+ oldPasswordField: null,
+ },
+ skipEmptyFields: undefined,
+ extraTestEnvironments: [TESTENVIRONMENTS.filledPW1WithGeneratedPassword],
+ extraTestPreferences: [],
+ },
+ // end of getusername heuristic tests
+ {
+ description: "1 username field in a <form>",
+ document: `<form>
+ <input id="un1" autocomplete="username">
+ </form>`,
+ returnedFieldIDs: {
+ usernameField: "un1",
+ newPasswordField: null,
+ oldPasswordField: null,
+ },
+ skipEmptyFields: undefined,
+ extraTestEnvironments: [],
+ extraTestPreferences: [],
+ },
+ {
+ description: "1 input field in a <form>",
+ document: `<form>
+ <input id="un1"">
+ </form>`,
+ returnedFieldIDs: {
+ usernameField: null,
+ newPasswordField: null,
+ oldPasswordField: null,
+ },
+ skipEmptyFields: undefined,
+ extraTestEnvironments: [],
+ extraTestPreferences: [],
+ },
+ {
+ description: "1 username field in a <form> with usernameOnlyForm pref off",
+ document: `<form>
+ <input id="un1" autocomplete="username">
+ </form>`,
+ returnedFieldIDs: {
+ usernameField: null,
+ newPasswordField: null,
+ oldPasswordField: null,
+ },
+ skipEmptyFields: undefined,
+ extraTestEnvironments: [],
+ extraTestPreferences: [["signon.usernameOnlyForm.enabled", false]],
+ },
+];
+
+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;
+});
+
+function _setPrefs() {
+ Services.prefs.setBoolPref("signon.usernameOnlyForm.enabled", true);
+ registerCleanupFunction(() => {
+ Services.prefs.clearUserPref("signon.usernameOnlyForm.enabled");
+ });
+}
+
+_setPrefs();
+
+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);
+
+ for (let pref of testcase.extraTestPreferences) {
+ Services.prefs.setBoolPref(pref[0], pref[1]);
+ }
+
+ 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 = loginFormState._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"
+ );
+ }
+ }
+
+ for (let pref of tc.extraTestPreferences) {
+ Services.prefs.clearUserPref(pref[0]);
+ }
+ });
+ })();
+}
+
+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..eee38c30d8
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_getPasswordFields.js
@@ -0,0 +1,308 @@
+/**
+ * Test for LoginFormState._getPasswordFields using LoginFormFactory.
+ */
+
+/* globals todo_check_eq */
+"use strict";
+
+const { LoginFormFactory } = ChromeUtils.importESModule(
+ "resource://gre/modules/LoginFormFactory.sys.mjs"
+);
+const { LoginFormState } = ChromeUtils.importESModule(
+ "resource://gre/modules/LoginManagerChild.sys.mjs"
+);
+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 = LoginFormState._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 = LoginFormState._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..9df18d05cc
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_getPasswordOrigin.js
@@ -0,0 +1,35 @@
+/**
+ * Test for LoginHelper.getLoginOrigin
+ */
+
+"use strict";
+
+const TESTCASES = [
+ ["javascript:void(0);", null],
+ ["javascript:void(0);", "javascript:", true],
+ ["chrome://MyAccount", "chrome://myaccount"],
+ ["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"],
+ [
+ "moz-proxy://username:password@123.456.789.123:12345/foo",
+ "moz-proxy://123.456.789.123:12345",
+ ],
+];
+
+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..86aae5d14c
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_getUserNameAndPasswordFields.js
@@ -0,0 +1,177 @@
+/**
+ * Test for LoginFormState.getUserNameAndPasswordFields
+ */
+
+"use strict";
+
+const { LoginManagerChild } = ChromeUtils.importESModule(
+ "resource://gre/modules/LoginManagerChild.sys.mjs"
+);
+const TESTCASES = [
+ {
+ description: "1 password field outside of a <form>",
+ document: `<input id="pw1" type=password>`,
+ returnedFieldIDs: [null, "pw1", null],
+ },
+ {
+ description: "1 text field in a <form> without a password field",
+ document: `<form>
+ <input id="un1">
+ </form>`,
+ returnedFieldIDs: [null, null, 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],
+ },
+ {
+ description: "1 username field in a <form>",
+ document: `<form>
+ <input id="un1" autocomplete=username>
+ </form>`,
+ returnedFieldIDs: ["un1", null, null],
+ },
+ {
+ description: "1 username field outside of a <form>",
+ document: `<input id="un1" autocomplete=username>`,
+ returnedFieldIDs: [null, null, null],
+ },
+];
+
+function _setPrefs() {
+ Services.prefs.setBoolPref("signon.usernameOnlyForm.enabled", true);
+ registerCleanupFunction(() => {
+ Services.prefs.clearUserPref("signon.usernameOnlyForm.enabled");
+ });
+}
+
+_setPrefs();
+
+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());
+
+ const loginManagerChild = new LoginManagerChild();
+ const docState = loginManagerChild.stateForDocument(document);
+ let actual = docState.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_getUsernameFieldFromUsernameOnlyForm.js b/toolkit/components/passwordmgr/test/unit/test_getUsernameFieldFromUsernameOnlyForm.js
new file mode 100644
index 0000000000..1af2bb889c
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_getUsernameFieldFromUsernameOnlyForm.js
@@ -0,0 +1,179 @@
+/**
+ * Test for LoginFormState.getUsernameFieldFromUsernameOnlyForm
+ */
+
+"use strict";
+
+const { LoginManagerChild } = ChromeUtils.importESModule(
+ "resource://gre/modules/LoginManagerChild.sys.mjs"
+);
+
+// expectation[0] tests cases when a form doesn't have a sign-in keyword.
+// expectation[1] tests cases when a form has a sign-in keyword.
+const TESTCASES = [
+ {
+ description: "1 text input field",
+ document: `<form>
+ <input id="un1" type="text">
+ </form>`,
+ expectations: [false, true],
+ },
+ {
+ description: "1 text input field & 1 hidden input fields",
+ document: `<form>
+ <input id="un1" type="text">
+ <input id="un2" type="hidden">
+ </form>`,
+ expectations: [false, true],
+ },
+ {
+ description: "1 username field",
+ document: `<form>
+ <input id="un1" autocomplete="username">
+ </form>`,
+ expectations: [true, true],
+ },
+ {
+ description: "1 username field & 1 hidden input fields",
+ document: `<form>
+ <input id="un1" autocomplete="username">
+ <input id="un2" type="hidden">
+ </form>`,
+ expectations: [true, true],
+ },
+ {
+ description: "1 username field, 1 hidden input field, & 1 password field",
+ document: `<form>
+ <input id="un1" autocomplete="username">
+ <input id="un2" type="hidden">
+ <input id="pw1" type=password>
+ </form>`,
+ expectations: [false, false],
+ },
+ {
+ description: "1 password field",
+ document: `<form>
+ <input id="pw1" type=password>
+ </form>`,
+ expectations: [false, false],
+ },
+ {
+ description: "1 username & password field",
+ document: `<form>
+ <input id="un1" autocomplete="username">
+ <input id="pw1" type=password>
+ </form>`,
+ expectations: [false, false],
+ },
+ {
+ description: "1 username & text field",
+ document: `<form>
+ <input id="un1" autocomplete="username">
+ <input id="un2" type="text">
+ </form>`,
+ expectations: [false, false],
+ },
+ {
+ description: "2 text input fields",
+ document: `<form>
+ <input id="un1" type="text">
+ <input id="un2" type="text">
+ </form>`,
+ expectations: [false, false],
+ },
+ {
+ description: "2 username fields",
+ document: `<form>
+ <input id="un1" autocomplete="username">
+ <input id="un2" autocomplete="username">
+ </form>`,
+ expectations: [false, false],
+ },
+ {
+ description: "1 username field with search keyword",
+ document: `<form>
+ <input id="un1" autocomplete="username" placeholder="search by username">
+ </form>`,
+ expectations: [false, false],
+ },
+ {
+ description: "1 text input field with code keyword",
+ document: `<form>
+ <input id="un1" type="text" placeholder="enter your 6-digit code">
+ </form>`,
+ expectations: [false, false],
+ },
+ {
+ description: "Form with only a hidden field",
+ document: `<form>
+ <input id="un1" type="hidden" autocomplete="username">
+ </form>`,
+ expectations: [false, false],
+ },
+ {
+ description: "Form with only a button",
+ document: `<form>
+ <input id="un1" type="button" autocomplete="username">
+ </form>`,
+ expectations: [false, false],
+ },
+ {
+ description: "A username only form matches not username selector",
+ document: `<form>
+ <input id="un1" type="text" name="secret_username">
+ </form>`,
+ fieldOverrideRecipe: {
+ hosts: ["localhost:8080"],
+ notUsernameSelector: 'input[name="secret_username"]',
+ },
+ expectations: [false, false],
+ },
+];
+
+function _setPrefs() {
+ Services.prefs.setBoolPref("signon.usernameOnlyForm.enabled", true);
+ registerCleanupFunction(() => {
+ Services.prefs.clearUserPref("signon.usernameOnlyForm.enabled");
+ });
+}
+
+_setPrefs();
+
+for (let tc of TESTCASES) {
+ info("Sanity checking the testcase: " + tc.description);
+
+ // A form is considered a username-only form
+ for (let formHasSigninKeyword of [false, true]) {
+ (function () {
+ const testcase = tc;
+ add_task(async function () {
+ if (formHasSigninKeyword) {
+ testcase.decription += " (form has a login keyword)";
+ }
+ info("Starting testcase: " + testcase.description);
+ info("Document string: " + testcase.document);
+ const document = MockDocument.createTestDocument(
+ "http://localhost:8080/test/",
+ testcase.document
+ );
+
+ let form = document.querySelector("form");
+ if (formHasSigninKeyword) {
+ form.setAttribute("name", "login");
+ }
+
+ const lmc = new LoginManagerChild();
+ const docState = lmc.stateForDocument(form.ownerDocument);
+ const element = docState.getUsernameFieldFromUsernameOnlyForm(
+ form,
+ testcase.fieldOverrideRecipe
+ );
+ Assert.strictEqual(
+ testcase.expectations[formHasSigninKeyword ? 1 : 0],
+ element != null,
+ `Return incorrect result when the layout is ${testcase.description}`
+ );
+ });
+ })();
+ }
+}
diff --git a/toolkit/components/passwordmgr/test/unit/test_isInferredLoginForm.js b/toolkit/components/passwordmgr/test/unit/test_isInferredLoginForm.js
new file mode 100644
index 0000000000..01bc3ff816
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_isInferredLoginForm.js
@@ -0,0 +1,98 @@
+/**
+ * Test for LoginHelper.isInferredLoginForm.
+ */
+
+"use strict";
+
+const attributeTestData = [
+ {
+ testValues: ["", "form", "search", "signup", "sign-up", "sign/up"],
+ expectation: false,
+ },
+ {
+ testValues: [
+ "Login",
+ "Log in",
+ "Log on",
+ "Log-on",
+ "Sign in",
+ "Sigin",
+ "Sign/in",
+ "Sign-in",
+ "Sign on",
+ "Sign-on",
+ "loginForm",
+ "form-sign-in",
+ ],
+ expectation: true,
+ },
+];
+
+const classNameTestData = [
+ {
+ testValues: [
+ "",
+ "inputTxt form-control",
+ "user-input form-name",
+ "text name mail",
+ "form signup",
+ ],
+ expectation: false,
+ },
+ {
+ testValues: ["login form"],
+ expectation: true,
+ },
+];
+
+const TESTCASES = [
+ {
+ description: "Test id attribute",
+ update: (doc, v) => {
+ doc.querySelector("form").setAttribute("id", v);
+ },
+ subtests: attributeTestData,
+ },
+ {
+ description: "Test name attribute",
+ update: (doc, v) => {
+ doc.querySelector("form").setAttribute("name", v);
+ },
+ subtests: attributeTestData,
+ },
+ {
+ description: "Test class attribute",
+ update: (doc, v) => {
+ doc.querySelector("form").setAttribute("class", v);
+ },
+ subtests: [...attributeTestData, ...classNameTestData],
+ },
+];
+
+for (let testcase of TESTCASES) {
+ info("Sanity checking the testcase: " + testcase.description);
+
+ (function () {
+ add_task(async function () {
+ info("Starting testcase: " + testcase.description);
+
+ for (let subtest of testcase.subtests) {
+ const document = MockDocument.createTestDocument(
+ "http://localhost:8080/test/",
+ `<form id="id" name="name"></form>`
+ );
+
+ for (let value of subtest.testValues) {
+ testcase.update(document, value);
+ const ele = document.querySelector("form");
+ const ret = LoginHelper.isInferredLoginForm(ele);
+ Assert.strictEqual(
+ ret,
+ subtest.expectation,
+ `${testcase.description}, isInferredLoginForm doesn't return correct result while setting the value to ${value}`
+ );
+ }
+ }
+ });
+ })();
+}
diff --git a/toolkit/components/passwordmgr/test/unit/test_isInferredUsernameField.js b/toolkit/components/passwordmgr/test/unit/test_isInferredUsernameField.js
new file mode 100644
index 0000000000..e7d0785e8d
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_isInferredUsernameField.js
@@ -0,0 +1,222 @@
+/**
+ * Test for LoginHelper.isInferredUsernameField and LoginHelper.isInferredEmailField.
+ */
+
+"use strict";
+
+const attributeTestData = [
+ {
+ testValues: [
+ "",
+ "name",
+ "e-mail",
+ "user",
+ "user name",
+ "userid",
+ "lastname",
+ ],
+ expectation: "none",
+ },
+ {
+ testValues: ["email", "EmaiL", "loginemail", "邮箱"],
+ expectation: "email",
+ },
+ {
+ testValues: ["username", "usErNaMe", "my username"],
+ expectation: "username",
+ },
+ {
+ testValues: ["usernameAndemail", "EMAILUSERNAME"],
+ expectation: "username,email",
+ },
+];
+
+const classNameTestData = [
+ {
+ testValues: [
+ "inputTxt form-control",
+ "user-input form-name",
+ "text name mail",
+ ],
+ expectation: "none",
+ },
+ {
+ testValues: ["input email", "signin-email form", "input form valid-Email"],
+ expectation: "email",
+ },
+ {
+ testValues: [
+ "input username",
+ "signup-username form",
+ "input form my_username",
+ ],
+ expectation: "username",
+ },
+ {
+ testValues: ["input text form username email"],
+ expectation: "username,email",
+ },
+];
+
+const labelTestData = [
+ {
+ testValues: [
+ "First Name",
+ "Last Name",
+ "Company Name",
+ "Password",
+ "User Name",
+ ],
+ expectation: "none",
+ },
+ {
+ testValues: ["Email:", "Email Address*"],
+ expectation: "email",
+ },
+ {
+ testValues: ["Username:", "choose a username"],
+ expectation: "username",
+ },
+ {
+ testValues: ["Username/Email", "username or email"],
+ expectation: "username,email",
+ },
+];
+
+const TESTCASES = [
+ {
+ description: "Test input type",
+ update: (doc, v) => {
+ doc.querySelector("input").setAttribute("type", v);
+ },
+ subtests: [
+ {
+ testValues: ["text", "url", "number", "username"],
+ expectation: "none",
+ },
+ {
+ testValues: ["email"],
+ expectation: "email",
+ },
+ ],
+ },
+ {
+ description: "Test autocomplete field",
+ update: (doc, v) => {
+ doc.querySelector("input").setAttribute("autocomplete", v);
+ },
+ subtests: [
+ {
+ testValues: [
+ "off",
+ "on",
+ "name",
+ "new-password",
+ "current-password",
+ "tel",
+ "tel-national",
+ "url",
+ ],
+ expectation: "none",
+ },
+ {
+ testValues: ["email"],
+ expectation: "email",
+ },
+ {
+ testValues: ["username"],
+ expectation: "username",
+ },
+ ],
+ },
+ {
+ description: "Test id attribute",
+ update: (doc, v) => {
+ doc.querySelector("input").setAttribute("id", v);
+ },
+ subtests: attributeTestData,
+ },
+ {
+ description: "Test name attribute",
+ update: (doc, v) => {
+ doc.querySelector("input").setAttribute("name", v);
+ },
+ subtests: attributeTestData,
+ },
+ {
+ description: "Test class attribute",
+ update: (doc, v) => {
+ doc.querySelector("input").setAttribute("class", v);
+ },
+ subtests: [...attributeTestData, ...classNameTestData],
+ },
+ {
+ description: "Test placeholder attribute",
+ update: (doc, v) => {
+ doc.querySelector("input").setAttribute("placeholder", v);
+ },
+ subtests: attributeTestData,
+ },
+ {
+ description: "Test the first label",
+ update: (doc, v) => {
+ doc.getElementById("l1").textContent = v;
+ },
+ subtests: labelTestData,
+ },
+ {
+ description: "Test the second label",
+ update: (doc, v) => {
+ doc.getElementById("l2").textContent = v;
+ },
+ subtests: labelTestData,
+
+ // The username detection heuristic only examine the first label associated
+ // with the input, so no matter what the data is for this label, it doesn't
+ // affect the result.
+ // We can update this testcase once we decide to support multiple labels.
+ supported: false,
+ },
+];
+
+for (let testcase of TESTCASES) {
+ info("Sanity checking the testcase: " + testcase.description);
+
+ (function () {
+ add_task(async function () {
+ info("Starting testcase: " + testcase.description);
+
+ for (let subtest of testcase.subtests) {
+ let document = MockDocument.createTestDocument(
+ "http://localhost:8080/test/",
+ `<label id="l1" for="id"></label>
+ <label id="l2" for="id"></label>
+ <input id="id" type="text" name="name">`
+ );
+
+ for (let value of subtest.testValues) {
+ testcase.update(document, value);
+ let ele = document.querySelector("input");
+
+ let ret = LoginHelper.isInferredUsernameField(ele);
+ Assert.strictEqual(
+ ret,
+ testcase.supported !== false
+ ? subtest.expectation.includes("username")
+ : false,
+ `${testcase.description}, isInferredUsernameField doesn't return correct result while setting the value to ${value}`
+ );
+
+ ret = LoginHelper.isInferredEmailField(ele);
+ Assert.strictEqual(
+ ret,
+ testcase.supported !== false
+ ? subtest.expectation.includes("email")
+ : false,
+ `${testcase.description}, isInferredEmailField doesn't return correct result while setting the value to ${value}`
+ );
+ }
+ }
+ });
+ })();
+}
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..167c160da2
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_isProbablyANewPasswordField.js
@@ -0,0 +1,192 @@
+/**
+ * Test for LoginAutoComplete.isProbablyANewPasswordField.
+ */
+
+"use strict";
+
+const LoginAutoComplete = Cc[
+ "@mozilla.org/login-manager/autocompletesearch;1"
+].getService(Ci.nsILoginAutoCompleteSearch).wrappedJSObject;
+// TODO: create a fake window for the test document to pass fathom.isVisible check.
+// We should consider moving these tests to mochitest because many fathom
+// signals rely on visibility, position, etc., of the test element (See Bug 1712699),
+// which is not supported in xpcshell-test.
+function makeDocumentVisibleToFathom(doc) {
+ let win = {
+ getComputedStyle() {
+ return {
+ overflow: "visible",
+ visibility: "visible",
+ };
+ },
+ };
+ Object.defineProperty(doc, "defaultView", {
+ value: win,
+ });
+ return doc;
+}
+
+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],
+ },
+ {
+ // TODO: Add <placeholder="confirm"> to "confirm-passowrd" password field so fathom can recognize it
+ // as a new password field. Currently, the fathom rules don't really work well in xpcshell-test
+ // because signals rely on visibility, position doesn't work. If we move this test to mochitest, we should
+ // be able to remove the interim solution (See Bug 1712699).
+ 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" placeholder="confirm"></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 = Document.isInstance(testcase.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 = Document.isInstance(testcase.document)
+ ? testcase.document
+ : MockDocument.createTestDocument(
+ "http://localhost:8080/test/",
+ testcase.document
+ );
+
+ document = makeDocumentVisibleToFathom(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..57238964a7
--- /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: true,
+ },
+ {
+ 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..ec6846ab9f
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_legacy_empty_formActionOrigin.js
@@ -0,0 +1,126 @@
+/* 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(async function test_addLogin_wildcard() {
+ let loginInfo = TestData.formLogin({
+ origin: "http://any.example.com",
+ formActionOrigin: "",
+ });
+ await Services.logins.addLoginAsync(loginInfo);
+
+ // Normal form logins cannot be added anymore.
+ loginInfo = TestData.formLogin({ origin: "http://any.example.com" });
+ await Assert.rejects(
+ Services.logins.addLoginAsync(loginInfo),
+ /already exists/
+ );
+
+ // Authentication logins can still be added.
+ loginInfo = TestData.authLogin({ origin: "http://any.example.com" });
+ await Services.logins.addLoginAsync(loginInfo);
+
+ // Form logins can be added for other hosts.
+ loginInfo = TestData.formLogin({ origin: "http://other.example.com" });
+ await Services.logins.addLoginAsync(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..7fb6c9807d
--- /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(async 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 });
+ await Assert.rejects(
+ Services.logins.addLoginAsync(loginInfo),
+ /login values can't contain newlines/
+ );
+
+ loginInfo = TestData.formLogin({ formActionOrigin: testValue });
+ await Assert.rejects(
+ Services.logins.addLoginAsync(loginInfo),
+ /login values can't contain newlines/
+ );
+
+ loginInfo = TestData.authLogin({ httpRealm: testValue });
+ await Assert.rejects(
+ Services.logins.addLoginAsync(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 });
+ await Assert.rejects(
+ Services.logins.addLoginAsync(loginInfo),
+ /login values can't contain newlines/
+ );
+
+ loginInfo = TestData.formLogin({ passwordField: testValue });
+ await Assert.rejects(
+ Services.logins.addLoginAsync(loginInfo),
+ /login values can't contain newlines/
+ );
+ }
+
+ // Test a single dot as the value of usernameField and formActionOrigin.
+ let loginInfo = TestData.formLogin({ usernameField: "." });
+ await Assert.rejects(
+ Services.logins.addLoginAsync(loginInfo),
+ /login values can't be periods/
+ );
+
+ loginInfo = TestData.formLogin({ formActionOrigin: "." });
+ await Assert.rejects(
+ Services.logins.addLoginAsync(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" });
+ await Assert.rejects(
+ Services.logins.addLoginAsync(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..8511e4f34a
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_login_autocomplete_result.js
@@ -0,0 +1,735 @@
+const { LoginAutoCompleteResult } = ChromeUtils.importESModule(
+ "resource://gre/modules/LoginAutoComplete.sys.mjs"
+);
+let nsLoginInfo = Components.Constructor(
+ "@mozilla.org/login-manager/loginInfo;1",
+ Ci.nsILoginInfo,
+ "init"
+);
+
+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_setup(async () => {
+ // 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 = [
+ {
+ 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",
+ telemetryEventData: { searchStartTimeMS: 0 },
+ },
+ },
+ ],
+ },
+ {
+ 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",
+ telemetryEventData: { searchStartTimeMS: 1 },
+ },
+ },
+ ],
+ },
+ {
+ 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" },
+ },
+ ],
+ },
+ {
+ 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" },
+ },
+ ],
+ },
+ {
+ 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" },
+ },
+ ],
+ },
+ {
+ 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" },
+ },
+ ],
+ },
+ {
+ isSecure: false,
+ hasBeenTypePassword: false,
+ matchingLogins: [],
+ searchString: "foo",
+ 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" },
+ },
+ ],
+ },
+ {
+ isSecure: true,
+ hasBeenTypePassword: true,
+ matchingLogins: [],
+ items: [
+ {
+ value: "",
+ label: "View Saved Logins",
+ style: "loginsFooter",
+ comment: { formHostname: "mochi.test" },
+ },
+ ],
+ },
+ {
+ isSecure: true,
+ hasBeenTypePassword: true,
+ matchingLogins: [],
+ searchString: "foo",
+ items: [],
+ },
+ {
+ generatedPassword: "9ljgfd4shyktb45",
+ 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,
+ 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",
+ 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",
+ 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",
+ 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);
+
+ expectedResults.forEach((pattern, testIndex) => {
+ info(`expectedResults[${testIndex}]`);
+ info(JSON.stringify(pattern, null, 2));
+ 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)) {
+ Assert.deepEqual(
+ 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..775f6f5486
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_loginsBackup.js
@@ -0,0 +1,218 @@
+/* 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.defineESModuleGetters(this, {
+ LoginStore: "resource://gre/modules/LoginStore.sys.mjs",
+});
+const { TestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TestUtils.sys.mjs"
+);
+const { TelemetryTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TelemetryTestUtils.sys.mjs"
+);
+
+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() {
+ const loginsStorePath = PathUtils.join(PathUtils.profileDir, "logins.json");
+ const loginsStoreBackup = PathUtils.join(
+ PathUtils.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.ok(!(await IOUtils.exists(store.path)), "No store file at start up");
+ Assert.ok(
+ !(await IOUtils.exists(store._options.backupTo)),
+ "No backup file at start up"
+ );
+
+ // Add logins to create logins.json and logins-backup.json.
+ store.data.logins.push(rawLogin1);
+ await store._save();
+ Assert.ok(await IOUtils.exists(store.path));
+
+ store.data.logins.push(rawLogin2);
+ await store._save();
+ Assert.ok(await IOUtils.exists(store._options.backupTo));
+
+ // Remove logins.json and see if logins-backup.json will be used.
+ await IOUtils.remove(store.path);
+ store.data.logins = [];
+ store.dataReady = false;
+ Assert.ok(!(await IOUtils.exists(store.path)));
+ Assert.ok(await IOUtils.exists(store._options.backupTo));
+
+ // Clear any telemetry events recorded in the jsonfile category previously.
+ Services.telemetry.clearEvents();
+
+ await store.load();
+
+ Assert.ok(
+ await IOUtils.exists(store.path),
+ "logins.json is restored as expected after it went missing"
+ );
+
+ Assert.ok(await IOUtils.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 IOUtils.writeUTF8(store.path, 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();
+
+ Assert.ok(
+ await IOUtils.exists(`${store.path}.corrupt`),
+ "logins.json.corrupt created"
+ );
+ Assert.ok(
+ await IOUtils.exists(store.path),
+ "logins.json is restored after it was corrupted"
+ );
+
+ // Data should be loaded from logins-backup.json.
+ Assert.ok(await IOUtils.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 IOUtils.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 IOUtils.remove(store.path);
+ store.data.logins = [];
+ store.dataReady = false;
+ Assert.ok(!(await IOUtils.exists(store.path)));
+ Assert.ok(await IOUtils.exists(store._options.backupTo));
+
+ store.ensureDataReady();
+
+ // Important to check here if logins.json is restored as expected
+ // after it went missing.
+ await IOUtils.exists(store.path);
+
+ Assert.ok(await IOUtils.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 IOUtils.writeUTF8(store.path, string, {
+ tmpPath: `${store.path}.tmp`,
+ });
+
+ // Try to load the corrupt file.
+ store.data.logins = [];
+ store.dataReady = false;
+ store.ensureDataReady();
+
+ Assert.ok(
+ await IOUtils.exists(`${store.path}.corrupt`),
+ "logins.json.corrupt created"
+ );
+ Assert.ok(
+ await IOUtils.exists(store.path),
+ "logins.json is restored after it was corrupted"
+ );
+
+ // Data should be loaded from logins-backup.json.
+ Assert.ok(await IOUtils.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..ee3fee04d0
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_logins_change.js
@@ -0,0 +1,628 @@
+/* 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.
+ */
+async function checkLoginInvalid(aLoginInfo, aExpectedError) {
+ // Try to add the new login, and verify that no data is stored.
+ await Assert.rejects(
+ Services.logins.addLoginAsync(aLoginInfo),
+ aExpectedError
+ );
+ LoginTestUtils.checkLogins([]);
+
+ // Add a login for the modification tests.
+ let testLogin = TestData.formLogin({ origin: "http://modify.example.com" });
+ await Services.logins.addLoginAsync(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(async function test_addLogin_removeLogin() {
+ // Each login from the test data should be valid and added to the list.
+ await Services.logins.addLogins(TestData.loginList());
+ LoginTestUtils.checkLogins(TestData.loginList());
+
+ // Trying to add each login again should result in an error.
+ for (let loginInfo of TestData.loginList()) {
+ await Assert.rejects(
+ Services.logins.addLoginAsync(loginInfo),
+ /This login already exists./
+ );
+ }
+
+ // Removing each login should succeed.
+ for (let loginInfo of TestData.loginList()) {
+ Services.logins.removeLogin(loginInfo);
+ }
+
+ LoginTestUtils.checkLogins([]);
+});
+
+add_task(async function add_login_works_with_empty_array() {
+ const result = await Services.logins.addLogins([]);
+ Assert.equal(result.length, 0, "no logins added");
+});
+
+add_task(async function duplicated_logins_are_not_added() {
+ const login = TestData.formLogin({
+ username: "user",
+ });
+ await Services.logins.addLogins([login]);
+ const result = await Services.logins.addLogins([login]);
+ Assert.equal(result, 0, "no logins added");
+ Services.logins.removeAllUserFacingLogins();
+});
+
+add_task(async function logins_containing_nul_in_username_are_not_added() {
+ const result = await Services.logins.addLogins([
+ TestData.formLogin({ username: "user\0name" }),
+ ]);
+ Assert.equal(result, 0, "no logins added");
+});
+
+add_task(async function logins_containing_nul_in_password_are_not_added() {
+ const result = await Services.logins.addLogins([
+ TestData.formLogin({ password: "pass\0word" }),
+ ]);
+ Assert.equal(result, 0, "no logins added");
+});
+
+add_task(
+ async function return_value_includes_plaintext_username_and_password() {
+ const login = TestData.formLogin({});
+ const [result] = await Services.logins.addLogins([login]);
+ Assert.equal(result.username, login.username, "plaintext username is set");
+ Assert.equal(result.password, login.password, "plaintext password is set");
+ Services.logins.removeAllUserFacingLogins();
+ }
+);
+
+add_task(async function event_data_includes_plaintext_username_and_password() {
+ const login = TestData.formLogin({});
+ const TestObserver = {
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIObserver",
+ "nsISupportsWeakReference",
+ ]),
+ observe(subject, topic, data) {
+ Assert.ok(subject instanceof Ci.nsILoginInfo);
+ Assert.ok(subject instanceof Ci.nsILoginMetaInfo);
+ Assert.equal(
+ subject.username,
+ login.username,
+ "plaintext username is set"
+ );
+ Assert.equal(
+ subject.password,
+ login.password,
+ "plaintext password is set"
+ );
+ },
+ };
+ Services.obs.addObserver(TestObserver, "passwordmgr-storage-changed");
+ await Services.logins.addLogins([login]);
+ Services.obs.removeObserver(TestObserver, "passwordmgr-storage-changed");
+ Services.logins.removeAllUserFacingLogins();
+});
+
+/**
+ * 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(async function test_invalid_httpRealm_formActionOrigin() {
+ // httpRealm === null, formActionOrigin === null
+ await checkLoginInvalid(
+ TestData.formLogin({ formActionOrigin: null }),
+ /without a httpRealm or formActionOrigin/
+ );
+
+ // httpRealm === "", formActionOrigin === null
+ await checkLoginInvalid(
+ TestData.authLogin({ httpRealm: "" }),
+ /without a httpRealm or formActionOrigin/
+ );
+
+ // httpRealm === null, formActionOrigin === ""
+ // TODO: This is not enforced for now.
+ // await checkLoginInvalid(TestData.formLogin({ formActionOrigin: "" }),
+ // /without a httpRealm or formActionOrigin/);
+
+ // httpRealm === "", formActionOrigin === ""
+ let login = TestData.formLogin({ formActionOrigin: "" });
+ login.httpRealm = "";
+ await checkLoginInvalid(login, /both a httpRealm and formActionOrigin/);
+
+ // !!httpRealm, !!formActionOrigin
+ login = TestData.formLogin();
+ login.httpRealm = "The HTTP Realm";
+ await checkLoginInvalid(login, /both a httpRealm and formActionOrigin/);
+
+ // httpRealm === "", !!formActionOrigin
+ login = TestData.formLogin();
+ login.httpRealm = "";
+ await checkLoginInvalid(login, /both a httpRealm and formActionOrigin/);
+
+ // !!httpRealm, formActionOrigin === ""
+ login = TestData.authLogin();
+ login.formActionOrigin = "";
+ await checkLoginInvalid(login, /both a httpRealm and formActionOrigin/);
+});
+
+/**
+ * Tests null or empty values in required login properties.
+ */
+add_task(async function test_missing_properties() {
+ await checkLoginInvalid(
+ TestData.formLogin({ origin: null }),
+ /null or empty origin/
+ );
+
+ await checkLoginInvalid(
+ TestData.formLogin({ origin: "" }),
+ /null or empty origin/
+ );
+
+ await checkLoginInvalid(
+ TestData.formLogin({ username: null }),
+ /null username/
+ );
+
+ await checkLoginInvalid(
+ TestData.formLogin({ password: null }),
+ /null or empty password/
+ );
+
+ await checkLoginInvalid(
+ TestData.formLogin({ password: "" }),
+ /null or empty password/
+ );
+});
+
+/**
+ * Tests invalid NUL characters in nsILoginInfo properties.
+ */
+add_task(async 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) {
+ await 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(async function test_removeAllUserFacingLogins() {
+ await Services.logins.addLogins(TestData.loginList());
+
+ 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(async 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.
+ await Services.logins.addLoginAsync(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.
+ await Services.logins.addLoginAsync(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(async 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.
+ await Services.logins.addLoginAsync(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.
+ await Services.logins.addLoginAsync(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(async 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(
+ !!(await Services.logins.addLoginAsync(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(
+ !!(await Services.logins.addLoginAsync(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(
+ !!(await Services.logins.addLoginAsync(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,
+ })
+ );
+ await Assert.rejects(
+ Services.logins.addLoginAsync(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",
+ });
+ await Services.logins.addLoginAsync(defaultsLogin);
+
+ // -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,
+ });
+ await Assert.rejects(
+ Services.logins.addLoginAsync(timeCreatedLogin),
+ /Can\'t add a login with invalid date properties./
+ );
+
+ let timeLastUsedLogin = TestData.formLogin({
+ username: "tlu",
+ timeLastUsed: -11644473600000,
+ });
+ await Assert.rejects(
+ Services.logins.addLoginAsync(timeLastUsedLogin),
+ /Can\'t add a login with invalid date properties./
+ );
+
+ let timePasswordChangedLogin = TestData.formLogin({
+ username: "tpc",
+ timePasswordChanged: -11644473600000,
+ });
+ await Assert.rejects(
+ Services.logins.addLoginAsync(timePasswordChangedLogin),
+ /Can\'t add a login with invalid date properties./
+ );
+
+ 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..30c7aa1be9
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_logins_decrypt_failure.js
@@ -0,0 +1,172 @@
+/* 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
+ * primary password when it is not known.
+ */
+function resetPrimaryPassword() {
+ let token = Cc["@mozilla.org/security/pk11tokendb;1"]
+ .getService(Ci.nsIPK11TokenDB)
+ .getInternalKeyToken();
+ token.reset();
+ token.initPassword("");
+}
+
+// Tests
+
+/**
+ * Resets the primary password after some logins were added to the database.
+ */
+add_task(async function test_logins_decrypt_failure() {
+ let logins = TestData.loginList();
+ await Services.logins.addLogins(logins);
+
+ // This makes the existing logins non-decryptable.
+ resetPrimaryPassword();
+
+ // 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.
+ await Services.logins.addLogins(logins);
+ 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(async 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;
+
+ await Services.logins.addLoginAsync(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.
+ await Assert.rejects(
+ Services.logins.addLoginAsync(login),
+ /This login already exists./
+ );
+ // We should fail to re-add a different login with the same GUID.
+ await Assert.rejects(
+ Services.logins.addLoginAsync(loginDupeGuid),
+ /specified GUID already exists/
+ );
+
+ // This makes the existing login non-decryptable.
+ resetPrimaryPassword();
+
+ // 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.
+ await Services.logins.addLoginAsync(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.
+ resetPrimaryPassword();
+
+ // 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..8f31fba02b
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_logins_metainfo.js
@@ -0,0 +1,295 @@
+/* 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
+
+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: Services.uuid.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(async function test_addLogin_metainfo() {
+ // Add a login without metadata to the database.
+ await Services.logins.addLoginAsync(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);
+ await Services.logins.addLoginAsync(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.
+ await Services.logins.addLoginAsync(gLoginInfo3);
+ gLoginMetaInfo3 = retrieveLoginMatching(gLoginInfo3);
+ LoginTestUtils.checkLogins([gLoginInfo1, gLoginInfo2, gLoginInfo3]);
+});
+
+/**
+ * Tests that adding a login with a duplicate GUID throws an exception.
+ */
+add_task(async function test_addLogin_metainfo_duplicate() {
+ let loginInfo = TestData.formLogin({
+ origin: "http://duplicate.example.com",
+ guid: gLoginMetaInfo2.guid,
+ });
+ await Assert.rejects(
+ Services.logins.addLoginAsync(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 = Services.uuid.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 = Services.uuid.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..89337f872e
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_logins_search.js
@@ -0,0 +1,232 @@
+/**
+ * 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_setup(async () => {
+ await Services.logins.addLogins(TestData.loginList());
+});
+
+/**
+ * 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..12e040eb60
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_module_LoginCSVImport.js
@@ -0,0 +1,870 @@
+/* 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 } = ChromeUtils.importESModule(
+ "resource://gre/modules/LoginCSVImport.sys.mjs"
+);
+const { LoginExport } = ChromeUtils.importESModule(
+ "resource://gre/modules/LoginExport.sys.mjs"
+);
+const { TelemetryTestUtils: TTU } = ChromeUtils.importESModule(
+ "resource://testing-common/TelemetryTestUtils.sys.mjs"
+);
+
+// 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
+);
+
+const CATEGORICAL_HISTOGRAM = "PWMGR_IMPORT_LOGINS_FROM_FILE_CATEGORICAL";
+const IMPORT_TIMER_HISTOGRAM = "PWMGR_IMPORT_LOGINS_FROM_FILE_MS";
+const IMPORT_JANK_HISTOGRAM = "PWMGR_IMPORT_LOGINS_FROM_FILE_JANK_MS";
+/**
+ * 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.getAndClearHistogram(CATEGORICAL_HISTOGRAM);
+ TTU.getAndClearHistogram(IMPORT_TIMER_HISTOGRAM);
+ TTU.getAndClearHistogram(IMPORT_JANK_HISTOGRAM);
+ 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;
+}
+
+/**
+ * Asserts histogram telemetry for the categories of logins
+ *
+ * @param {Object} histogram Histogram object returned from `TelemetryTestUtils.getAndClearHistogram()`
+ * @param {Number} index Index representing one of the following values in order: ["added", "modified", "error", "no_change"]. See `toolkit/components/telemetry/Histogram.json` for more information
+ * @param {Number} expected The expected number of entries in the histogram at the passed index
+ */
+function assertHistogramTelemetry(histogram, index, expected) {
+ TTU.assertHistogram(histogram, index, expected);
+}
+
+/**
+ * Ensure that an import works with TSV.
+ */
+add_task(async function test_import_tsv() {
+ let histogram = TTU.getAndClearHistogram(CATEGORICAL_HISTOGRAM);
+ 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);
+ assertHistogramTelemetry(histogram, 0, 1);
+
+ 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 columns that map to one login field.
+ */
+add_task(async function test_import_with_duplicate_fields() {
+ // 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 Assert.rejects(
+ LoginCSVImport.importFromCSV(csvFilePath),
+ /CONFLICTING_VALUES_ERROR/,
+ "Check that the errorType is file format error"
+ );
+
+ LoginTestUtils.checkLogins(
+ [],
+ "Check that no login was added from a file with duplicated columns"
+ );
+});
+
+/**
+ * Ensure that an import fails if there are two identical columns.
+ */
+add_task(async function test_import_with_duplicate_columns() {
+ let csvFilePath = await setupCsv([
+ "url,username,password,password",
+ "https://example.com/path,john@example.com,azerty,12345",
+ ]);
+
+ await Assert.rejects(
+ LoginCSVImport.importFromCSV(csvFilePath),
+ /CONFLICTING_VALUES_ERROR/,
+ "Check that the errorType is file format error"
+ );
+
+ LoginTestUtils.checkLogins(
+ [],
+ "Check that no login was added from a file with duplicated columns"
+ );
+});
+
+/**
+ * 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();
+ await Services.logins.addLogins(logins);
+
+ 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 with an item without the username.
+ */
+add_task(async function test_import_login_without_username() {
+ let csvFilePath = await setupCsv([
+ "url,username,password",
+ "https://example.com/login,,secret_password",
+ ]);
+
+ await LoginCSVImport.importFromCSV(csvFilePath);
+
+ LoginTestUtils.checkLogins(
+ [
+ TestData.formLogin({
+ formActionOrigin: "",
+ httpRealm: null,
+ origin: "https://example.com",
+ password: "secret_password",
+ passwordField: "",
+ timesUsed: 1,
+ username: "",
+ usernameField: "",
+ }),
+ ],
+ "Check that a Login is added without an username",
+ (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 without guid.
+ */
+add_task(async function test_import_summary_modified_login_without_guid() {
+ let histogram = TTU.getAndClearHistogram(CATEGORICAL_HISTOGRAM);
+ let initialDataFile = await setupCsv([
+ "url,username,password,httpRealm,formActionOrigin,guid,timeCreated,timeLastUsed,timePasswordChanged",
+ "https://modifiedwithoutguid.example.com,gini@example.com,initial_password,My realm,,,1589617814635,1589710449871,1589617846802",
+ ]);
+ await LoginCSVImport.importFromCSV(initialDataFile);
+ assertHistogramTelemetry(histogram, 0, 1);
+ histogram = TTU.getAndClearHistogram(CATEGORICAL_HISTOGRAM);
+
+ let csvFile = await LoginTestUtils.file.setupCsvFileWithLines([
+ "url,username,password,httpRealm,formActionOrigin,guid,timeCreated,timeLastUsed,timePasswordChanged",
+ "https://modifiedwithoutguid.example.com,gini@example.com,modified_password,My realm,,,1589617814635,1589710449871,1589617846999",
+ ]);
+
+ let [modifiedWithoutGuid] = await LoginCSVImport.importFromCSV(csvFile.path);
+ assertHistogramTelemetry(histogram, 1, 1);
+ equal(
+ modifiedWithoutGuid.result,
+ "modified",
+ `Check that the login was modified when there was no guid data`
+ );
+ LoginTestUtils.checkLogins(
+ [
+ TestData.authLogin({
+ formActionOrigin: null,
+ guid: null,
+ httpRealm: "My realm",
+ origin: "https://modifiedwithoutguid.example.com",
+ password: "modified_password",
+ passwordField: "",
+ timeCreated: 1589617814635,
+ timeLastUsed: 1589710449871,
+ timePasswordChanged: 1589617846999,
+ timesUsed: 1,
+ username: "gini@example.com",
+ usernameField: "",
+ }),
+ ],
+ "Check that logins were updated with the correct fields",
+ (a, e) => a.equals(e) && checkMetaInfo(a, e)
+ );
+});
+
+/**
+ * Imports login data summary contains modified logins with guid.
+ */
+add_task(async function test_import_summary_modified_login_with_guid() {
+ 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",
+ ]);
+ 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",
+ ]);
+
+ let [modifiedWithGuid] = await LoginCSVImport.importFromCSV(csvFile.path);
+
+ equal(
+ modifiedWithGuid.result,
+ "modified",
+ `Check that the login was modified when it had the same guid`
+ );
+ LoginTestUtils.checkLogins(
+ [
+ TestData.authLogin({
+ formActionOrigin: null,
+ guid: "{5ec0d12f-e194-4279-ae1b-d7d281bb0001}",
+ httpRealm: "My realm",
+ origin: "https://modified.example.com",
+ password: "modified_password",
+ passwordField: "",
+ timeCreated: 1589617814635,
+ timeLastUsed: 1589710449871,
+ timePasswordChanged: 1589617846999,
+ timesUsed: 1,
+ username: "jane@example.com",
+ usernameField: "",
+ }),
+ ],
+ "Check that logins were updated with the correct fields",
+ (a, e) => a.equals(e) && checkMetaInfo(a, e)
+ );
+});
+
+/**
+ * Imports login data summary contains unchanged logins.
+ */
+add_task(async function test_import_summary_contains_unchanged_login() {
+ let histogram = TTU.getAndClearHistogram(CATEGORICAL_HISTOGRAM);
+ 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);
+ assertHistogramTelemetry(histogram, 0, 1);
+ histogram = TTU.getAndClearHistogram(CATEGORICAL_HISTOGRAM);
+
+ 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);
+ assertHistogramTelemetry(histogram, 3, 1);
+ equal(noChange.result, "no_change", `Check that the login was not changed`);
+});
+
+/**
+ * Imports login data summary contains logins with errors in case of missing fields.
+ */
+add_task(async function test_import_summary_contains_missing_fields_errors() {
+ let histogram = TTU.getAndClearHistogram(CATEGORICAL_HISTOGRAM);
+ const missingFieldsToCheck = ["url", "password"];
+ const sourceObject = {
+ url: "https://invalid.password.example.com",
+ username: "jane@example.com",
+ password: "qwerty",
+ };
+ for (const missingField of missingFieldsToCheck) {
+ const clonedUser = { ...sourceObject };
+ clonedUser[missingField] = "";
+ let csvFilePath = await setupCsv([
+ "url,username,password",
+ `${clonedUser.url},${clonedUser.username},${clonedUser.password}`,
+ ]);
+
+ let [importLogin] = await LoginCSVImport.importFromCSV(csvFilePath);
+
+ equal(
+ importLogin.result,
+ "error_missing_field",
+ `Check that the missing field error is reported for ${missingField}`
+ );
+ equal(
+ importLogin.field_name,
+ missingField,
+ `Check that the invalid field name is correctly reported for the ${missingField}`
+ );
+ }
+ assertHistogramTelemetry(histogram, 2, 1);
+});
+
+/**
+ * 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 multiple url and user will import the first and skip the second.
+ */
+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 initialLoginCount = Services.logins.getAllLogins().length;
+
+ let results = await LoginCSVImport.importFromCSV(csvFilePath);
+ let afterImportLoginCount = Services.logins.getAllLogins().length;
+
+ equal(results.length, 2, `Check that we got a result for each imported row`);
+ equal(results[0].result, "added", `Check that the first login was added`);
+ equal(
+ results[1].result,
+ "no_change",
+ `Check that the second login was skipped`
+ );
+ equal(initialLoginCount, 0, `Check that initially we had no logins`);
+ equal(afterImportLoginCount, 1, `Check that we imported only one login`);
+});
+
+/**
+ * Imports login with duplicated guid values throws error.
+ */
+add_task(async function test_import_summary_with_duplicated_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 initialLoginCount = Services.logins.getAllLogins().length;
+
+ let results = await LoginCSVImport.importFromCSV(csvFilePath);
+ let afterImportLoginCount = Services.logins.getAllLogins().length;
+
+ equal(results.length, 2, `Check that we got a result for each imported row`);
+ equal(results[0].result, "added", `Check that the first login was added`);
+ equal(results[1].result, "error", `Check that the second login was an error`);
+ equal(initialLoginCount, 0, `Check that initially we had no logins`);
+ equal(afterImportLoginCount, 1, `Check that we imported only one login`);
+});
+
+/**
+ * Imports login with different passwords will pick up the newest one and ignore the oldest one.
+ */
+add_task(async function test_import_summary_with_different_time_changed() {
+ let csvFilePath = await setupCsv([
+ "url,username,password,timeCreated,timeLastUsed,timePasswordChanged",
+ "https://example.com,eve@example.com,old password,1589617814635,1589710449800,1589617846800",
+ "https://example.com,eve@example.com,new password,1589617814635,1589710449801,1589617846801",
+ ]);
+ let initialLoginCount = Services.logins.getAllLogins().length;
+
+ let results = await LoginCSVImport.importFromCSV(csvFilePath);
+ let afterImportLoginCount = Services.logins.getAllLogins().length;
+
+ equal(results.length, 2, `Check that we got a result for each imported row`);
+ equal(
+ results[0].result,
+ "no_change",
+ `Check that the oldest password is skipped`
+ );
+ equal(
+ results[1].login.password,
+ "new password",
+ `Check that the newest password is imported`
+ );
+ equal(
+ results[1].result,
+ "added",
+ `Check that the newest password result is correct`
+ );
+ equal(initialLoginCount, 0, `Check that initially we had no logins`);
+ equal(afterImportLoginCount, 1, `Check that we imported only one login`);
+});
+
+/**
+ * Imports duplicate logins as one without an error.
+ */
+add_task(async function test_import_duplicate_logins_as_one() {
+ let csvFilePath = await setupCsv([
+ "name,url,username,password",
+ "somesite,https://example.com/,user@example.com,asdasd123123",
+ "somesite,https://example.com/,user@example.com,asdasd123123",
+ ]);
+ let initialLoginCount = Services.logins.getAllLogins().length;
+
+ let results = await LoginCSVImport.importFromCSV(csvFilePath);
+ let afterImportLoginCount = Services.logins.getAllLogins().length;
+
+ equal(results.length, 2, `Check that we got a result for each imported row`);
+ equal(
+ results[0].result,
+ "added",
+ `Check that the first login login was added`
+ );
+ equal(
+ results[1].result,
+ "no_change",
+ `Check that the second login was not changed`
+ );
+
+ equal(initialLoginCount, 0, `Check that initially we had no logins`);
+ equal(afterImportLoginCount, 1, `Check that we imported only one login`);
+});
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..d7df6168c9
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_module_LoginExport.js
@@ -0,0 +1,219 @@
+/* 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.importESModule(
+ "resource://gre/modules/LoginExport.sys.mjs"
+);
+let { sinon } = ChromeUtils.importESModule(
+ "resource://testing-common/Sinon.sys.mjs"
+);
+
+/**
+ * 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() {
+ const tmpFilePath = FileTestUtils.getTempFile("logins.csv").path;
+ await LoginExport.exportAsCSV(tmpFilePath);
+ const csvString = await IOUtils.readUTF8(tmpFilePath);
+ await IOUtils.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_setup(async () => {
+ 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",
+ "unknownFields",
+ "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_LoginManager.js b/toolkit/components/passwordmgr/test/unit/test_module_LoginManager.js
new file mode 100644
index 0000000000..8ab75bdeef
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_module_LoginManager.js
@@ -0,0 +1,37 @@
+/* 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 LoginManager module.
+ */
+
+"use strict";
+
+const { LoginManager } = ChromeUtils.importESModule(
+ "resource://gre/modules/LoginManager.sys.mjs"
+);
+
+add_task(async function test_ensureCurrentSyncID() {
+ let loginManager = new LoginManager();
+ await loginManager.setSyncID(1);
+ await loginManager.setLastSync(100);
+
+ // test calling ensureCurrentSyncID with the current sync ID
+ Assert.equal(await loginManager.ensureCurrentSyncID(1), 1);
+ Assert.equal(await loginManager.getSyncID(), 1, "sync ID shouldn't change");
+ Assert.equal(
+ await loginManager.getLastSync(),
+ 100,
+ "last sync shouldn't change"
+ );
+
+ // test calling ensureCurrentSyncID with the different sync ID
+ Assert.equal(await loginManager.ensureCurrentSyncID(2), 2);
+ Assert.equal(await loginManager.getSyncID(), 2, "sync ID should be updated");
+ Assert.equal(
+ await loginManager.getLastSync(),
+ 0,
+ "last sync should be reset"
+ );
+});
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..f7e764c789
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_module_LoginStore.js
@@ -0,0 +1,337 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Tests the LoginStore object.
+ */
+
+"use strict";
+
+// Globals
+
+ChromeUtils.defineESModuleGetters(this, {
+ LoginStore: "resource://gre/modules/LoginStore.sys.mjs",
+});
+
+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 IOUtils.exists(store.path));
+
+ await store.load();
+
+ Assert.equal(false, await IOUtils.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() {
+ const store = new LoginStore(getTempFile(TEST_STORE_FILE_NAME).path);
+
+ await store.load();
+ await IOUtils.writeUTF8(store.path, "", { writeMode: "create" });
+ await store._save();
+
+ Assert.ok(await IOUtils.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 IOUtils.writeUTF8(store.path, 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 IOUtils.writeUTF8(store.path, string, {
+ tmpPath: store.path + ".tmp",
+ });
+
+ await store.load();
+
+ // A backup file should have been created.
+ Assert.ok(await IOUtils.exists(store.path + ".corrupt"));
+ await IOUtils.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 IOUtils.writeUTF8(store.path, string, {
+ tmpPath: store.path + ".tmp",
+ });
+
+ store.ensureDataReady();
+
+ // A backup file should have been created.
+ Assert.ok(await IOUtils.exists(store.path + ".corrupt"));
+ await IOUtils.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 IOUtils.writeUTF8(store.path, 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..d13ecdcab6
--- /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(async 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;
+ await Services.logins.addLoginAsync(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;
+ await Services.logins.addLoginAsync(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..7d16e205e9
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_remote_recipes.js
@@ -0,0 +1,162 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Tests retrieving remote LoginRecipes in the parent process.
+ * See https://firefox-source-docs.mozilla.org/services/settings/#unit-tests for explanation of db.importChanges({}, Date.now());
+ */
+
+"use strict";
+
+const { RemoteSettings } = ChromeUtils.importESModule(
+ "resource://services-settings/remote-settings.sys.mjs"
+);
+
+const REMOTE_SETTINGS_COLLECTION = "password-recipes";
+
+add_task(async function test_init_remote_recipe() {
+ const db = RemoteSettings(REMOTE_SETTINGS_COLLECTION).db;
+ await db.clear();
+ const record1 = {
+ id: "some-fake-ID",
+ hosts: ["www.testDomain.com"],
+ description: "Some description here",
+ usernameSelector: "#username",
+ };
+ await db.importChanges({}, Date.now(), [record1], { clear: true });
+ 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();
+ await db.importChanges({}, 42);
+});
+
+add_task(async function test_add_recipe_sync() {
+ const db = RemoteSettings(REMOTE_SETTINGS_COLLECTION).db;
+ const record1 = {
+ id: "some-fake-ID",
+ hosts: ["www.testDomain.com"],
+ description: "Some description here",
+ usernameSelector: "#username",
+ };
+ await db.importChanges({}, Date.now(), [record1], { clear: true });
+ 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();
+ await db.importChanges({}, 42);
+});
+
+add_task(async function test_remove_recipe_sync() {
+ const db = RemoteSettings(REMOTE_SETTINGS_COLLECTION).db;
+ const record1 = {
+ id: "some-fake-ID",
+ hosts: ["www.testDomain.com"],
+ description: "Some description here",
+ usernameSelector: "#username",
+ };
+ await db.importChanges({}, Date.now(), [record1], { clear: true });
+ 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 = RemoteSettings(REMOTE_SETTINGS_COLLECTION).db;
+ const malformedRecord = {
+ id: "some-ID",
+ hosts: ["www.testDomain.com"],
+ description: "Some description here",
+ usernameSelector: "#username",
+ fieldThatDoesNotExist: "value",
+ };
+ await db.importChanges({}, Date.now(), [malformedRecord], { clear: true });
+ 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.importChanges({}, Date.now(), [missingHostsRecord], { clear: true });
+ 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..9428d3f897
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_search_schemeUpgrades.js
@@ -0,0 +1,239 @@
+/**
+ * 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_setup(async () => {
+ await Services.logins.addLogins(TestData.loginList());
+});
+
+/**
+ * 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..86a9a08ac3
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_shadowHTTPLogins.js
@@ -0,0 +1,82 @@
+/**
+ * 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",
+});
+
+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..97c54586f1
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_storage.js
@@ -0,0 +1,93 @@
+/* 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),
+ });
+ await Services.logins.addLoginAsync(loginInfo);
+ await reloadAndCheckLoginsGen([loginInfo]);
+
+ // Store the string "test" using similarly looking glyphs.
+ loginInfo = TestData.authLogin({
+ httpRealm: String.fromCharCode(355, 277, 349, 357),
+ });
+ await Services.logins.addLoginAsync(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",
+ });
+ await Services.logins.addLoginAsync(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: "." });
+ await Services.logins.addLoginAsync(loginInfo);
+ await reloadAndCheckLoginsGen([loginInfo]);
+
+ loginInfo = TestData.authLogin({ httpRealm: "." });
+ await Services.logins.addLoginAsync(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" }),
+ ];
+ await Services.logins.addLogins(loginList);
+ 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..56fe6233d9
--- /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.importESModule(
+ "resource://testing-common/TestUtils.sys.mjs"
+);
+
+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_setup(async () => {
+ let oldCanRecord = Services.telemetry.canRecordExtended;
+ Services.telemetry.canRecordExtended = true;
+ registerCleanupFunction(function () {
+ Services.telemetry.canRecordExtended = oldCanRecord;
+ });
+
+ let uniqueNumber = 1;
+ let logins = [];
+ for (let loginModifications of StatisticsTestData) {
+ loginModifications.origin = `http://${uniqueNumber++}.example.com`;
+ if (typeof loginModifications.httpRealm != "undefined") {
+ logins.push(TestData.authLogin(loginModifications));
+ } else {
+ logins.push(TestData.formLogin(loginModifications));
+ }
+ }
+ await Services.logins.addLogins(logins);
+});
+
+/**
+ * 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..b3250b9676
--- /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_setup(async () => {
+ await Services.logins.initializationPromise;
+});
+
+add_task(async function test_vulnerable_password_methods() {
+ const storageJSON = Services.logins.wrappedJSObject._storage.wrappedJSObject;
+
+ const logins = TestData.loginList();
+ Assert.greater(logins.length, 0, "Initial logins length should be > 0.");
+
+ for (const loginInfo of logins) {
+ await Services.logins.addLoginAsync(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 (const 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 (const 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..e576855b85
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/xpcshell.ini
@@ -0,0 +1,74 @@
+[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_findRelatedRealms.js]
+[test_getFormFields.js]
+[test_getPasswordFields.js]
+[test_getPasswordOrigin.js]
+[test_getUserNameAndPasswordFields.js]
+[test_getUsernameFieldFromUsernameOnlyForm.js]
+[test_isInferredLoginForm.js]
+[test_isInferredUsernameField.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_CSVParser.js]
+[test_module_LoginExport.js]
+skip-if = os == "android" # there is no export for android
+[test_module_LoginManager.js]
+[test_notifications.js]
+[test_OSCrypto_win.js]
+skip-if = os != "win"
+[test_PasswordGenerator.js]
+skip-if = os == "android" # Not packaged/used on Android
+[test_PasswordRulesManager_generatePassword.js]
+[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