diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 17:32:43 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 17:32:43 +0000 |
commit | 6bf0a5cb5034a7e684dcc3500e841785237ce2dd (patch) | |
tree | a68f146d7fa01f0134297619fbe7e33db084e0aa /toolkit/components/passwordmgr/test/unit | |
parent | Initial commit. (diff) | |
download | thunderbird-upstream.tar.xz thunderbird-upstream.zip |
Adding upstream version 1:115.7.0.upstream/1%115.7.0upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to '')
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 Binary files differnew file mode 100644 index 0000000000..b234246cac --- /dev/null +++ b/toolkit/components/passwordmgr/test/unit/data/corruptDB.sqlite diff --git a/toolkit/components/passwordmgr/test/unit/data/key4.db b/toolkit/components/passwordmgr/test/unit/data/key4.db Binary files differnew file mode 100644 index 0000000000..b75a14aa8e --- /dev/null +++ b/toolkit/components/passwordmgr/test/unit/data/key4.db diff --git a/toolkit/components/passwordmgr/test/unit/data/signons-v1.sqlite b/toolkit/components/passwordmgr/test/unit/data/signons-v1.sqlite Binary files differnew file mode 100644 index 0000000000..fe030b61fd --- /dev/null +++ b/toolkit/components/passwordmgr/test/unit/data/signons-v1.sqlite diff --git a/toolkit/components/passwordmgr/test/unit/data/signons-v1v2.sqlite b/toolkit/components/passwordmgr/test/unit/data/signons-v1v2.sqlite Binary files differnew file mode 100644 index 0000000000..729512a12b --- /dev/null +++ b/toolkit/components/passwordmgr/test/unit/data/signons-v1v2.sqlite diff --git a/toolkit/components/passwordmgr/test/unit/data/signons-v2.sqlite b/toolkit/components/passwordmgr/test/unit/data/signons-v2.sqlite Binary files differnew file mode 100644 index 0000000000..a6c72b31e8 --- /dev/null +++ b/toolkit/components/passwordmgr/test/unit/data/signons-v2.sqlite diff --git a/toolkit/components/passwordmgr/test/unit/data/signons-v2v3.sqlite b/toolkit/components/passwordmgr/test/unit/data/signons-v2v3.sqlite Binary files differnew file mode 100644 index 0000000000..359df5d311 --- /dev/null +++ b/toolkit/components/passwordmgr/test/unit/data/signons-v2v3.sqlite diff --git a/toolkit/components/passwordmgr/test/unit/data/signons-v3.sqlite b/toolkit/components/passwordmgr/test/unit/data/signons-v3.sqlite Binary files differnew file mode 100644 index 0000000000..918f4142fe --- /dev/null +++ b/toolkit/components/passwordmgr/test/unit/data/signons-v3.sqlite diff --git a/toolkit/components/passwordmgr/test/unit/data/signons-v3v4.sqlite b/toolkit/components/passwordmgr/test/unit/data/signons-v3v4.sqlite Binary files differnew file mode 100644 index 0000000000..e06c33aae3 --- /dev/null +++ b/toolkit/components/passwordmgr/test/unit/data/signons-v3v4.sqlite diff --git a/toolkit/components/passwordmgr/test/unit/data/signons-v4.sqlite b/toolkit/components/passwordmgr/test/unit/data/signons-v4.sqlite Binary files differnew file mode 100644 index 0000000000..227c09c816 --- /dev/null +++ b/toolkit/components/passwordmgr/test/unit/data/signons-v4.sqlite diff --git a/toolkit/components/passwordmgr/test/unit/data/signons-v4v5.sqlite b/toolkit/components/passwordmgr/test/unit/data/signons-v4v5.sqlite Binary files differnew file mode 100644 index 0000000000..4534cf2553 --- /dev/null +++ b/toolkit/components/passwordmgr/test/unit/data/signons-v4v5.sqlite diff --git a/toolkit/components/passwordmgr/test/unit/data/signons-v5v6.sqlite b/toolkit/components/passwordmgr/test/unit/data/signons-v5v6.sqlite Binary files differnew file mode 100644 index 0000000000..eb4ee6d01e --- /dev/null +++ b/toolkit/components/passwordmgr/test/unit/data/signons-v5v6.sqlite diff --git a/toolkit/components/passwordmgr/test/unit/data/signons-v999-2.sqlite b/toolkit/components/passwordmgr/test/unit/data/signons-v999-2.sqlite Binary files differnew file mode 100644 index 0000000000..e09c4f7100 --- /dev/null +++ b/toolkit/components/passwordmgr/test/unit/data/signons-v999-2.sqlite diff --git a/toolkit/components/passwordmgr/test/unit/data/signons-v999.sqlite b/toolkit/components/passwordmgr/test/unit/data/signons-v999.sqlite Binary files differnew file mode 100644 index 0000000000..0328a1a02a --- /dev/null +++ b/toolkit/components/passwordmgr/test/unit/data/signons-v999.sqlite 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 |