diff options
Diffstat (limited to '')
43 files changed, 8444 insertions, 0 deletions
diff --git a/browser/components/aboutlogins/tests/browser/AboutLoginsTestUtils.sys.mjs b/browser/components/aboutlogins/tests/browser/AboutLoginsTestUtils.sys.mjs new file mode 100644 index 0000000000..44a51b80ad --- /dev/null +++ b/browser/components/aboutlogins/tests/browser/AboutLoginsTestUtils.sys.mjs @@ -0,0 +1,107 @@ +/* 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/. */ + +/** + * An utility class to help out with the about:logins and about:loginsimportreport DOM interaction for the tests. + * + */ +export class AboutLoginsTestUtils { + /** + * An utility method to fetch the data from the CSV import success dialog. + * + * @param {content} content + * The content object. + * @param {ContentTaskUtils} ContentTaskUtils + * The ContentTaskUtils object. + * @returns {Promise<Object>} A promise that contains added, modified, noChange and errors count. + */ + static async getCsvImportSuccessDialogData(content, ContentTaskUtils) { + let dialog = Cu.waiveXrays( + content.document.querySelector("import-summary-dialog") + ); + await ContentTaskUtils.waitForCondition( + () => !dialog.hidden, + "Waiting for the dialog to be visible" + ); + + let added = dialog.shadowRoot.querySelector( + ".import-items-added .result-count" + ).textContent; + let modified = dialog.shadowRoot.querySelector( + ".import-items-modified .result-count" + ).textContent; + let noChange = dialog.shadowRoot.querySelector( + ".import-items-no-change .result-count" + ).textContent; + let errors = dialog.shadowRoot.querySelector( + ".import-items-errors .result-count" + ).textContent; + return { + added, + modified, + noChange, + errors, + l10nFocused: dialog.shadowRoot.activeElement.getAttribute("data-l10n-id"), + }; + } + + /** + * An utility method to fetch the data from the CSV import error dialog. + * + * @param {content} content + * The content object. + * @returns {Promise<Object>} A promise that contains the hidden state and l10n id for title, description and focused element. + */ + static async getCsvImportErrorDialogData(content) { + const dialog = Cu.waiveXrays( + content.document.querySelector("import-error-dialog") + ); + const l10nTitle = dialog._genericDialog + .querySelector(".error-title") + .getAttribute("data-l10n-id"); + const l10nDescription = dialog._genericDialog + .querySelector(".error-description") + .getAttribute("data-l10n-id"); + return { + hidden: dialog.hidden, + l10nFocused: dialog.shadowRoot.activeElement.getAttribute("data-l10n-id"), + l10nTitle, + l10nDescription, + }; + } + + /** + * An utility method to fetch data from the about:loginsimportreport page. + * It also cleans up the tab so you don't have to. + * + * @param {content} content + * The content object. + * @returns {Promise<Object>} A promise that contains the detailed report data like added, modified, noChange, errors and rows. + */ + static async getCsvImportReportData(content) { + const rows = []; + for (let element of content.document.querySelectorAll(".row-details")) { + rows.push(element.getAttribute("data-l10n-id")); + } + const added = content.document.querySelector( + ".new-logins .result-count" + ).textContent; + const modified = content.document.querySelector( + ".exiting-logins .result-count" + ).textContent; + const noChange = content.document.querySelector( + ".duplicate-logins .result-count" + ).textContent; + const errors = content.document.querySelector( + ".errors-logins .result-count" + ).textContent; + return { + rows, + added, + modified, + noChange, + errors, + }; + } +} diff --git a/browser/components/aboutlogins/tests/browser/browser.ini b/browser/components/aboutlogins/tests/browser/browser.ini new file mode 100644 index 0000000000..06eb8ab92a --- /dev/null +++ b/browser/components/aboutlogins/tests/browser/browser.ini @@ -0,0 +1,58 @@ +[DEFAULT] +support-files = + head.js +prefs = + signon.management.page.vulnerable-passwords.enabled=true + signon.management.page.os-auth.enabled=true + # lower the interval for event telemetry in the content process to update the parent process + toolkit.telemetry.ipcBatchTimeout=10 + +# Run first so content events from previous tests won't trickle in. +# Skip ASAN and debug since waiting for content events is already slow. +[browser_aaa_eventTelemetry_run_first.js] +skip-if = + asan || tsan || ccov || debug || (os == "win" && !debug) # bug 1605494 is more prevalent on linux, Bug 1627419 + os == 'linux' && bits == 64 && !debug # Bug 1648862 +[browser_alertDismissedAfterChangingPassword.js] +skip-if = + os == "mac" && os_version == "10.15" && !debug # Bug 1684513 +[browser_breachAlertShowingForAddedLogin.js] +[browser_confirmDeleteDialog.js] +[browser_contextmenuFillLogins.js] +skip-if = win10_2004 && debug # Bug 1723573 +[browser_copyToClipboardButton.js] +[browser_createLogin.js] +[browser_deleteLogin.js] +[browser_fxAccounts.js] +[browser_loginFilter.js] +[browser_loginItemErrors.js] +skip-if = debug # Bug 1577710 +[browser_loginListChanges.js] +[browser_loginSortOrderRestored.js] +skip-if = os == 'linux' && bits == 64 && os_version == '18.04' # Bug 1587625; Bug 1587626 for linux1804 +[browser_noLoginsView.js] +[browser_openExport.js] +[browser_openFiltered.js] +[browser_openImport.js] +skip-if = + os != "win" && os != "mac" # import is only available on Windows and macOS + os == "mac" && !debug # bug 1775753 +[browser_openImportCSV.js] +[browser_openPreferences.js] +[browser_openPreferencesExternal.js] +[browser_openSite.js] +skip-if = + os == "linux" && bits == 64 # Bug 1581889 +[browser_osAuthDialog.js] +skip-if = (os == 'linux') # bug 1527745 +[browser_primaryPassword.js] +skip-if = + (os == 'linux') # bug 1569789 +[browser_removeAllDialog.js] +[browser_sessionRestore.js] +skip-if = + tsan + debug # Bug 1576876 +[browser_tabKeyNav.js] +[browser_updateLogin.js] +[browser_vulnerableLoginAddedInSecondaryWindow.js] diff --git a/browser/components/aboutlogins/tests/browser/browser_aaa_eventTelemetry_run_first.js b/browser/components/aboutlogins/tests/browser/browser_aaa_eventTelemetry_run_first.js new file mode 100644 index 0000000000..0ff77a2240 --- /dev/null +++ b/browser/components/aboutlogins/tests/browser/browser_aaa_eventTelemetry_run_first.js @@ -0,0 +1,271 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +requestLongerTimeout(2); + +const { TelemetryTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TelemetryTestUtils.sys.mjs" +); + +EXPECTED_BREACH = { + AddedDate: "2018-12-20T23:56:26Z", + BreachDate: "2018-12-16", + Domain: "breached.example.com", + Name: "Breached", + PwnCount: 1643100, + DataClasses: ["Email addresses", "Usernames", "Passwords", "IP addresses"], + _status: "synced", + id: "047940fe-d2fd-4314-b636-b4a952ee0043", + last_modified: "1541615610052", + schema: "1541615609018", +}; + +let VULNERABLE_TEST_LOGIN2 = new nsLoginInfo( + "https://2.example.com", + "https://2.example.com", + null, + "user2", + "pass3", + "username", + "password" +); + +add_setup(async function () { + TEST_LOGIN1 = await addLogin(TEST_LOGIN1); + VULNERABLE_TEST_LOGIN2 = await addLogin(VULNERABLE_TEST_LOGIN2); + TEST_LOGIN3 = await addLogin(TEST_LOGIN3); + + await TestUtils.waitForCondition(() => { + Services.telemetry.clearEvents(); + let events = Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS, + true + ).content; + return !events || !events.length; + }, "Waiting for telemetry events to get cleared"); + + await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: "about:logins", + }); + registerCleanupFunction(() => { + BrowserTestUtils.removeTab(gBrowser.selectedTab); + Services.logins.removeAllUserFacingLogins(); + }); +}); + +add_task(async function test_telemetry_events() { + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + let loginList = content.document.querySelector("login-list"); + let loginListItem = loginList.shadowRoot.querySelector( + ".login-list-item.breached" + ); + loginListItem.click(); + }); + await LoginTestUtils.telemetry.waitForEventCount(2); + + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + let loginItem = content.document.querySelector("login-item"); + let copyButton = loginItem.shadowRoot.querySelector( + ".copy-username-button" + ); + copyButton.click(); + }); + await LoginTestUtils.telemetry.waitForEventCount(3); + + if (OSKeyStoreTestUtils.canTestOSKeyStoreLogin()) { + let reauthObserved = OSKeyStoreTestUtils.waitForOSKeyStoreLogin(true); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + let loginItem = content.document.querySelector("login-item"); + let copyButton = loginItem.shadowRoot.querySelector( + ".copy-password-button" + ); + copyButton.click(); + }); + await reauthObserved; + // When reauth is observed an extra telemetry event will be recorded + // for the reauth, hence the event count increasing by 2 here, and later + // in the test as well. + await LoginTestUtils.telemetry.waitForEventCount(5); + } + let nextTelemetryEventCount = OSKeyStoreTestUtils.canTestOSKeyStoreLogin() + ? 6 + : 4; + + let promiseNewTab = BrowserTestUtils.waitForNewTab( + gBrowser, + TEST_LOGIN3.origin + "/" + ); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + let loginItem = content.document.querySelector("login-item"); + let originInput = loginItem.shadowRoot.querySelector(".origin-input"); + originInput.click(); + }); + let newTab = await promiseNewTab; + Assert.ok(true, "New tab opened to " + TEST_LOGIN3.origin); + BrowserTestUtils.removeTab(newTab); + await LoginTestUtils.telemetry.waitForEventCount(nextTelemetryEventCount++); + + // Show the password + if (OSKeyStoreTestUtils.canTestOSKeyStoreLogin()) { + let reauthObserved = forceAuthTimeoutAndWaitForOSKeyStoreLogin({ + loginResult: true, + }); + nextTelemetryEventCount++; // An extra event is observed for the reauth event. + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + let loginItem = content.document.querySelector("login-item"); + let revealCheckbox = loginItem.shadowRoot.querySelector( + ".reveal-password-checkbox" + ); + revealCheckbox.click(); + }); + await reauthObserved; + await LoginTestUtils.telemetry.waitForEventCount(nextTelemetryEventCount++); + + // Hide the password + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + let loginItem = content.document.querySelector("login-item"); + let revealCheckbox = loginItem.shadowRoot.querySelector( + ".reveal-password-checkbox" + ); + revealCheckbox.click(); + }); + await LoginTestUtils.telemetry.waitForEventCount(nextTelemetryEventCount++); + + // Don't force the auth timeout here to check that `auth_skipped: true` is set as + // in `extra`. + nextTelemetryEventCount++; // An extra event is observed for the reauth event. + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + let loginItem = content.document.querySelector("login-item"); + let editButton = loginItem.shadowRoot.querySelector(".edit-button"); + editButton.click(); + }); + await LoginTestUtils.telemetry.waitForEventCount(nextTelemetryEventCount++); + + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + let loginItem = content.document.querySelector("login-item"); + let usernameField = loginItem.shadowRoot.querySelector( + 'input[name="username"]' + ); + usernameField.value = "user1-modified"; + + let saveButton = loginItem.shadowRoot.querySelector( + ".save-changes-button" + ); + saveButton.click(); + }); + await LoginTestUtils.telemetry.waitForEventCount(nextTelemetryEventCount++); + } + + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + let loginItem = content.document.querySelector("login-item"); + let deleteButton = loginItem.shadowRoot.querySelector(".delete-button"); + deleteButton.click(); + let confirmDeleteDialog = content.document.querySelector( + "confirmation-dialog" + ); + let confirmDeleteButton = + confirmDeleteDialog.shadowRoot.querySelector(".confirm-button"); + confirmDeleteButton.click(); + }); + await LoginTestUtils.telemetry.waitForEventCount(nextTelemetryEventCount++); + + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + let newLoginButton = content.document + .querySelector("login-list") + .shadowRoot.querySelector(".create-login-button"); + newLoginButton.click(); + }); + await LoginTestUtils.telemetry.waitForEventCount(nextTelemetryEventCount++); + + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + let loginItem = content.document.querySelector("login-item"); + let cancelButton = loginItem.shadowRoot.querySelector(".cancel-button"); + cancelButton.click(); + }); + await LoginTestUtils.telemetry.waitForEventCount(nextTelemetryEventCount++); + + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + let loginList = content.document.querySelector("login-list"); + let loginListItem = loginList.shadowRoot.querySelector( + ".login-list-item.vulnerable" + ); + loginListItem.click(); + }); + await LoginTestUtils.telemetry.waitForEventCount(nextTelemetryEventCount++); + + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + let loginItem = content.document.querySelector("login-item"); + let copyButton = loginItem.shadowRoot.querySelector( + ".copy-username-button" + ); + copyButton.click(); + }); + await LoginTestUtils.telemetry.waitForEventCount(nextTelemetryEventCount++); + + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + let loginItem = content.document.querySelector("login-item"); + let deleteButton = loginItem.shadowRoot.querySelector(".delete-button"); + deleteButton.click(); + let confirmDeleteDialog = content.document.querySelector( + "confirmation-dialog" + ); + let confirmDeleteButton = + confirmDeleteDialog.shadowRoot.querySelector(".confirm-button"); + confirmDeleteButton.click(); + }); + await LoginTestUtils.telemetry.waitForEventCount(nextTelemetryEventCount++); + + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + let loginSort = content.document + .querySelector("login-list") + .shadowRoot.querySelector("#login-sort"); + loginSort.value = "last-used"; + loginSort.dispatchEvent(new content.Event("change", { bubbles: true })); + }); + await LoginTestUtils.telemetry.waitForEventCount(nextTelemetryEventCount++); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("signon.management.page.sort"); + }); + + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + const loginList = content.document.querySelector("login-list"); + const loginFilter = loginList.shadowRoot.querySelector("login-filter"); + const input = loginFilter.shadowRoot.querySelector("input"); + input.setUserInput("test"); + }); + await LoginTestUtils.telemetry.waitForEventCount(nextTelemetryEventCount++); + + const testOSAuth = OSKeyStoreTestUtils.canTestOSKeyStoreLogin(); + let expectedEvents = [ + [true, "pwmgr", "open_management", "direct"], + [true, "pwmgr", "select", "existing_login", null, { breached: "true" }], + [true, "pwmgr", "copy", "username", null, { breached: "true" }], + [testOSAuth, "pwmgr", "reauthenticate", "os_auth", "success"], + [testOSAuth, "pwmgr", "copy", "password", null, { breached: "true" }], + [true, "pwmgr", "open_site", "existing_login", null, { breached: "true" }], + [testOSAuth, "pwmgr", "reauthenticate", "os_auth", "success"], + [testOSAuth, "pwmgr", "show", "password", null, { breached: "true" }], + [testOSAuth, "pwmgr", "hide", "password", null, { breached: "true" }], + [testOSAuth, "pwmgr", "reauthenticate", "os_auth", "success_no_prompt"], + [testOSAuth, "pwmgr", "edit", "existing_login", null, { breached: "true" }], + [testOSAuth, "pwmgr", "save", "existing_login", null, { breached: "true" }], + [true, "pwmgr", "delete", "existing_login", null, { breached: "true" }], + [true, "pwmgr", "new", "new_login"], + [true, "pwmgr", "cancel", "new_login"], + [true, "pwmgr", "select", "existing_login", null, { vulnerable: "true" }], + [true, "pwmgr", "copy", "username", null, { vulnerable: "true" }], + [true, "pwmgr", "delete", "existing_login", null, { vulnerable: "true" }], + [true, "pwmgr", "sort", "list"], + [true, "pwmgr", "filter", "list"], + ]; + expectedEvents = expectedEvents + .filter(event => event[0]) + .map(event => event.slice(1)); + + TelemetryTestUtils.assertEvents( + expectedEvents, + { category: "pwmgr" }, + { clear: true, process: "content" } + ); +}); diff --git a/browser/components/aboutlogins/tests/browser/browser_alertDismissedAfterChangingPassword.js b/browser/components/aboutlogins/tests/browser/browser_alertDismissedAfterChangingPassword.js new file mode 100644 index 0000000000..623df38fcb --- /dev/null +++ b/browser/components/aboutlogins/tests/browser/browser_alertDismissedAfterChangingPassword.js @@ -0,0 +1,227 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +EXPECTED_BREACH = { + AddedDate: "2018-12-20T23:56:26Z", + BreachDate: "2018-12-16", + Domain: "breached.example.com", + Name: "Breached", + PwnCount: 1643100, + DataClasses: ["Email addresses", "Usernames", "Passwords", "IP addresses"], + _status: "synced", + id: "047940fe-d2fd-4314-b636-b4a952ee0043", + last_modified: "1541615610052", + schema: "1541615609018", +}; + +let VULNERABLE_TEST_LOGIN2 = new nsLoginInfo( + "https://2.example.com", + "https://2.example.com", + null, + "user2", + "pass3", + "username", + "password" +); + +add_setup(async function () { + TEST_LOGIN1 = await addLogin(TEST_LOGIN1); + VULNERABLE_TEST_LOGIN2 = await addLogin(VULNERABLE_TEST_LOGIN2); + TEST_LOGIN3 = await addLogin(TEST_LOGIN3); + await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: "about:logins", + }); + registerCleanupFunction(() => { + BrowserTestUtils.removeTab(gBrowser.selectedTab); + Services.logins.removeAllUserFacingLogins(); + }); +}); + +add_task(async function test_added_login_shows_breach_warning() { + let browser = gBrowser.selectedBrowser; + await SpecialPowers.spawn( + browser, + [[TEST_LOGIN1.guid, VULNERABLE_TEST_LOGIN2.guid, TEST_LOGIN3.guid]], + async ([regularLoginGuid, vulnerableLoginGuid, breachedLoginGuid]) => { + let loginList = Cu.waiveXrays( + content.document.querySelector("login-list") + ); + await ContentTaskUtils.waitForCondition( + () => loginList.shadowRoot.querySelectorAll(".login-list-item").length, + "Waiting for login-list to get populated" + ); + let { listItem: regularListItem } = loginList._logins[regularLoginGuid]; + let { listItem: vulnerableListItem } = + loginList._logins[vulnerableLoginGuid]; + let { listItem: breachedListItem } = loginList._logins[breachedLoginGuid]; + await ContentTaskUtils.waitForCondition(() => { + return ( + !regularListItem.matches(".breached.vulnerable") && + vulnerableListItem.matches(".vulnerable") && + breachedListItem.matches(".breached") + ); + }, `waiting for the list items to get their classes updated: ${regularListItem.className} / ${vulnerableListItem.className} / ${breachedListItem.className}`); + Assert.ok( + !regularListItem.classList.contains("breached") && + !regularListItem.classList.contains("vulnerable"), + "regular login should not be marked breached or vulnerable: " + + regularLoginGuid.className + ); + Assert.ok( + !vulnerableListItem.classList.contains("breached") && + vulnerableListItem.classList.contains("vulnerable"), + "vulnerable login should be marked vulnerable: " + + vulnerableListItem.className + ); + Assert.ok( + breachedListItem.classList.contains("breached") && + !breachedListItem.classList.contains("vulnerable"), + "breached login should be marked breached: " + + breachedListItem.className + ); + + breachedListItem.click(); + let loginItem = Cu.waiveXrays( + content.document.querySelector("login-item") + ); + await ContentTaskUtils.waitForCondition(() => { + return loginItem._login && loginItem._login.guid == breachedLoginGuid; + }, "waiting for breached login to get selected"); + Assert.ok( + !ContentTaskUtils.is_hidden( + loginItem.shadowRoot.querySelector(".breach-alert") + ), + "the breach alert should be visible" + ); + } + ); + + if (!OSKeyStoreTestUtils.canTestOSKeyStoreLogin()) { + info( + "leaving test early since the remaining part of the test requires 'edit' mode which requires 'oskeystore' login" + ); + return; + } + + let reauthObserved = forceAuthTimeoutAndWaitForOSKeyStoreLogin({ + loginResult: true, + }); + // Change the password on the breached login and check that the + // login is no longer marked as breached. The vulnerable login + // should still be marked as vulnerable afterwards. + await SpecialPowers.spawn(browser, [], () => { + let loginItem = Cu.waiveXrays(content.document.querySelector("login-item")); + loginItem.shadowRoot.querySelector(".edit-button").click(); + }); + await reauthObserved; + await SpecialPowers.spawn( + browser, + [[TEST_LOGIN1.guid, VULNERABLE_TEST_LOGIN2.guid, TEST_LOGIN3.guid]], + async ([regularLoginGuid, vulnerableLoginGuid, breachedLoginGuid]) => { + let loginList = Cu.waiveXrays( + content.document.querySelector("login-list") + ); + let loginItem = Cu.waiveXrays( + content.document.querySelector("login-item") + ); + await ContentTaskUtils.waitForCondition( + () => loginItem.dataset.editing == "true", + "waiting for login-item to enter edit mode" + ); + + // The password display field is in the DOM when password input is unfocused. + // To get the password input field, ensure it receives focus. + let passwordInput = loginItem.shadowRoot.querySelector( + "input[type='password']" + ); + passwordInput.focus(); + passwordInput = loginItem.shadowRoot.querySelector( + "input[name='password']" + ); + + const CHANGED_PASSWORD_VALUE = "changedPassword"; + passwordInput.value = CHANGED_PASSWORD_VALUE; + let saveChangesButton = loginItem.shadowRoot.querySelector( + ".save-changes-button" + ); + saveChangesButton.click(); + + await ContentTaskUtils.waitForCondition(() => { + return ( + loginList._logins[breachedLoginGuid].login.password == + CHANGED_PASSWORD_VALUE + ); + }, "waiting for stored login to get updated"); + + Assert.ok( + ContentTaskUtils.is_hidden( + loginItem.shadowRoot.querySelector(".breach-alert") + ), + "the breach alert should be hidden now" + ); + + let { listItem: breachedListItem } = loginList._logins[breachedLoginGuid]; + let { listItem: vulnerableListItem } = + loginList._logins[vulnerableLoginGuid]; + Assert.ok( + !breachedListItem.classList.contains("breached") && + !breachedListItem.classList.contains("vulnerable"), + "the originally breached login should no longer be marked as breached" + ); + Assert.ok( + !vulnerableListItem.classList.contains("breached") && + vulnerableListItem.classList.contains("vulnerable"), + "vulnerable login should still be marked vulnerable: " + + vulnerableListItem.className + ); + + // Change the password on the vulnerable login and check that the + // login is no longer marked as vulnerable. + vulnerableListItem.click(); + await ContentTaskUtils.waitForCondition(() => { + return loginItem._login && loginItem._login.guid == vulnerableLoginGuid; + }, "waiting for vulnerable login to get selected"); + Assert.ok( + !ContentTaskUtils.is_hidden( + loginItem.shadowRoot.querySelector(".vulnerable-alert") + ), + "the vulnerable alert should be visible" + ); + loginItem.shadowRoot.querySelector(".edit-button").click(); + await ContentTaskUtils.waitForCondition( + () => loginItem.dataset.editing == "true", + "waiting for login-item to enter edit mode" + ); + + passwordInput.value = CHANGED_PASSWORD_VALUE; + saveChangesButton.click(); + + await ContentTaskUtils.waitForCondition(() => { + return ( + loginList._logins[vulnerableLoginGuid].login.password == + CHANGED_PASSWORD_VALUE + ); + }, "waiting for stored login to get updated"); + + Assert.ok( + ContentTaskUtils.is_hidden( + loginItem.shadowRoot.querySelector(".vulnerable-alert") + ), + "the vulnerable alert should be hidden now" + ); + Assert.equal( + vulnerableListItem.querySelector(".alert-icon").src, + "", + ".alert-icon for the vulnerable list item should have its source removed" + ); + vulnerableListItem = loginList._logins[vulnerableLoginGuid].listItem; + Assert.ok( + !vulnerableListItem.classList.contains("breached") && + !vulnerableListItem.classList.contains("vulnerable"), + "vulnerable login should no longer be marked vulnerable: " + + vulnerableListItem.className + ); + } + ); +}); diff --git a/browser/components/aboutlogins/tests/browser/browser_breachAlertShowingForAddedLogin.js b/browser/components/aboutlogins/tests/browser/browser_breachAlertShowingForAddedLogin.js new file mode 100644 index 0000000000..a5aef703fa --- /dev/null +++ b/browser/components/aboutlogins/tests/browser/browser_breachAlertShowingForAddedLogin.js @@ -0,0 +1,123 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +EXPECTED_BREACH = { + AddedDate: "2018-12-20T23:56:26Z", + BreachDate: "2018-12-16", + Domain: "breached.example.com", + Name: "Breached", + PwnCount: 1643100, + DataClasses: ["Email addresses", "Usernames", "Passwords", "IP addresses"], + _status: "synced", + id: "047940fe-d2fd-4314-b636-b4a952ee0043", + last_modified: "1541615610052", + schema: "1541615609018", +}; + +add_setup(async function () { + await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: "about:logins", + }); + registerCleanupFunction(() => { + BrowserTestUtils.removeTab(gBrowser.selectedTab); + Services.logins.removeAllUserFacingLogins(); + }); +}); + +add_task(async function test_added_login_shows_breach_warning() { + let browser = gBrowser.selectedBrowser; + await SpecialPowers.spawn(browser, [], async () => { + let loginList = Cu.waiveXrays(content.document.querySelector("login-list")); + Assert.equal( + loginList._loginGuidsSortedOrder.length, + 0, + "the login list should be empty" + ); + }); + + TEST_LOGIN3 = await addLogin(TEST_LOGIN3); + await SpecialPowers.spawn( + browser, + [TEST_LOGIN3.guid], + async aTestLogin3Guid => { + let loginList = Cu.waiveXrays( + content.document.querySelector("login-list") + ); + await ContentTaskUtils.waitForCondition( + () => loginList._loginGuidsSortedOrder.length == 1, + "waiting for login list count to equal one. count=" + + loginList._loginGuidsSortedOrder.length + ); + Assert.equal( + loginList._loginGuidsSortedOrder.length, + 1, + "one login should be in the list" + ); + let breachedLoginListItems; + await ContentTaskUtils.waitForCondition(() => { + breachedLoginListItems = loginList._list.querySelectorAll( + ".login-list-item[data-guid].breached" + ); + return breachedLoginListItems.length == 1; + }, "waiting for the login to get marked as breached"); + Assert.equal( + breachedLoginListItems[0].dataset.guid, + aTestLogin3Guid, + "the breached login should be login3" + ); + } + ); + + info("adding a login that uses the same password as the breached login"); + let vulnerableLogin = new nsLoginInfo( + "https://2.example.com", + "https://2.example.com", + null, + "user2", + "pass3", + "username", + "password" + ); + vulnerableLogin = await addLogin(vulnerableLogin); + await SpecialPowers.spawn( + browser, + [[TEST_LOGIN3.guid, vulnerableLogin.guid]], + async ([aTestLogin3Guid, aVulnerableLoginGuid]) => { + let loginList = Cu.waiveXrays( + content.document.querySelector("login-list") + ); + await ContentTaskUtils.waitForCondition( + () => loginList._loginGuidsSortedOrder.length == 2, + "waiting for login list count to equal two. count=" + + loginList._loginGuidsSortedOrder.length + ); + Assert.equal( + loginList._loginGuidsSortedOrder.length, + 2, + "two logins should be in the list" + ); + let breachedAndVulnerableLoginListItems; + await ContentTaskUtils.waitForCondition(() => { + breachedAndVulnerableLoginListItems = [ + ...loginList._list.querySelectorAll(".breached, .vulnerable"), + ]; + return breachedAndVulnerableLoginListItems.length == 2; + }, "waiting for the logins to get marked as breached and vulnerable"); + Assert.ok( + !!breachedAndVulnerableLoginListItems.find( + listItem => listItem.dataset.guid == aTestLogin3Guid + ), + "the list should include the breached login: " + + breachedAndVulnerableLoginListItems.map(li => li.dataset.guid) + ); + Assert.ok( + !!breachedAndVulnerableLoginListItems.find( + listItem => listItem.dataset.guid == aVulnerableLoginGuid + ), + "the list should include the vulnerable login: " + + breachedAndVulnerableLoginListItems.map(li => li.dataset.guid) + ); + } + ); +}); diff --git a/browser/components/aboutlogins/tests/browser/browser_confirmDeleteDialog.js b/browser/components/aboutlogins/tests/browser/browser_confirmDeleteDialog.js new file mode 100644 index 0000000000..52c288c780 --- /dev/null +++ b/browser/components/aboutlogins/tests/browser/browser_confirmDeleteDialog.js @@ -0,0 +1,128 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_setup(async function () { + await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: "about:logins", + }); + registerCleanupFunction(() => { + BrowserTestUtils.removeTab(gBrowser.selectedTab); + }); +}); + +add_task(async function test() { + let browser = gBrowser.selectedBrowser; + + await SpecialPowers.spawn(browser, [], async () => { + let loginItem = Cu.waiveXrays(content.document.querySelector("login-item")); + + let showPromise = loginItem.showConfirmationDialog("delete"); + + let dialog = Cu.waiveXrays( + content.document.querySelector("confirmation-dialog") + ); + let cancelButton = dialog.shadowRoot.querySelector(".cancel-button"); + let confirmDeleteButton = + dialog.shadowRoot.querySelector(".confirm-button"); + let dismissButton = dialog.shadowRoot.querySelector(".dismiss-button"); + let message = dialog.shadowRoot.querySelector(".message"); + let title = dialog.shadowRoot.querySelector(".title"); + + await content.document.l10n.translateElements([ + title, + message, + cancelButton, + confirmDeleteButton, + ]); + + Assert.equal( + title.textContent, + "Remove this login?", + "Title contents should match l10n attribute set on outer element" + ); + Assert.equal( + message.textContent, + "This action cannot be undone.", + "Message contents should match l10n attribute set on outer element" + ); + Assert.equal( + cancelButton.textContent, + "Cancel", + "Cancel button contents should match l10n attribute set on outer element" + ); + Assert.equal( + confirmDeleteButton.textContent, + "Remove", + "Remove button contents should match l10n attribute set on outer element" + ); + + cancelButton.click(); + try { + await showPromise; + Assert.ok( + false, + "Promise returned by show() should not resolve after clicking cancel button" + ); + } catch (ex) { + Assert.ok( + true, + "Promise returned by show() should reject after clicking cancel button" + ); + } + await ContentTaskUtils.waitForCondition( + () => dialog.hidden, + "Waiting for the dialog to be hidden" + ); + Assert.ok( + dialog.hidden, + "Dialog should be hidden after clicking cancel button" + ); + + showPromise = loginItem.showConfirmationDialog("delete"); + dismissButton.click(); + try { + await showPromise; + Assert.ok( + false, + "Promise returned by show() should not resolve after clicking dismiss button" + ); + } catch (ex) { + Assert.ok( + true, + "Promise returned by show() should reject after clicking dismiss button" + ); + } + await ContentTaskUtils.waitForCondition( + () => dialog.hidden, + "Waiting for the dialog to be hidden" + ); + Assert.ok( + dialog.hidden, + "Dialog should be hidden after clicking dismiss button" + ); + + showPromise = loginItem.showConfirmationDialog("delete"); + confirmDeleteButton.click(); + try { + await showPromise; + Assert.ok( + true, + "Promise returned by show() should resolve after clicking confirm button" + ); + } catch (ex) { + Assert.ok( + false, + "Promise returned by show() should not reject after clicking confirm button" + ); + } + await ContentTaskUtils.waitForCondition( + () => dialog.hidden, + "Waiting for the dialog to be hidden" + ); + Assert.ok( + dialog.hidden, + "Dialog should be hidden after clicking confirm button" + ); + }); +}); diff --git a/browser/components/aboutlogins/tests/browser/browser_contextmenuFillLogins.js b/browser/components/aboutlogins/tests/browser/browser_contextmenuFillLogins.js new file mode 100644 index 0000000000..a10d92baac --- /dev/null +++ b/browser/components/aboutlogins/tests/browser/browser_contextmenuFillLogins.js @@ -0,0 +1,185 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_setup(async function () { + TEST_LOGIN1 = await addLogin(TEST_LOGIN1); + await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: "about:logins", + }); + registerCleanupFunction(() => { + BrowserTestUtils.removeTab(gBrowser.selectedTab); + }); +}); + +const gTests = [ + { + name: "test contextmenu on password field in create login view", + async setup(browser) { + // load up the create login view + await SpecialPowers.spawn(browser, [], async () => { + let loginList = Cu.waiveXrays( + content.document.querySelector("login-list") + ); + let createButton = loginList._createLoginButton; + createButton.click(); + }); + }, + }, +]; + +if (OSKeyStoreTestUtils.canTestOSKeyStoreLogin()) { + gTests[gTests.length] = { + name: "test contextmenu on password field in edit login view", + async setup(browser) { + let osAuthDialogShown = OSKeyStoreTestUtils.waitForOSKeyStoreLogin(true); + + // load up the edit login view + await SpecialPowers.spawn( + browser, + [LoginHelper.loginToVanillaObject(TEST_LOGIN1)], + async login => { + let loginList = content.document.querySelector("login-list"); + let loginListItem = loginList.shadowRoot.querySelector( + ".login-list-item[data-guid]:not([hidden])" + ); + info("Clicking on the first login"); + + loginListItem.click(); + let loginItem = Cu.waiveXrays( + content.document.querySelector("login-item") + ); + await ContentTaskUtils.waitForCondition(() => { + return ( + loginItem._login.guid == loginListItem.dataset.guid && + loginItem._login.guid == login.guid + ); + }, "Waiting for login item to get populated"); + let editButton = loginItem.shadowRoot.querySelector(".edit-button"); + editButton.click(); + } + ); + await osAuthDialogShown; + await SpecialPowers.spawn(browser, [], async () => { + let loginItem = Cu.waiveXrays( + content.document.querySelector("login-item") + ); + await ContentTaskUtils.waitForCondition( + () => loginItem.dataset.editing, + "Waiting for login-item to be in editing state" + ); + }); + }, + }; +} + +/** + * Synthesize mouse clicks to open the password manager context menu popup + * for a target input element. + * + */ +async function openContextMenuForPasswordInput(browser) { + const doc = browser.ownerDocument; + const CONTEXT_MENU = doc.getElementById("contentAreaContextMenu"); + + let contextMenuShownPromise = BrowserTestUtils.waitForEvent( + CONTEXT_MENU, + "popupshown" + ); + + let passwordInputCoords = await SpecialPowers.spawn(browser, [], async () => { + let loginItem = Cu.waiveXrays(content.document.querySelector("login-item")); + + // The password display field is in the DOM when password input is unfocused. + // To get the password input field, ensure it receives focus. + let passwordInput = loginItem.shadowRoot.querySelector( + "input[type='password']" + ); + passwordInput.focus(); + passwordInput = loginItem.shadowRoot.querySelector( + "input[name='password']" + ); + + passwordInput.focus(); + let passwordRect = passwordInput.getBoundingClientRect(); + + // listen for the contextmenu event so we can assert on receiving it + // and examine the target + content.contextmenuPromise = new Promise(resolve => { + content.document.body.addEventListener( + "contextmenu", + event => { + info( + `Received event on target: ${event.target.nodeName}, type: ${event.target.type}` + ); + content.console.log("got contextmenu event: ", event); + resolve(event); + }, + { once: true } + ); + }); + + let coords = { + x: passwordRect.x + passwordRect.width / 2, + y: passwordRect.y + passwordRect.height / 2, + }; + return coords; + }); + + // add the offsets of the <browser> in the chrome window + let browserOffsets = browser.getBoundingClientRect(); + let offsetX = browserOffsets.x + passwordInputCoords.x; + let offsetY = browserOffsets.y + passwordInputCoords.y; + + // Synthesize a right mouse click over the password input element, we have to trigger + // both events because formfill code relies on this event happening before the contextmenu + // (which it does for real user input) in order to not show the password autocomplete. + let eventDetails = { type: "mousedown", button: 2 }; + await EventUtils.synthesizeMouseAtPoint(offsetX, offsetY, eventDetails); + + // Synthesize a contextmenu event to actually open the context menu. + eventDetails = { type: "contextmenu", button: 2 }; + await EventUtils.synthesizeMouseAtPoint(offsetX, offsetY, eventDetails); + + await SpecialPowers.spawn(browser, [], async () => { + let event = await content.contextmenuPromise; + // XXX the event target here is the login-item element, + // not the input[type='password'] in its shadowRoot + info("contextmenu event target: " + event.target.nodeName); + }); + + info("waiting for contextMenuShownPromise"); + await contextMenuShownPromise; + return CONTEXT_MENU; +} + +async function testContextMenuOnInputField(testData) { + let browser = gBrowser.selectedBrowser; + + await SimpleTest.promiseFocus(browser.ownerGlobal); + await testData.setup(browser); + + info("test setup completed"); + let contextMenu = await openContextMenuForPasswordInput(browser); + let fillItem = contextMenu.querySelector("#fill-login"); + Assert.ok(fillItem, "fill menu item exists"); + Assert.ok( + fillItem && EventUtils.isHidden(fillItem), + "fill menu item is hidden" + ); + + let promiseHidden = BrowserTestUtils.waitForEvent(contextMenu, "popuphidden"); + info("Calling hidePopup on contextMenu"); + contextMenu.hidePopup(); + info("waiting for promiseHidden"); + await promiseHidden; +} + +for (let testData of gTests) { + let tmp = { + async [testData.name]() { + await testContextMenuOnInputField(testData); + }, + }; + add_task(tmp[testData.name]); +} diff --git a/browser/components/aboutlogins/tests/browser/browser_copyToClipboardButton.js b/browser/components/aboutlogins/tests/browser/browser_copyToClipboardButton.js new file mode 100644 index 0000000000..f8ca37cc47 --- /dev/null +++ b/browser/components/aboutlogins/tests/browser/browser_copyToClipboardButton.js @@ -0,0 +1,118 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_task(async function test() { + await SpecialPowers.pushPrefEnv({ + set: [["dom.events.testing.asyncClipboard", true]], + }); + + await BrowserTestUtils.withNewTab( + { gBrowser, url: "about:logins" }, + async function (browser) { + let TEST_LOGIN = { + guid: "70a", + username: "jared", + password: "deraj", + origin: "https://www.example.com", + }; + + await SpecialPowers.spawn(browser, [TEST_LOGIN], async function (login) { + let loginItem = Cu.waiveXrays( + content.document.querySelector("login-item") + ); + + // The login object needs to be cloned into the content global. + loginItem.setLogin(Cu.cloneInto(login, content)); + + // Lower the timeout for the test. + Object.defineProperty( + loginItem.constructor, + "COPY_BUTTON_RESET_TIMEOUT", + { + configurable: true, + writable: true, + value: 1000, + } + ); + }); + + let testCases = [[TEST_LOGIN.username, ".copy-username-button"]]; + if (OSKeyStoreTestUtils.canTestOSKeyStoreLogin()) { + testCases[1] = [TEST_LOGIN.password, ".copy-password-button"]; + } + + for (let testCase of testCases) { + let testObj = { + expectedValue: testCase[0], + copyButtonSelector: testCase[1], + }; + info( + "waiting for " + testObj.expectedValue + " to be placed on clipboard" + ); + let reauthObserved = true; + if (testObj.copyButtonSelector.includes("password")) { + reauthObserved = OSKeyStoreTestUtils.waitForOSKeyStoreLogin(true); + } + + await SimpleTest.promiseClipboardChange( + testObj.expectedValue, + async () => { + await SpecialPowers.spawn( + browser, + [testObj], + async function (aTestObj) { + let loginItem = content.document.querySelector("login-item"); + let copyButton = loginItem.shadowRoot.querySelector( + aTestObj.copyButtonSelector + ); + info("Clicking 'copy' button"); + copyButton.click(); + } + ); + } + ); + await reauthObserved; + Assert.ok(true, testObj.expectedValue + " is on clipboard now"); + + await SpecialPowers.spawn( + browser, + [testObj], + async function (aTestObj) { + let loginItem = Cu.waiveXrays( + content.document.querySelector("login-item") + ); + let copyButton = loginItem.shadowRoot.querySelector( + aTestObj.copyButtonSelector + ); + let otherCopyButton = + copyButton == loginItem._copyUsernameButton + ? loginItem._copyPasswordButton + : loginItem._copyUsernameButton; + Assert.ok( + !otherCopyButton.dataset.copied, + "The other copy button should have the 'copied' state removed" + ); + Assert.ok( + copyButton.dataset.copied, + "Success message should be shown" + ); + } + ); + } + + // Wait for the 'copied' attribute to get removed from the copyPassword + // button, which is the last button that is clicked in the above testcase. + // Since another Copy button isn't clicked, the state won't get cleared + // instantly. This test covers the built-in timeout of the visual display. + await SpecialPowers.spawn(browser, [], async () => { + let copyButton = Cu.waiveXrays( + content.document.querySelector("login-item") + )._copyPasswordButton; + await ContentTaskUtils.waitForCondition( + () => !copyButton.dataset.copied, + "'copied' attribute should be removed after a timeout" + ); + }); + } + ); +}); diff --git a/browser/components/aboutlogins/tests/browser/browser_createLogin.js b/browser/components/aboutlogins/tests/browser/browser_createLogin.js new file mode 100644 index 0000000000..a876320ecf --- /dev/null +++ b/browser/components/aboutlogins/tests/browser/browser_createLogin.js @@ -0,0 +1,535 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_setup(async function () { + let aboutLoginsTab = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: "about:logins", + }); + registerCleanupFunction(() => { + BrowserTestUtils.removeTab(aboutLoginsTab); + Services.logins.removeAllUserFacingLogins(); + }); +}); + +add_task(async function test_create_login() { + let browser = gBrowser.selectedBrowser; + await SpecialPowers.spawn(browser, [], async () => { + let loginList = Cu.waiveXrays(content.document.querySelector("login-list")); + Assert.ok( + !loginList._selectedGuid, + "should not be a selected guid by default" + ); + Assert.ok( + content.document.documentElement.classList.contains("no-logins"), + "Should initially be in no logins view" + ); + Assert.ok( + loginList.classList.contains("no-logins"), + "login-list should initially be in no logins view" + ); + Assert.equal( + loginList._loginGuidsSortedOrder.length, + 0, + "login list should be empty" + ); + }); + + let testCases = [ + ["ftp://ftp.example.com/", "ftp://ftp.example.com"], + ["https://example.com/foo", "https://example.com"], + ["http://example.com/", "http://example.com"], + [ + "https://testuser1:testpass1@bugzilla.mozilla.org/show_bug.cgi?id=1556934", + "https://bugzilla.mozilla.org", + ], + ["https://www.example.com/bar", "https://www.example.com"], + ]; + + for (let i = 0; i < testCases.length; i++) { + let originTuple = testCases[i]; + info("Testcase " + i); + let storageChangedPromised = TestUtils.topicObserved( + "passwordmgr-storage-changed", + (_, data) => data == "addLogin" + ); + + await SpecialPowers.spawn( + browser, + [[originTuple, i]], + async ([aOriginTuple, index]) => { + let loginList = Cu.waiveXrays( + content.document.querySelector("login-list") + ); + let createButton = loginList._createLoginButton; + Assert.ok( + ContentTaskUtils.is_hidden(loginList._blankLoginListItem), + "the blank login list item should be hidden initially" + ); + Assert.ok( + !createButton.disabled, + "Create button should not be disabled initially" + ); + + let loginItem = Cu.waiveXrays( + content.document.querySelector("login-item") + ); + let usernameInput = loginItem.shadowRoot.querySelector( + "input[name='username']" + ); + usernameInput.placeholder = "dummy placeholder"; + + createButton.click(); + + Assert.ok( + ContentTaskUtils.is_visible(loginList._blankLoginListItem), + "the blank login list item should be visible after clicking on the create button" + ); + Assert.ok( + createButton.disabled, + "Create button should be disabled after being clicked" + ); + + let cancelButton = loginItem.shadowRoot.querySelector(".cancel-button"); + Assert.ok( + ContentTaskUtils.is_visible(cancelButton), + "cancel button should be visible in create mode with no logins saved" + ); + + let originInput = loginItem.shadowRoot.querySelector( + "input[name='origin']" + ); + let passwordInput = loginItem.shadowRoot.querySelector( + "input[name='password']" + ); + + Assert.equal( + content.document.l10n.getAttributes(usernameInput).id, + null, + "there should be no placeholder id on the username input in edit mode" + ); + Assert.equal( + usernameInput.placeholder, + "", + "there should be no placeholder on the username input in edit mode" + ); + originInput.value = aOriginTuple[0]; + usernameInput.value = "testuser1"; + passwordInput.value = "testpass1"; + + let saveChangesButton = loginItem.shadowRoot.querySelector( + ".save-changes-button" + ); + saveChangesButton.click(); + } + ); + + info("waiting for login to get added to storage"); + await storageChangedPromised; + info("login added to storage"); + + let canTestOSKeyStoreLogin = OSKeyStoreTestUtils.canTestOSKeyStoreLogin(); + if (canTestOSKeyStoreLogin) { + storageChangedPromised = TestUtils.topicObserved( + "passwordmgr-storage-changed", + (_, data) => data == "modifyLogin" + ); + } + await SpecialPowers.spawn(browser, [originTuple], async aOriginTuple => { + await ContentTaskUtils.waitForCondition(() => { + return !content.document.documentElement.classList.contains( + "no-logins" + ); + }, "waiting for no-logins view to exit"); + Assert.ok( + !content.document.documentElement.classList.contains("no-logins"), + "Should no longer be in no logins view" + ); + let loginList = Cu.waiveXrays( + content.document.querySelector("login-list") + ); + Assert.ok( + !loginList.classList.contains("no-logins"), + "login-list should no longer be in no logins view" + ); + Assert.ok( + ContentTaskUtils.is_hidden(loginList._blankLoginListItem), + "the blank login list item should be hidden after adding new login" + ); + Assert.ok( + !loginList._createLoginButton.disabled, + "Create button shouldn't be disabled after exiting create login view" + ); + + let loginGuid = await ContentTaskUtils.waitForCondition(() => { + return loginList._loginGuidsSortedOrder.find( + guid => loginList._logins[guid].login.origin == aOriginTuple[1] + ); + }, "Waiting for login to be displayed"); + Assert.ok(loginGuid, "Expected login found in login-list"); + + let loginItem = Cu.waiveXrays( + content.document.querySelector("login-item") + ); + Assert.equal(loginItem._login.guid, loginGuid, "login-item should match"); + + let { login, listItem } = loginList._logins[loginGuid]; + Assert.ok( + listItem.classList.contains("selected"), + "list item should be selected" + ); + Assert.equal( + login.origin, + aOriginTuple[1], + "Stored login should only include the origin of the URL provided during creation" + ); + Assert.equal( + login.username, + "testuser1", + "Stored login should have username provided during creation" + ); + Assert.equal( + login.password, + "testpass1", + "Stored login should have password provided during creation" + ); + + let usernameInput = loginItem.shadowRoot.querySelector( + "input[name='username']" + ); + await ContentTaskUtils.waitForCondition( + () => usernameInput.placeholder, + "waiting for placeholder to get set" + ); + Assert.ok( + usernameInput.placeholder, + "there should be a placeholder on the username input when not in edit mode" + ); + }); + + if (!canTestOSKeyStoreLogin) { + continue; + } + + let reauthObserved = forceAuthTimeoutAndWaitForOSKeyStoreLogin({ + loginResult: true, + }); + await SpecialPowers.spawn(browser, [originTuple], async aOriginTuple => { + let loginItem = Cu.waiveXrays( + content.document.querySelector("login-item") + ); + let editButton = loginItem.shadowRoot.querySelector(".edit-button"); + info("clicking on edit button"); + editButton.click(); + }); + info("waiting for oskeystore auth"); + await reauthObserved; + + await SpecialPowers.spawn(browser, [originTuple], async aOriginTuple => { + let loginItem = Cu.waiveXrays( + content.document.querySelector("login-item") + ); + await ContentTaskUtils.waitForCondition( + () => loginItem.dataset.editing, + "waiting for 'edit' mode" + ); + info("in edit mode"); + + let usernameInput = loginItem.shadowRoot.querySelector( + "input[name='username']" + ); + let passwordInput = loginItem.shadowRoot.querySelector( + "input[type='password']" + ); + passwordInput.focus(); + passwordInput = loginItem.shadowRoot.querySelector( + "input[name='password']" + ); + usernameInput.value = "testuser2"; + passwordInput.value = "testpass2"; + + let saveChangesButton = loginItem.shadowRoot.querySelector( + ".save-changes-button" + ); + info("clicking save changes button"); + saveChangesButton.click(); + }); + + info("waiting for login to get modified in storage"); + await storageChangedPromised; + info("login modified in storage"); + + await SpecialPowers.spawn(browser, [originTuple], async aOriginTuple => { + let loginList = Cu.waiveXrays( + content.document.querySelector("login-list") + ); + let login; + await ContentTaskUtils.waitForCondition(() => { + login = Object.values(loginList._logins).find( + obj => obj.login.origin == aOriginTuple[1] + ).login; + info(`${login.origin} / ${login.username} / ${login.password}`); + return ( + login.origin == aOriginTuple[1] && + login.username == "testuser2" && + login.password == "testpass2" + ); + }, "waiting for the login to get updated"); + Assert.equal( + login.origin, + aOriginTuple[1], + "Stored login should only include the origin of the URL provided during creation" + ); + Assert.equal( + login.username, + "testuser2", + "Stored login should have modified username" + ); + Assert.equal( + login.password, + "testpass2", + "Stored login should have modified password" + ); + }); + } + + await SpecialPowers.spawn( + browser, + [testCases.length], + async testCasesLength => { + let loginList = Cu.waiveXrays( + content.document.querySelector("login-list") + ); + Assert.equal( + loginList._loginGuidsSortedOrder.length, + 5, + "login list should have a login per testcase" + ); + } + ); +}); + +add_task(async function test_cancel_create_login() { + let browser = gBrowser.selectedBrowser; + await SpecialPowers.spawn(browser, [], async () => { + let loginList = Cu.waiveXrays(content.document.querySelector("login-list")); + Assert.ok( + loginList._selectedGuid, + "there should be a selected guid before create mode" + ); + Assert.ok( + ContentTaskUtils.is_hidden(loginList._blankLoginListItem), + "the blank login list item should be hidden before create mode" + ); + + let createButton = content.document + .querySelector("login-list") + .shadowRoot.querySelector(".create-login-button"); + createButton.click(); + + Assert.ok( + !loginList._selectedGuid, + "there should be no selected guid when in create mode" + ); + Assert.ok( + ContentTaskUtils.is_visible(loginList._blankLoginListItem), + "the blank login list item should be visible in create mode" + ); + + let loginItem = Cu.waiveXrays(content.document.querySelector("login-item")); + let cancelButton = loginItem.shadowRoot.querySelector(".cancel-button"); + cancelButton.click(); + + Assert.ok( + loginList._selectedGuid, + "there should be a selected guid after canceling create mode" + ); + Assert.ok( + ContentTaskUtils.is_hidden(loginList._blankLoginListItem), + "the blank login list item should be hidden after canceling create mode" + ); + }); +}); + +add_task( + async function test_cancel_create_login_with_filter_showing_one_login() { + const browser = gBrowser.selectedBrowser; + await SpecialPowers.spawn(browser, [], async () => { + const loginList = Cu.waiveXrays( + content.document.querySelector("login-list") + ); + + const loginFilter = Cu.waiveXrays( + loginList.shadowRoot.querySelector("login-filter") + ); + loginFilter.value = "bugzilla.mozilla.org"; + Assert.equal( + loginList._list.querySelectorAll( + ".login-list-item[data-guid]:not([hidden])" + ).length, + 1, + "filter should have one login showing" + ); + let visibleLoginGuid = loginList.shadowRoot.querySelectorAll( + ".login-list-item[data-guid]:not([hidden])" + )[0].dataset.guid; + + let createButton = loginList._createLoginButton; + createButton.click(); + + let loginItem = Cu.waiveXrays( + content.document.querySelector("login-item") + ); + let cancelButton = loginItem.shadowRoot.querySelector(".cancel-button"); + Assert.ok( + ContentTaskUtils.is_visible(cancelButton), + "cancel button should be visible in create mode with one login showing" + ); + cancelButton.click(); + + Assert.equal( + loginFilter.value, + "bugzilla.mozilla.org", + "login-filter should not be cleared if there was a login in the list" + ); + Assert.equal( + loginList.shadowRoot.querySelectorAll( + ".login-list-item[data-guid]:not([hidden])" + )[0].dataset.guid, + visibleLoginGuid, + "the same login should still be visible" + ); + }); + } +); + +add_task(async function test_cancel_create_login_with_logins_filtered_out() { + const browser = gBrowser.selectedBrowser; + await SpecialPowers.spawn(browser, [], async () => { + const loginList = Cu.waiveXrays( + content.document.querySelector("login-list") + ); + const loginFilter = Cu.waiveXrays( + loginList.shadowRoot.querySelector("login-filter") + ); + loginFilter.value = "XXX-no-logins-should-match-this-XXX"; + await Promise.resolve(); + Assert.equal( + loginList._list.querySelectorAll( + ".login-list-item[data-guid]:not([hidden])" + ).length, + 0, + "filter should have no logins showing" + ); + + let createButton = loginList._createLoginButton; + createButton.click(); + + let loginItem = Cu.waiveXrays(content.document.querySelector("login-item")); + let cancelButton = loginItem.shadowRoot.querySelector(".cancel-button"); + Assert.ok( + ContentTaskUtils.is_visible(cancelButton), + "cancel button should be visible in create mode with no logins showing" + ); + cancelButton.click(); + await Promise.resolve(); + + Assert.equal( + loginFilter.value, + "", + "login-filter should be cleared if there were no logins in the list" + ); + let visibleLoginItems = loginList.shadowRoot.querySelectorAll( + ".login-list-item[data-guid]:not([hidden])" + ); + Assert.equal( + visibleLoginItems.length, + 5, + "all logins should be visible with blank filter" + ); + Assert.equal( + loginList._selectedGuid, + visibleLoginItems[0].dataset.guid, + "the first item in the list should be selected" + ); + }); +}); + +add_task(async function test_create_duplicate_login() { + if (!OSKeyStoreTestUtils.canTestOSKeyStoreLogin()) { + return; + } + + let browser = gBrowser.selectedBrowser; + EXPECTED_ERROR_MESSAGE = "This login already exists."; + await SpecialPowers.spawn(browser, [], async () => { + let loginList = Cu.waiveXrays(content.document.querySelector("login-list")); + let createButton = loginList._createLoginButton; + createButton.click(); + + let loginItem = Cu.waiveXrays(content.document.querySelector("login-item")); + let originInput = loginItem.shadowRoot.querySelector( + "input[name='origin']" + ); + let usernameInput = loginItem.shadowRoot.querySelector( + "input[name='username']" + ); + let passwordInput = loginItem.shadowRoot.querySelector( + "input[name='password']" + ); + const EXISTING_USERNAME = "testuser2"; + const EXISTING_ORIGIN = "https://example.com"; + originInput.value = EXISTING_ORIGIN; + usernameInput.value = EXISTING_USERNAME; + passwordInput.value = "different password value"; + + let saveChangesButton = loginItem.shadowRoot.querySelector( + ".save-changes-button" + ); + saveChangesButton.click(); + + await ContentTaskUtils.waitForCondition( + () => !loginItem._errorMessage.hidden, + "waiting until the error message is visible" + ); + let duplicatedGuid = Object.values(loginList._logins).find( + v => + v.login.origin == EXISTING_ORIGIN && + v.login.username == EXISTING_USERNAME + ).login.guid; + Assert.equal( + loginItem._errorMessageLink.dataset.errorGuid, + duplicatedGuid, + "Error message has GUID of existing duplicated login set on it" + ); + + let confirmationDialog = Cu.waiveXrays( + content.document.querySelector("confirmation-dialog") + ); + Assert.ok( + confirmationDialog.hidden, + "the discard-changes dialog should be hidden before clicking the error-message-text" + ); + loginItem._errorMessageLink.querySelector("a").click(); + Assert.ok( + !confirmationDialog.hidden, + "the discard-changes dialog should be visible" + ); + let discardChangesButton = + confirmationDialog.shadowRoot.querySelector(".confirm-button"); + discardChangesButton.click(); + + await ContentTaskUtils.waitForCondition( + () => + Object.keys(loginItem._login).length > 1 && + loginItem._login.guid == duplicatedGuid, + "waiting until the existing duplicated login is selected" + ); + Assert.equal( + loginList._selectedGuid, + duplicatedGuid, + "the duplicated login should be selected in the list" + ); + }); + EXPECTED_ERROR_MESSAGE = null; +}); diff --git a/browser/components/aboutlogins/tests/browser/browser_deleteLogin.js b/browser/components/aboutlogins/tests/browser/browser_deleteLogin.js new file mode 100644 index 0000000000..8aa4201378 --- /dev/null +++ b/browser/components/aboutlogins/tests/browser/browser_deleteLogin.js @@ -0,0 +1,182 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_setup(async function () { + TEST_LOGIN1 = await addLogin(TEST_LOGIN1); + TEST_LOGIN2 = await addLogin(TEST_LOGIN2); + await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: "about:logins", + }); + registerCleanupFunction(() => { + BrowserTestUtils.removeTab(gBrowser.selectedTab); + }); +}); + +add_task(async function test_show_logins() { + let browser = gBrowser.selectedBrowser; + + await SpecialPowers.spawn( + browser, + [[TEST_LOGIN1.guid, TEST_LOGIN2.guid]], + async loginGuids => { + let loginList = Cu.waiveXrays( + content.document.querySelector("login-list") + ); + let loginFound = await ContentTaskUtils.waitForCondition(() => { + return ( + loginList._loginGuidsSortedOrder.length == 2 && + loginList._loginGuidsSortedOrder.includes(loginGuids[0]) && + loginList._loginGuidsSortedOrder.includes(loginGuids[1]) + ); + }, "Waiting for logins to be displayed"); + Assert.ok( + !content.document.documentElement.classList.contains("no-logins"), + "Should no longer be in no logins view" + ); + Assert.ok( + !loginList.classList.contains("no-logins"), + "login-list should no longer be in no logins view" + ); + Assert.ok(loginFound, "Newly added logins should be added to the page"); + } + ); +}); + +add_task(async function test_login_item() { + let browser = gBrowser.selectedBrowser; + + function waitForDelete() { + let numLogins = Services.logins.countLogins("", "", ""); + return TestUtils.waitForCondition( + () => Services.logins.countLogins("", "", "") < numLogins, + "Error waiting for login deletion" + ); + } + + async function deleteFirstLoginAfterEdit() { + await SpecialPowers.spawn(browser, [], async () => { + let loginList = content.document.querySelector("login-list"); + let loginListItem = loginList.shadowRoot.querySelector( + ".login-list-item[data-guid]:not([hidden])" + ); + info("Clicking on the first login"); + loginListItem.click(); + + let loginItem = Cu.waiveXrays( + content.document.querySelector("login-item") + ); + let loginItemPopulated = await ContentTaskUtils.waitForCondition(() => { + return loginItem._login.guid == loginListItem.dataset.guid; + }, "Waiting for login item to get populated"); + Assert.ok(loginItemPopulated, "The login item should get populated"); + }); + let reauthObserved = OSKeyStoreTestUtils.waitForOSKeyStoreLogin(true); + await SpecialPowers.spawn(browser, [], async () => { + let loginItem = Cu.waiveXrays( + content.document.querySelector("login-item") + ); + let editButton = loginItem.shadowRoot.querySelector(".edit-button"); + editButton.click(); + }); + await reauthObserved; + return SpecialPowers.spawn(browser, [], async () => { + let loginItem = Cu.waiveXrays( + content.document.querySelector("login-item") + ); + let usernameInput = loginItem.shadowRoot.querySelector( + "input[name='username']" + ); + let passwordInput = loginItem._passwordInput; + usernameInput.value += "-undone"; + passwordInput.value += "-undone"; + + let deleteButton = loginItem.shadowRoot.querySelector(".delete-button"); + deleteButton.click(); + + let confirmDeleteDialog = Cu.waiveXrays( + content.document.querySelector("confirmation-dialog") + ); + let confirmButton = + confirmDeleteDialog.shadowRoot.querySelector(".confirm-button"); + confirmButton.click(); + }); + } + + function deleteFirstLogin() { + return SpecialPowers.spawn(browser, [], async () => { + let loginList = content.document.querySelector("login-list"); + let loginListItem = loginList.shadowRoot.querySelector( + ".login-list-item[data-guid]:not([hidden])" + ); + info("Clicking on the first login"); + loginListItem.click(); + + let loginItem = Cu.waiveXrays( + content.document.querySelector("login-item") + ); + let loginItemPopulated = await ContentTaskUtils.waitForCondition(() => { + return loginItem._login.guid == loginListItem.dataset.guid; + }, "Waiting for login item to get populated"); + Assert.ok(loginItemPopulated, "The login item should get populated"); + + let deleteButton = loginItem.shadowRoot.querySelector(".delete-button"); + deleteButton.click(); + + let confirmDeleteDialog = Cu.waiveXrays( + content.document.querySelector("confirmation-dialog") + ); + let confirmButton = + confirmDeleteDialog.shadowRoot.querySelector(".confirm-button"); + confirmButton.click(); + }); + } + + let onDeletePromise; + if (OSKeyStoreTestUtils.canTestOSKeyStoreLogin()) { + // Can only test Edit mode in official builds + onDeletePromise = waitForDelete(); + await deleteFirstLoginAfterEdit(); + await onDeletePromise; + + await SpecialPowers.spawn(browser, [], async () => { + let loginList = content.document.querySelector("login-list"); + Assert.ok( + !content.document.documentElement.classList.contains("no-logins"), + "Should not be in no logins view as there is still one login" + ); + Assert.ok( + !loginList.classList.contains("no-logins"), + "Should not be in no logins view as there is still one login" + ); + + let confirmDiscardDialog = Cu.waiveXrays( + content.document.querySelector("confirmation-dialog") + ); + Assert.ok( + confirmDiscardDialog.hidden, + "Discard confirm dialog should not show up after delete an edited login" + ); + }); + } else { + onDeletePromise = waitForDelete(); + await deleteFirstLogin(); + await onDeletePromise; + } + + onDeletePromise = waitForDelete(); + await deleteFirstLogin(); + await onDeletePromise; + + await SpecialPowers.spawn(browser, [], async () => { + let loginList = content.document.querySelector("login-list"); + Assert.ok( + content.document.documentElement.classList.contains("no-logins"), + "Should be in no logins view as all logins got deleted" + ); + Assert.ok( + loginList.classList.contains("no-logins"), + "login-list should be in no logins view as all logins got deleted" + ); + }); +}); diff --git a/browser/components/aboutlogins/tests/browser/browser_fxAccounts.js b/browser/components/aboutlogins/tests/browser/browser_fxAccounts.js new file mode 100644 index 0000000000..b66f204c92 --- /dev/null +++ b/browser/components/aboutlogins/tests/browser/browser_fxAccounts.js @@ -0,0 +1,96 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { UIState } = ChromeUtils.importESModule( + "resource://services-sync/UIState.sys.mjs" +); + +function mockState(state) { + UIState.get = () => ({ + status: state.status, + lastSync: new Date(), + email: state.email, + avatarURL: state.avatarURL, + }); +} + +add_setup(async function () { + let aboutLoginsTab = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: "about:logins", + }); + let getState = UIState.get; + registerCleanupFunction(() => { + BrowserTestUtils.removeTab(aboutLoginsTab); + UIState.get = getState; + }); +}); + +add_task(async function test_logged_out() { + mockState({ status: UIState.STATUS_NOT_CONFIGURED }); + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + + let browser = gBrowser.selectedBrowser; + await SpecialPowers.spawn(browser, [], async () => { + let fxAccountsButton = content.document.querySelector("fxaccounts-button"); + Assert.ok(fxAccountsButton, "fxAccountsButton should exist"); + fxAccountsButton = Cu.waiveXrays(fxAccountsButton); + await ContentTaskUtils.waitForCondition( + () => fxAccountsButton._loggedIn === false, + "waiting for _loggedIn to strictly equal false" + ); + Assert.equal( + fxAccountsButton._loggedIn, + false, + "state should reflect not logged in" + ); + }); +}); + +add_task(async function test_login_syncing_enabled() { + const TEST_EMAIL = "test@example.com"; + const TEST_AVATAR_URL = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg=="; + mockState({ + status: UIState.STATUS_SIGNED_IN, + email: TEST_EMAIL, + avatarURL: TEST_AVATAR_URL, + }); + await SpecialPowers.pushPrefEnv({ + set: [["services.sync.engine.passwords", true]], + }); + Services.obs.notifyObservers(null, UIState.ON_UPDATE); + + let browser = gBrowser.selectedBrowser; + await SpecialPowers.spawn( + browser, + [[TEST_EMAIL, TEST_AVATAR_URL]], + async ([expectedEmail, expectedAvatarURL]) => { + let fxAccountsButton = + content.document.querySelector("fxaccounts-button"); + Assert.ok(fxAccountsButton, "fxAccountsButton should exist"); + fxAccountsButton = Cu.waiveXrays(fxAccountsButton); + await ContentTaskUtils.waitForCondition( + () => fxAccountsButton._email === expectedEmail, + "waiting for _email to strictly equal expectedEmail" + ); + Assert.equal( + fxAccountsButton._loggedIn, + true, + "state should reflect logged in" + ); + Assert.equal( + fxAccountsButton._email, + expectedEmail, + "state should have email set" + ); + Assert.equal( + fxAccountsButton._avatarURL, + expectedAvatarURL, + "state should have avatarURL set" + ); + } + ); + + await SpecialPowers.popPrefEnv(); +}); diff --git a/browser/components/aboutlogins/tests/browser/browser_loginFilter.js b/browser/components/aboutlogins/tests/browser/browser_loginFilter.js new file mode 100644 index 0000000000..765e68713f --- /dev/null +++ b/browser/components/aboutlogins/tests/browser/browser_loginFilter.js @@ -0,0 +1,60 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_setup(async function () { + const aboutLoginsTab = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: "about:logins", + }); + registerCleanupFunction(() => { + BrowserTestUtils.removeTab(aboutLoginsTab); + }); +}); + +add_task(async function focus_filter_by_ctrl_f() { + const browser = gBrowser.selectedBrowser; + await SpecialPowers.spawn(browser, [], async () => { + function getActiveElement() { + let element = content.document.activeElement; + + while (element?.shadowRoot) { + element = element.shadowRoot.activeElement; + } + + return element; + } + + //// State after load + + const loginFilter = content.document + .querySelector("login-list") + .shadowRoot.querySelector("login-filter") + .shadowRoot.querySelector("input"); + Assert.equal( + getActiveElement(), + loginFilter, + "login filter must be focused after opening about:logins" + ); + + //// Focus something else (Create Login button) + + content.document + .querySelector("login-list") + .shadowRoot.querySelector(".create-login-button") + .focus(); + Assert.notEqual( + getActiveElement(), + loginFilter, + "login filter is not focused" + ); + + //// Ctrl+F key + + EventUtils.synthesizeKey("f", { accelKey: true }, content); + Assert.equal( + getActiveElement(), + loginFilter, + "Ctrl+F/Cmd+F focused login filter" + ); + }); +}); diff --git a/browser/components/aboutlogins/tests/browser/browser_loginItemErrors.js b/browser/components/aboutlogins/tests/browser/browser_loginItemErrors.js new file mode 100644 index 0000000000..952149f0db --- /dev/null +++ b/browser/components/aboutlogins/tests/browser/browser_loginItemErrors.js @@ -0,0 +1,153 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_setup(async function () { + await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: "about:logins", + }); + registerCleanupFunction(() => { + BrowserTestUtils.removeTab(gBrowser.selectedTab); + Services.logins.removeAllUserFacingLogins(); + }); +}); + +add_task(async function test_showLoginItemErrors() { + const browser = gBrowser.selectedBrowser; + let LOGIN_TO_UPDATE = new nsLoginInfo( + "https://example.com", + "https://example.com", + null, + "user2", + "pass2" + ); + LOGIN_TO_UPDATE = await Services.logins.addLoginAsync(LOGIN_TO_UPDATE); + EXPECTED_ERROR_MESSAGE = "This login already exists."; + const LOGIN_UPDATES = { + origin: "https://example.com", + password: "my1GoodPassword", + username: "user1", + }; + + await SpecialPowers.spawn( + browser, + [[LoginHelper.loginToVanillaObject(LOGIN_TO_UPDATE), LOGIN_UPDATES]], + async ([loginToUpdate, loginUpdates]) => { + const loginItem = Cu.waiveXrays( + content.document.querySelector("login-item") + ); + const loginItemErrorMessage = Cu.waiveXrays( + loginItem.shadowRoot.querySelector(".error-message") + ); + const loginList = Cu.waiveXrays( + content.document.querySelector("login-list") + ); + + const createButton = loginList._createLoginButton; + createButton.click(); + + const event = Cu.cloneInto( + { + bubbles: true, + detail: loginUpdates, + }, + content + ); + + content.dispatchEvent( + // adds first login + new content.CustomEvent("AboutLoginsCreateLogin", event) + ); + + await ContentTaskUtils.waitForCondition(() => { + return ( + loginList.shadowRoot.querySelectorAll(".login-list-item").length === 3 + ); + }, "Waiting for login item to be created."); + + Assert.ok( + loginItemErrorMessage.hidden, + "An error message should not be displayed after adding a new login." + ); + + content.dispatchEvent( + // adds a duplicate of the first login + new content.CustomEvent("AboutLoginsCreateLogin", event) + ); + + const loginItemErrorMessageVisible = + await ContentTaskUtils.waitForCondition(() => { + return !loginItemErrorMessage.hidden; + }, "Waiting for error message to be shown after attempting to create a duplicate login."); + Assert.ok( + loginItemErrorMessageVisible, + "An error message should be shown after user attempts to add a login that already exists." + ); + + const loginItemErrorMessageText = + loginItemErrorMessage.querySelector("span:not([hidden])"); + Assert.equal( + loginItemErrorMessageText.dataset.l10nId, + "about-logins-error-message-duplicate-login-with-link", + "The correct error message is displayed." + ); + + let loginListItem = Cu.waiveXrays( + loginList.shadowRoot.querySelector( + `.login-list-item[data-guid='${loginToUpdate.guid}']` + ) + ); + loginListItem.click(); + + Assert.ok( + loginItemErrorMessage.hidden, + "The error message should no longer be visible." + ); + } + ); + if (!OSKeyStoreTestUtils.canTestOSKeyStoreLogin()) { + // The rest of the test uses Edit mode which causes an OS prompt in official builds. + return; + } + let reauthObserved = OSKeyStoreTestUtils.waitForOSKeyStoreLogin(true); + await SpecialPowers.spawn( + browser, + [[LoginHelper.loginToVanillaObject(LOGIN_TO_UPDATE), LOGIN_UPDATES]], + async ([loginToUpdate, loginUpdates]) => { + const loginItem = Cu.waiveXrays( + content.document.querySelector("login-item") + ); + const editButton = loginItem.shadowRoot.querySelector(".edit-button"); + editButton.click(); + + const updateEvent = Cu.cloneInto( + { + bubbles: true, + detail: Object.assign({ guid: loginToUpdate.guid }, loginUpdates), + }, + content + ); + + content.dispatchEvent( + // attempt to update LOGIN_TO_UPDATE to a username/origin combination that already exists. + new content.CustomEvent("AboutLoginsUpdateLogin", updateEvent) + ); + + const loginItemErrorMessage = Cu.waiveXrays( + loginItem.shadowRoot.querySelector(".error-message") + ); + const loginAlreadyExistsErrorShownAfterUpdate = + await ContentTaskUtils.waitForCondition(() => { + return !loginItemErrorMessage.hidden; + }, "Waiting for error message to show after updating login to existing login."); + Assert.ok( + loginAlreadyExistsErrorShownAfterUpdate, + "An error message should be shown after updating a login to a username/origin combination that already exists." + ); + } + ); + info("making sure os auth dialog is shown"); + await reauthObserved; + info("saw os auth dialog"); + EXPECTED_ERROR_MESSAGE = null; +}); diff --git a/browser/components/aboutlogins/tests/browser/browser_loginListChanges.js b/browser/components/aboutlogins/tests/browser/browser_loginListChanges.js new file mode 100644 index 0000000000..13df6c1ef6 --- /dev/null +++ b/browser/components/aboutlogins/tests/browser/browser_loginListChanges.js @@ -0,0 +1,144 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_setup(async function () { + await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: "about:logins", + }); + registerCleanupFunction(() => { + BrowserTestUtils.removeTab(gBrowser.selectedTab); + }); +}); + +add_task(async function test_login_added() { + let login = { + guid: "70", + username: "jared", + password: "deraj", + origin: "https://www.example.com", + }; + let browser = gBrowser.selectedBrowser; + browser.browsingContext.currentWindowGlobal + .getActor("AboutLogins") + .sendAsyncMessage("AboutLogins:LoginAdded", login); + + await SpecialPowers.spawn(browser, [login], async addedLogin => { + let loginList = Cu.waiveXrays(content.document.querySelector("login-list")); + let loginFound = await ContentTaskUtils.waitForCondition(() => { + return ( + loginList._loginGuidsSortedOrder.length == 1 && + loginList._loginGuidsSortedOrder[0] == addedLogin.guid + ); + }, "Waiting for login to be added"); + Assert.ok(loginFound, "Newly added logins should be added to the page"); + }); +}); + +add_task(async function test_login_modified() { + let login = { + guid: "70", + username: "jared@example.com", + password: "deraj", + origin: "https://www.example.com", + }; + let browser = gBrowser.selectedBrowser; + browser.browsingContext.currentWindowGlobal + .getActor("AboutLogins") + .sendAsyncMessage("AboutLogins:LoginModified", login); + + await SpecialPowers.spawn(browser, [login], async modifiedLogin => { + let loginList = Cu.waiveXrays(content.document.querySelector("login-list")); + let loginFound = await ContentTaskUtils.waitForCondition(() => { + return ( + loginList._loginGuidsSortedOrder.length == 1 && + loginList._loginGuidsSortedOrder[0] == modifiedLogin.guid && + loginList._logins[loginList._loginGuidsSortedOrder[0]].login.username == + modifiedLogin.username + ); + }, "Waiting for username to get updated"); + Assert.ok(loginFound, "The login should get updated on the page"); + }); +}); + +add_task(async function test_login_removed() { + let login = { + guid: "70", + username: "jared@example.com", + password: "deraj", + origin: "https://www.example.com", + }; + let browser = gBrowser.selectedBrowser; + browser.browsingContext.currentWindowGlobal + .getActor("AboutLogins") + .sendAsyncMessage("AboutLogins:LoginRemoved", login); + + await SpecialPowers.spawn(browser, [login], async removedLogin => { + let loginList = Cu.waiveXrays(content.document.querySelector("login-list")); + let loginRemoved = await ContentTaskUtils.waitForCondition(() => { + return !loginList._loginGuidsSortedOrder.length; + }, "Waiting for login to get removed"); + Assert.ok(loginRemoved, "The login should be removed from the page"); + }); +}); + +add_task(async function test_all_logins_removed() { + // Setup the test with 2 logins. + let logins = [ + { + guid: "70", + username: "jared", + password: "deraj", + origin: "https://www.example.com", + }, + { + guid: "71", + username: "ntim", + password: "verysecurepassword", + origin: "https://www.example.com", + }, + ]; + + let browser = gBrowser.selectedBrowser; + browser.browsingContext.currentWindowGlobal + .getActor("AboutLogins") + .sendAsyncMessage("AboutLogins:AllLogins", logins); + + await SpecialPowers.spawn(browser, [logins], async addedLogins => { + let loginList = Cu.waiveXrays(content.document.querySelector("login-list")); + let loginFound = await ContentTaskUtils.waitForCondition(() => { + return ( + loginList._loginGuidsSortedOrder.length == 2 && + loginList._loginGuidsSortedOrder[0] == addedLogins[0].guid && + loginList._loginGuidsSortedOrder[1] == addedLogins[1].guid + ); + }, "Waiting for login to be added"); + Assert.ok(loginFound, "Newly added logins should be added to the page"); + Assert.ok( + !content.document.documentElement.classList.contains("no-logins"), + "Should not be in no logins view after adding logins" + ); + Assert.ok( + !loginList.classList.contains("no-logins"), + "login-list should not be in no logins view after adding logins" + ); + }); + + Services.logins.removeAllUserFacingLogins(); + + await SpecialPowers.spawn(browser, [], async () => { + let loginList = Cu.waiveXrays(content.document.querySelector("login-list")); + let loginFound = await ContentTaskUtils.waitForCondition(() => { + return !loginList._loginGuidsSortedOrder.length; + }, "Waiting for logins to be cleared"); + Assert.ok(loginFound, "Logins should be cleared"); + Assert.ok( + content.document.documentElement.classList.contains("no-logins"), + "Should be in no logins view after clearing" + ); + Assert.ok( + loginList.classList.contains("no-logins"), + "login-list should be in no logins view after clearing" + ); + }); +}); diff --git a/browser/components/aboutlogins/tests/browser/browser_loginSortOrderRestored.js b/browser/components/aboutlogins/tests/browser/browser_loginSortOrderRestored.js new file mode 100644 index 0000000000..fb39dda30c --- /dev/null +++ b/browser/components/aboutlogins/tests/browser/browser_loginSortOrderRestored.js @@ -0,0 +1,172 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +EXPECTED_BREACH = { + AddedDate: "2018-12-20T23:56:26Z", + BreachDate: "2018-12-16", + Domain: "breached.example.com", + Name: "Breached", + PwnCount: 1643100, + DataClasses: ["Email addresses", "Usernames", "Passwords", "IP addresses"], + _status: "synced", + id: "047940fe-d2fd-4314-b636-b4a952ee0043", + last_modified: "1541615610052", + schema: "1541615609018", +}; + +const SORT_PREF_NAME = "signon.management.page.sort"; + +add_setup(async function () { + TEST_LOGIN3.QueryInterface(Ci.nsILoginMetaInfo).timePasswordChanged = 1; + TEST_LOGIN1 = await addLogin(TEST_LOGIN1); + info(`TEST_LOGIN1 added with guid=${TEST_LOGIN1.guid}`); + TEST_LOGIN3 = await addLogin(TEST_LOGIN3); + info(`TEST_LOGIN3 added with guid=${TEST_LOGIN3.guid}`); + registerCleanupFunction(() => { + Services.logins.removeAllUserFacingLogins(); + Services.prefs.clearUserPref(SORT_PREF_NAME); + }); +}); + +add_task(async function test_sort_order_persisted() { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "about:logins", + }, + async function (browser) { + await ContentTask.spawn( + browser, + [TEST_LOGIN1.guid, TEST_LOGIN3.guid], + async function ([testLogin1Guid, testLogin3Guid]) { + let loginList = Cu.waiveXrays( + content.document.querySelector("login-list") + ); + await ContentTaskUtils.waitForCondition( + () => loginList._sortSelect.value == "alerts", + "Waiting for login-list sort to get changed to 'alerts'. Current value is: " + + loginList._sortSelect.value + ); + Assert.equal( + loginList._sortSelect.value, + "alerts", + "selected sort should be 'alerts' since there is a breached login" + ); + Assert.equal( + loginList._list.querySelector( + ".login-list-item[data-guid]:not([hidden])" + ).dataset.guid, + testLogin3Guid, + "the first login should be TEST_LOGIN3 since they are sorted by alerts" + ); + + loginList._sortSelect.value = "last-changed"; + loginList._sortSelect.dispatchEvent( + new content.Event("change", { bubbles: true }) + ); + Assert.equal( + loginList._list.querySelector( + ".login-list-item[data-guid]:not([hidden])" + ).dataset.guid, + testLogin1Guid, + "the first login should be TEST_LOGIN1 since it has the most recent timePasswordChanged value" + ); + } + ); + } + ); + + Assert.equal( + Services.prefs.getCharPref(SORT_PREF_NAME), + "last-changed", + "'last-changed' should be stored in the pref" + ); + + // Set the pref to the value used in Fx70-76 to confirm our + // backwards-compat support that "breached" is changed to "alerts" + Services.prefs.setCharPref(SORT_PREF_NAME, "breached"); + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "about:logins", + }, + async function (browser) { + await ContentTask.spawn( + browser, + TEST_LOGIN3.guid, + async function (testLogin3Guid) { + let loginList = Cu.waiveXrays( + content.document.querySelector("login-list") + ); + await ContentTaskUtils.waitForCondition( + () => loginList._sortSelect.value == "alerts", + "Waiting for login-list sort to get changed to 'alerts'. Current value is: " + + loginList._sortSelect.value + ); + Assert.equal( + loginList._sortSelect.value, + "alerts", + "selected sort should be restored to 'alerts' since 'breached' was in prefs" + ); + Assert.equal( + loginList._list.querySelector( + ".login-list-item[data-guid]:not([hidden])" + ).dataset.guid, + testLogin3Guid, + "the first login should be TEST_LOGIN3 since they are sorted by alerts" + ); + } + ); + } + ); + + let storageChangedPromised = TestUtils.topicObserved( + "passwordmgr-storage-changed", + (_, data) => data == "removeLogin" + ); + Services.logins.removeLogin(TEST_LOGIN3); + await storageChangedPromised; + TEST_LOGIN2 = await addLogin(TEST_LOGIN2); + + Assert.equal( + Services.prefs.getCharPref(SORT_PREF_NAME), + "breached", + "confirm that the stored sort is still 'breached' and as such shouldn't apply when the page loads" + ); + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "about:logins", + }, + async function (browser) { + await ContentTask.spawn( + browser, + TEST_LOGIN2.guid, + async function (testLogin2Guid) { + let loginList = Cu.waiveXrays( + content.document.querySelector("login-list") + ); + await ContentTaskUtils.waitForCondition( + () => + loginList._list.querySelector( + ".login-list-item[data-guid]:not([hidden])" + ), + "wait for a visible loging to get populated" + ); + Assert.equal( + loginList._sortSelect.value, + "name", + "selected sort should be name since 'alerts' no longer applies with no breached or vulnerable logins" + ); + Assert.equal( + loginList._list.querySelector( + ".login-list-item[data-guid]:not([hidden])" + ).dataset.guid, + testLogin2Guid, + "the first login should be TEST_LOGIN2 since it is sorted first by 'name'" + ); + } + ); + } + ); +}); diff --git a/browser/components/aboutlogins/tests/browser/browser_noLoginsView.js b/browser/components/aboutlogins/tests/browser/browser_noLoginsView.js new file mode 100644 index 0000000000..b86304aac1 --- /dev/null +++ b/browser/components/aboutlogins/tests/browser/browser_noLoginsView.js @@ -0,0 +1,199 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_setup(async function () { + await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: "about:logins", + }); + registerCleanupFunction(() => { + BrowserTestUtils.removeTab(gBrowser.selectedTab); + }); +}); + +add_task(async function test_no_logins_class() { + let { platform } = AppConstants; + let wizardPromise; + + // The import link is hidden on Linux, so we don't wait for the migration + // wizard to open on that platform. + if (AppConstants.platform != "linux") { + wizardPromise = BrowserTestUtils.waitForMigrationWizard(window); + } + + await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [platform], + async aPlatform => { + let loginList = content.document.querySelector("login-list"); + + Assert.ok( + content.document.documentElement.classList.contains("no-logins"), + "root should be in no logins view" + ); + Assert.ok( + loginList.classList.contains("no-logins"), + "login-list should be in no logins view" + ); + + let loginIntro = Cu.waiveXrays( + content.document.querySelector("login-intro") + ); + let loginItem = content.document.querySelector("login-item"); + let loginListIntro = loginList.shadowRoot.querySelector(".intro"); + let loginListList = loginList.shadowRoot.querySelector("ol"); + + Assert.ok( + !ContentTaskUtils.is_hidden(loginIntro), + "login-intro should be shown in no logins view" + ); + Assert.ok( + !ContentTaskUtils.is_hidden(loginListIntro), + "login-list intro should be shown in no logins view" + ); + + Assert.ok( + ContentTaskUtils.is_hidden(loginItem), + "login-item should be hidden in no logins view" + ); + Assert.ok( + ContentTaskUtils.is_hidden(loginListList), + "login-list logins list should be hidden in no logins view" + ); + Assert.equal( + content.document.l10n.getAttributes( + loginIntro.shadowRoot.querySelector(".heading") + ).id, + "about-logins-login-intro-heading-logged-out2", + "The default message should be the non-logged-in message" + ); + Assert.ok( + loginIntro.shadowRoot + .querySelector("a.intro-help-link") + .href.includes("password-manager-remember-delete-edit-logins"), + "Check support href populated" + ); + + loginIntro.updateState(Cu.cloneInto({ loggedIn: true }, content)); + + Assert.equal( + content.document.l10n.getAttributes( + loginIntro.shadowRoot.querySelector(".heading") + ).id, + "about-logins-login-intro-heading-logged-in", + "When logged in the message should update" + ); + + let importClass = Services.prefs.getBoolPref( + "signon.management.page.fileImport.enabled" + ) + ? ".intro-import-text.file-import" + : ".intro-import-text.no-file-import"; + Assert.equal( + ContentTaskUtils.is_hidden( + loginIntro.shadowRoot.querySelector(importClass) + ), + aPlatform == "linux", + "the import link should be hidden on Linux builds" + ); + if (aPlatform == "linux") { + // End the test now for Linux since the link is hidden. + return; + } + loginIntro.shadowRoot.querySelector(importClass + " > a").click(); + info("waiting for MigrationWizard to open"); + } + ); + if (AppConstants.platform == "linux") { + // End the test now for Linux since the link is hidden. + return; + } + let wizard = await wizardPromise; + Assert.ok(wizard, "Migrator window opened"); + await BrowserTestUtils.closeMigrationWizard(wizard); +}); + +add_task( + async function login_selected_when_login_added_and_in_no_logins_view() { + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async () => { + let loginList = content.document.querySelector("login-list"); + let loginItem = content.document.querySelector("login-item"); + let loginIntro = content.document.querySelector("login-intro"); + Assert.ok( + loginList.classList.contains("empty-search"), + "login-list should be showing no logins view from a search with no results" + ); + Assert.ok( + loginList.classList.contains("no-logins"), + "login-list should be showing no logins view since there are no saved logins" + ); + Assert.ok( + !loginList.classList.contains("create-login-selected"), + "login-list should not be in create-login-selected mode" + ); + Assert.ok( + loginItem.classList.contains("no-logins"), + "login-item should be marked as having no-logins" + ); + Assert.ok( + ContentTaskUtils.is_hidden(loginItem), + "login-item should be hidden" + ); + Assert.ok( + !ContentTaskUtils.is_hidden(loginIntro), + "login-intro should be visible" + ); + }); + + TEST_LOGIN1 = await addLogin(TEST_LOGIN1); + + await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [TEST_LOGIN1.guid], + async testLogin1Guid => { + let loginList = content.document.querySelector("login-list"); + let loginItem = content.document.querySelector("login-item"); + let loginIntro = content.document.querySelector("login-intro"); + await ContentTaskUtils.waitForCondition(() => { + return !loginList.classList.contains("no-logins"); + }, "waiting for login-list to leave the no-logins view"); + Assert.ok( + !loginList.classList.contains("empty-search"), + "login-list should not be showing no logins view since one login exists" + ); + Assert.ok( + !loginList.classList.contains("no-logins"), + "login-list should not be showing no logins view since one login exists" + ); + Assert.ok( + !loginList.classList.contains("create-login-selected"), + "login-list should not be in create-login-selected mode" + ); + Assert.equal( + loginList.shadowRoot.querySelector( + ".login-list-item.selected[data-guid]" + ).dataset.guid, + testLogin1Guid, + "the login that was just added should be selected" + ); + Assert.ok( + !loginItem.classList.contains("no-logins"), + "login-item should not be marked as having no-logins" + ); + Assert.equal( + Cu.waiveXrays(loginItem)._login.guid, + testLogin1Guid, + "the login-item should have the newly added login selected" + ); + Assert.ok( + !ContentTaskUtils.is_hidden(loginItem), + "login-item should be visible" + ); + Assert.ok( + ContentTaskUtils.is_hidden(loginIntro), + "login-intro should be hidden" + ); + } + ); + } +); diff --git a/browser/components/aboutlogins/tests/browser/browser_openExport.js b/browser/components/aboutlogins/tests/browser/browser_openExport.js new file mode 100644 index 0000000000..c5df84c447 --- /dev/null +++ b/browser/components/aboutlogins/tests/browser/browser_openExport.js @@ -0,0 +1,149 @@ +/* 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/. */ + +"use strict"; + +/** + * Test the export logins file picker appears. + */ + +let { OSKeyStore } = ChromeUtils.importESModule( + "resource://gre/modules/OSKeyStore.sys.mjs" +); +let { TelemetryTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TelemetryTestUtils.sys.mjs" +); + +let { MockFilePicker } = SpecialPowers; + +add_setup(async function () { + await TestUtils.waitForCondition(() => { + Services.telemetry.clearEvents(); + let events = Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS, + true + ).content; + return !events || !events.length; + }, "Waiting for content telemetry events to get cleared"); + + MockFilePicker.init(window); + MockFilePicker.useAnyFile(); + MockFilePicker.returnValue = MockFilePicker.returnOK; + + registerCleanupFunction(() => { + MockFilePicker.cleanup(); + }); +}); + +function waitForFilePicker() { + return new Promise(resolve => { + MockFilePicker.showCallback = () => { + MockFilePicker.showCallback = null; + Assert.ok(true, "Saw the file picker"); + resolve(); + }; + }); +} + +add_task(async function test_open_export() { + await BrowserTestUtils.withNewTab( + { gBrowser, url: "about:logins" }, + async function (browser) { + await BrowserTestUtils.synthesizeMouseAtCenter( + "menu-button", + {}, + browser + ); + + await SpecialPowers.spawn(browser, [], async () => { + let menuButton = content.document.querySelector("menu-button"); + return ContentTaskUtils.waitForCondition(function waitForMenu() { + return !menuButton.shadowRoot.querySelector(".menu").hidden; + }, "waiting for menu to open"); + }); + + function getExportMenuItem() { + let menuButton = window.document.querySelector("menu-button"); + let exportButton = + menuButton.shadowRoot.querySelector(".menuitem-export"); + return exportButton; + } + + await BrowserTestUtils.synthesizeMouseAtCenter( + getExportMenuItem, + {}, + browser + ); + + // First event is for opening about:logins + await LoginTestUtils.telemetry.waitForEventCount(2); + TelemetryTestUtils.assertEvents( + [["pwmgr", "mgmt_menu_item_used", "export"]], + { category: "pwmgr", method: "mgmt_menu_item_used" }, + { process: "content" } + ); + + info("Clicking confirm button"); + let osReAuthPromise = null; + + if ( + OSKeyStore.canReauth() && + !OSKeyStoreTestUtils.canTestOSKeyStoreLogin() + ) { + todo( + OSKeyStoreTestUtils.canTestOSKeyStoreLogin(), + "Cannot test OS key store login in this build." + ); + return; + } + + if (OSKeyStore.canReauth()) { + osReAuthPromise = OSKeyStoreTestUtils.waitForOSKeyStoreLogin(true); + } + let filePicker = waitForFilePicker(); + await BrowserTestUtils.synthesizeMouseAtCenter( + () => { + let confirmExportDialog = window.document.querySelector( + "confirmation-dialog" + ); + return confirmExportDialog.shadowRoot.querySelector( + ".confirm-button" + ); + }, + {}, + browser + ); + + if (osReAuthPromise) { + Assert.ok(osReAuthPromise, "Waiting for OS re-auth promise"); + await osReAuthPromise; + } + + info("waiting for Export file picker to get opened"); + await filePicker; + Assert.ok(true, "Export file picker opened"); + + info("Waiting for the export to complete"); + let expectedEvents = [ + [ + "pwmgr", + "reauthenticate", + "os_auth", + osReAuthPromise ? "success" : "success_unsupported_platform", + ], + ["pwmgr", "mgmt_menu_item_used", "export_complete"], + ]; + await LoginTestUtils.telemetry.waitForEventCount( + expectedEvents.length, + "parent" + ); + + TelemetryTestUtils.assertEvents( + expectedEvents, + { category: "pwmgr", method: /(reauthenticate|mgmt_menu_item_used)/ }, + { process: "parent" } + ); + } + ); +}); diff --git a/browser/components/aboutlogins/tests/browser/browser_openFiltered.js b/browser/components/aboutlogins/tests/browser/browser_openFiltered.js new file mode 100644 index 0000000000..fcd9692065 --- /dev/null +++ b/browser/components/aboutlogins/tests/browser/browser_openFiltered.js @@ -0,0 +1,295 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_setup(async function () { + let storageChangedPromised = TestUtils.topicObserved( + "passwordmgr-storage-changed", + (_, data) => data == "addLogin" + ); + TEST_LOGIN1 = await Services.logins.addLoginAsync(TEST_LOGIN1); + await storageChangedPromised; + storageChangedPromised = TestUtils.topicObserved( + "passwordmgr-storage-changed", + (_, data) => data == "addLogin" + ); + TEST_LOGIN2 = await Services.logins.addLoginAsync(TEST_LOGIN2); + await storageChangedPromised; + let tabOpenedPromise = BrowserTestUtils.waitForNewTab( + gBrowser, + url => + url.includes( + `about:logins?filter=${encodeURIComponent(TEST_LOGIN1.origin)}` + ), + true + ); + LoginHelper.openPasswordManager(window, { + filterString: TEST_LOGIN1.origin, + entryPoint: "preferences", + }); + await tabOpenedPromise; + registerCleanupFunction(() => { + BrowserTestUtils.removeTab(gBrowser.selectedTab); + Services.logins.removeAllUserFacingLogins(); + }); +}); + +add_task(async function test_query_parameter_filter() { + let browser = gBrowser.selectedBrowser; + let vanillaLogins = [ + LoginHelper.loginToVanillaObject(TEST_LOGIN1), + LoginHelper.loginToVanillaObject(TEST_LOGIN2), + ]; + await SpecialPowers.spawn(browser, [vanillaLogins], async logins => { + const loginList = Cu.waiveXrays( + content.document.querySelector("login-list") + ); + await ContentTaskUtils.waitForCondition(() => { + return loginList._loginGuidsSortedOrder.length == 2; + }, "Waiting for logins to be cached"); + + await ContentTaskUtils.waitForCondition(() => { + const selectedLoginItem = Cu.waiveXrays( + loginList.shadowRoot.querySelector("li[aria-selected='true']") + ); + return selectedLoginItem.dataset.guid === logins[0].guid; + }, "Waiting for TEST_LOGIN1 to be selected for the login-item view"); + + const loginItem = Cu.waiveXrays( + content.document.querySelector("login-item") + ); + + Assert.ok( + ContentTaskUtils.is_visible(loginItem), + "login-item should be visible when a login is selected" + ); + const loginIntro = content.document.querySelector("login-intro"); + Assert.ok( + ContentTaskUtils.is_hidden(loginIntro), + "login-intro should be hidden when a login is selected" + ); + + const loginFilter = loginList.shadowRoot.querySelector("login-filter"); + + const xRayLoginFilter = Cu.waiveXrays(loginFilter); + Assert.equal( + xRayLoginFilter.value, + logins[0].origin, + "The filter should be prepopulated" + ); + Assert.equal( + loginList.shadowRoot.activeElement, + loginFilter, + "login-filter should be focused" + ); + Assert.equal( + loginFilter.shadowRoot.activeElement, + loginFilter.shadowRoot.querySelector(".filter"), + "the actual input inside of login-filter should be focused" + ); + + let hiddenLoginListItems = loginList.shadowRoot.querySelectorAll( + ".login-list-item[hidden]" + ); + let visibleLoginListItems = loginList.shadowRoot.querySelectorAll( + ".login-list-item:not([hidden])" + ); + Assert.equal( + visibleLoginListItems.length, + 1, + "The one login should be visible" + ); + Assert.equal( + visibleLoginListItems[0].dataset.guid, + logins[0].guid, + "TEST_LOGIN1 should be visible" + ); + Assert.equal( + hiddenLoginListItems.length, + 2, + "One saved login and one blank login should be hidden" + ); + Assert.equal( + hiddenLoginListItems[0].id, + "new-login-list-item", + "#new-login-list-item should be hidden" + ); + Assert.equal( + hiddenLoginListItems[1].dataset.guid, + logins[1].guid, + "TEST_LOGIN2 should be hidden" + ); + }); +}); + +add_task(async function test_query_parameter_filter_no_logins_for_site() { + BrowserTestUtils.removeTab(gBrowser.selectedTab); + const HOSTNAME_WITH_NO_LOGINS = "xxx-no-logins-for-site-xxx"; + let tabOpenedPromise = BrowserTestUtils.waitForNewTab( + gBrowser, + url => + url.includes( + `about:logins?filter=${encodeURIComponent(HOSTNAME_WITH_NO_LOGINS)}` + ), + true + ); + LoginHelper.openPasswordManager(window, { + filterString: HOSTNAME_WITH_NO_LOGINS, + entryPoint: "preferences", + }); + await tabOpenedPromise; + + let browser = gBrowser.selectedBrowser; + await SpecialPowers.spawn(browser, [], async () => { + let loginList = Cu.waiveXrays(content.document.querySelector("login-list")); + await ContentTaskUtils.waitForCondition(() => { + return loginList._loginGuidsSortedOrder.length == 2; + }, "Waiting for logins to be cached"); + Assert.equal( + loginList._loginGuidsSortedOrder.length, + 2, + "login list should have two logins stored" + ); + + Assert.ok( + ContentTaskUtils.is_hidden(loginList._list), + "the login list should be hidden when there is a search with no results" + ); + let intro = loginList.shadowRoot.querySelector(".intro"); + Assert.ok( + ContentTaskUtils.is_hidden(intro), + "the intro should be hidden when there is a search with no results" + ); + let emptySearchMessage = loginList.shadowRoot.querySelector( + ".empty-search-message" + ); + Assert.ok( + ContentTaskUtils.is_visible(emptySearchMessage), + "the empty search message should be visible when there is a search with no results" + ); + + let visibleLoginListItems = loginList.shadowRoot.querySelectorAll( + ".login-list-item:not([hidden])" + ); + Assert.equal(visibleLoginListItems.length, 0, "No login should be visible"); + + Assert.ok( + !loginList._createLoginButton.disabled, + "create button should be enabled" + ); + + let loginItem = content.document.querySelector("login-item"); + Assert.ok(!loginItem.dataset.isNewLogin, "should not be in create mode"); + Assert.ok(!loginItem.dataset.editing, "should not be in edit mode"); + Assert.ok( + ContentTaskUtils.is_hidden(loginItem), + "login-item should be hidden when a login is not selected and we're not in create mode" + ); + let loginIntro = content.document.querySelector("login-intro"); + Assert.ok( + ContentTaskUtils.is_hidden(loginIntro), + "login-intro should be hidden when a login is not selected and we're not in create mode" + ); + + loginList._createLoginButton.click(); + + Assert.ok(loginItem.dataset.isNewLogin, "should be in create mode"); + Assert.ok(loginItem.dataset.editing, "should be in edit mode"); + Assert.ok( + ContentTaskUtils.is_visible(loginItem), + "login-item should be visible in create mode" + ); + Assert.ok( + ContentTaskUtils.is_hidden(loginIntro), + "login-intro should be hidden in create mode" + ); + }); +}); + +add_task(async function test_query_parameter_filter_no_login_until_backspace() { + BrowserTestUtils.removeTab(gBrowser.selectedTab); + let tabOpenedPromise = BrowserTestUtils.waitForNewTab( + gBrowser, + "about:logins?filter=" + encodeURIComponent(TEST_LOGIN1.origin) + "x", + true + ); + LoginHelper.openPasswordManager(window, { + filterString: TEST_LOGIN1.origin + "x", + entryPoint: "preferences", + }); + await tabOpenedPromise; + + let browser = gBrowser.selectedBrowser; + await SpecialPowers.spawn(browser, [], async () => { + let loginList = Cu.waiveXrays(content.document.querySelector("login-list")); + await ContentTaskUtils.waitForCondition(() => { + return loginList._loginGuidsSortedOrder.length == 2; + }, "Waiting for logins to be cached"); + Assert.equal( + loginList._loginGuidsSortedOrder.length, + 2, + "login list should have two logins stored" + ); + + Assert.ok( + ContentTaskUtils.is_hidden(loginList._list), + "the login list should be hidden when there is a search with no results" + ); + + // Backspace the trailing 'x' to get matching logins + const EventUtils = ContentTaskUtils.getEventUtils(content); + EventUtils.sendChar("KEY_Backspace", content); + + let intro = loginList.shadowRoot.querySelector(".intro"); + Assert.ok( + ContentTaskUtils.is_hidden(intro), + "the intro should be hidden when there is no selection" + ); + let emptySearchMessage = loginList.shadowRoot.querySelector( + ".empty-search-message" + ); + Assert.ok( + ContentTaskUtils.is_hidden(emptySearchMessage), + "the empty search message should be hidden when there is matching logins" + ); + + let visibleLoginListItems = loginList.shadowRoot.querySelectorAll( + ".login-list-item:not([hidden])" + ); + Assert.equal( + visibleLoginListItems.length, + 1, + "One login should be visible after backspacing" + ); + + Assert.ok( + !loginList._createLoginButton.disabled, + "create button should be enabled" + ); + + let loginItem = content.document.querySelector("login-item"); + Assert.ok(!loginItem.dataset.isNewLogin, "should not be in create mode"); + Assert.ok(!loginItem.dataset.editing, "should not be in edit mode"); + Assert.ok( + ContentTaskUtils.is_hidden(loginItem), + "login-item should be hidden when a login is not selected and we're not in create mode" + ); + let loginIntro = content.document.querySelector("login-intro"); + Assert.ok( + ContentTaskUtils.is_hidden(loginIntro), + "login-intro should be hidden when a login is not selected and we're not in create mode" + ); + + loginList._createLoginButton.click(); + + Assert.ok(loginItem.dataset.isNewLogin, "should be in create mode"); + Assert.ok(loginItem.dataset.editing, "should be in edit mode"); + Assert.ok( + ContentTaskUtils.is_visible(loginItem), + "login-item should be visible in create mode" + ); + Assert.ok( + ContentTaskUtils.is_hidden(loginIntro), + "login-intro should be hidden in create mode" + ); + }); +}); diff --git a/browser/components/aboutlogins/tests/browser/browser_openImport.js b/browser/components/aboutlogins/tests/browser/browser_openImport.js new file mode 100644 index 0000000000..627e0d6e3b --- /dev/null +++ b/browser/components/aboutlogins/tests/browser/browser_openImport.js @@ -0,0 +1,60 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +let { TelemetryTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TelemetryTestUtils.sys.mjs" +); + +add_setup(async function () { + await TestUtils.waitForCondition(() => { + Services.telemetry.clearEvents(); + let events = Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS, + true + ).content; + return !events || !events.length; + }, "Waiting for content telemetry events to get cleared"); + + let aboutLoginsTab = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: "about:logins", + }); + registerCleanupFunction(() => { + BrowserTestUtils.removeTab(aboutLoginsTab); + }); +}); + +add_task(async function test_open_import() { + let promiseImportWindow = BrowserTestUtils.waitForMigrationWizard(window); + + let browser = gBrowser.selectedBrowser; + await BrowserTestUtils.synthesizeMouseAtCenter("menu-button", {}, browser); + await SpecialPowers.spawn(browser, [], async () => { + return ContentTaskUtils.waitForCondition(() => { + let menuButton = Cu.waiveXrays( + content.document.querySelector("menu-button") + ); + return !menuButton.shadowRoot.querySelector(".menu").hidden; + }, "waiting for menu to open"); + }); + + function getImportItem() { + let menuButton = window.document.querySelector("menu-button"); + return menuButton.shadowRoot.querySelector(".menuitem-import-browser"); + } + await BrowserTestUtils.synthesizeMouseAtCenter(getImportItem, {}, browser); + + info("waiting for Import to get opened"); + let importWindow = await promiseImportWindow; + Assert.ok(true, "Import opened"); + + // First event is for opening about:logins + await LoginTestUtils.telemetry.waitForEventCount(2); + TelemetryTestUtils.assertEvents( + [["pwmgr", "mgmt_menu_item_used", "import_from_browser"]], + { category: "pwmgr", method: "mgmt_menu_item_used" }, + { process: "content" } + ); + + await BrowserTestUtils.closeMigrationWizard(importWindow); +}); diff --git a/browser/components/aboutlogins/tests/browser/browser_openImportCSV.js b/browser/components/aboutlogins/tests/browser/browser_openImportCSV.js new file mode 100644 index 0000000000..c4994215d8 --- /dev/null +++ b/browser/components/aboutlogins/tests/browser/browser_openImportCSV.js @@ -0,0 +1,411 @@ +/* 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/. */ + +const { FileTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/FileTestUtils.sys.mjs" +); + +let { TelemetryTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TelemetryTestUtils.sys.mjs" +); + +let { MockFilePicker } = SpecialPowers; + +/** + * A helper class to deal with Login CSV import UI. + */ +class CsvImportHelper { + /** + * Waits until the mock file picker is opened and sets the destFilePath as it's selected file. + * + * @param {nsIFile} destFile + * The file being passed to the picker. + * @returns {string} A promise that is resolved when the picker selects the file. + */ + static waitForOpenFilePicker(destFile) { + return new Promise(resolve => { + MockFilePicker.showCallback = fp => { + info("showCallback"); + info("fileName: " + destFile.path); + MockFilePicker.setFiles([destFile]); + MockFilePicker.filterIndex = 1; + info("done showCallback"); + resolve(); + }; + }); + } + + /** + * Clicks the 3 dot menu and then "Import from a file..." and then it serves a CSV file. + * It also does the needed assertions and telemetry validations. + * If you await for it to return, it will have processed the CSV file already. + * + * @param {browser} browser + * The browser object. + * @param {string[]} linesInFile + * An array of strings to be used to generate the CSV file. Each string is a line. + * @returns {Promise} A promise that is resolved when the picker selects the file. + */ + static async clickImportFromCsvMenu(browser, linesInFile) { + MockFilePicker.init(window); + MockFilePicker.returnValue = MockFilePicker.returnOK; + let csvFile = await LoginTestUtils.file.setupCsvFileWithLines(linesInFile); + + await BrowserTestUtils.synthesizeMouseAtCenter("menu-button", {}, browser); + + await SpecialPowers.spawn(browser, [], async () => { + let menuButton = content.document.querySelector("menu-button"); + return ContentTaskUtils.waitForCondition(function waitForMenu() { + return !menuButton.shadowRoot.querySelector(".menu").hidden; + }, "waiting for menu to open"); + }); + + Services.telemetry.clearEvents(); + + function getImportMenuItem() { + let menuButton = window.document.querySelector("menu-button"); + let importButton = menuButton.shadowRoot.querySelector( + ".menuitem-import-file" + ); + // Force the menu item to be visible for the test. + importButton.hidden = false; + return importButton; + } + + BrowserTestUtils.synthesizeMouseAtCenter(getImportMenuItem, {}, browser); + + async function waitForFilePicker() { + let filePickerPromise = CsvImportHelper.waitForOpenFilePicker(csvFile); + // First event is for opening about:logins + await LoginTestUtils.telemetry.waitForEventCount( + 1, + "content", + "pwmgr", + "mgmt_menu_item_used" + ); + TelemetryTestUtils.assertEvents( + [["pwmgr", "mgmt_menu_item_used", "import_from_csv"]], + { category: "pwmgr", method: "mgmt_menu_item_used" }, + { process: "content", clear: false } + ); + + info("waiting for Import file picker to get opened"); + await filePickerPromise; + Assert.ok(true, "Import file picker opened"); + } + + await waitForFilePicker(); + } + + /** + * An utility method to fetch the data from the CSV import success dialog. + * + * @param {browser} browser + * The browser object. + * @returns {Promise<Object>} A promise that contains added, modified, noChange and errors count. + */ + static async getCsvImportSuccessDialogData(browser) { + return SpecialPowers.spawn(browser, [], async () => { + let dialog = Cu.waiveXrays( + content.document.querySelector("import-summary-dialog") + ); + await ContentTaskUtils.waitForCondition( + () => !dialog.hidden, + "Waiting for the dialog to be visible" + ); + + let added = dialog.shadowRoot.querySelector( + ".import-items-added .result-count" + ).textContent; + let modified = dialog.shadowRoot.querySelector( + ".import-items-modified .result-count" + ).textContent; + let noChange = dialog.shadowRoot.querySelector( + ".import-items-no-change .result-count" + ).textContent; + let errors = dialog.shadowRoot.querySelector( + ".import-items-errors .result-count" + ).textContent; + const dialogData = { + added, + modified, + noChange, + errors, + }; + if (dialog.shadowRoot.activeElement) { + dialogData.l10nFocused = + dialog.shadowRoot.activeElement.getAttribute("data-l10n-id"); + } + return dialogData; + }); + } + + /** + * An utility method to fetch the data from the CSV import error dialog. + * + * @param {browser} browser + * The browser object. + * @returns {Promise<Object>} A promise that contains the hidden state and l10n id for title, description and focused element. + */ + static async getCsvImportErrorDialogData(browser) { + return SpecialPowers.spawn(browser, [], async () => { + const dialog = Cu.waiveXrays( + content.document.querySelector("import-error-dialog") + ); + const l10nTitle = dialog._genericDialog + .querySelector(".error-title") + .getAttribute("data-l10n-id"); + const l10nDescription = dialog._genericDialog + .querySelector(".error-description") + .getAttribute("data-l10n-id"); + return { + hidden: dialog.hidden, + l10nFocused: + dialog.shadowRoot.activeElement.getAttribute("data-l10n-id"), + l10nTitle, + l10nDescription, + }; + }); + } + + /** + * An utility method to wait until CSV import is complete. + * + * @returns {Promise} A promise that gets resolved when the import is complete. + */ + static async waitForImportToComplete() { + info("Waiting for the import to complete"); + await LoginTestUtils.telemetry.waitForEventCount(1, "parent"); + TelemetryTestUtils.assertEvents( + [["pwmgr", "mgmt_menu_item_used", "import_csv_complete"]], + { category: "pwmgr", method: "mgmt_menu_item_used" }, + { process: "parent" } + ); + } + + /** + * An utility method open the about:loginsimportreport page. + * + * @param {browser} browser + * The browser object. + * @returns {Promise<Object>} A promise that contains the about:loginsimportreport tab. + */ + static async clickDetailedReport(browser) { + let loadedReportTab = BrowserTestUtils.waitForNewTab( + gBrowser, + "about:loginsimportreport", + true + ); + await SpecialPowers.spawn(browser, [], async () => { + let dialog = Cu.waiveXrays( + content.document.querySelector("import-summary-dialog") + ); + await ContentTaskUtils.waitForCondition( + () => !dialog.hidden, + "Waiting for the dialog to be visible" + ); + let detailedReportLink = dialog.shadowRoot.querySelector( + ".open-detailed-report" + ); + + detailedReportLink.click(); + }); + return loadedReportTab; + } + + /** + * An utility method to fetch data from the about:loginsimportreport page. + * + * @param {browser} browser + * The browser object. + * @returns {Promise<Object>} A promise that contains the detailed report data like added, modified, noChange, errors and rows. + */ + static async getDetailedReportData(browser) { + const data = await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [], + async () => { + function getCount(selector) { + const attribute = content.document + .querySelector(selector) + .getAttribute("data-l10n-args"); + return JSON.parse(attribute).count; + } + const rows = []; + for (let element of content.document.querySelectorAll(".row-details")) { + rows.push(element.getAttribute("data-l10n-id")); + } + const added = getCount(".new-logins"); + const modified = getCount(".exiting-logins"); + const noChange = getCount(".duplicate-logins"); + const errors = getCount(".errors-logins"); + return { + rows, + added, + modified, + noChange, + errors, + }; + } + ); + return data; + } +} + +const random = Math.round(Math.random() * 100000001); + +add_setup(async function () { + registerCleanupFunction(() => { + Services.logins.removeAllUserFacingLogins(); + }); +}); + +add_task(async function test_open_import_one_item_from_csv() { + await BrowserTestUtils.withNewTab( + { gBrowser, url: "about:logins" }, + async browser => { + await CsvImportHelper.clickImportFromCsvMenu(browser, [ + "url,username,password,httpRealm,formActionOrigin,guid,timeCreated,timeLastUsed,timePasswordChanged", + `https://example.com,joe${random}@example.com,qwerty,My realm,,{${random}-e194-4279-ae1b-d7d281bb46f0},1589617814635,1589710449871,1589617846802`, + ]); + await CsvImportHelper.waitForImportToComplete(); + + let summary = await CsvImportHelper.getCsvImportSuccessDialogData( + browser + ); + Assert.equal(summary.added, "1", "It should have one item as added"); + Assert.equal( + summary.l10nFocused, + "about-logins-import-dialog-done", + "dismiss button should be focused" + ); + } + ); +}); + +add_task(async function test_open_import_all_four_categories() { + await BrowserTestUtils.withNewTab( + { gBrowser, url: "about:logins" }, + async browser => { + const initialCsvData = [ + "url,username,password,httpRealm,formActionOrigin,guid,timeCreated,timeLastUsed,timePasswordChanged", + `https://example1.com,existing${random},existing,,,{${random}-07a1-4bcf-86f0-7d56b9c1f48f},1582229924361,1582495972623,1582229924000`, + `https://example1.com,duplicate,duplicate,,,{dddd0080-07a1-4bcf-86f0-7d56b9c1f48f},1582229924361,1582495972623,1582229924363`, + ]; + const updatedCsvData = [ + "url,username,password,httpRealm,formActionOrigin,guid,timeCreated,timeLastUsed,timePasswordChanged", + `https://example1.com,added${random},added,,,,,,`, + `https://example1.com,existing${random},modified,,,{${random}-07a1-4bcf-86f0-7d56b9c1f48f},1582229924361,1582495972623,1582229924363`, + `https://example1.com,duplicate,duplicate,,,{dddd0080-07a1-4bcf-86f0-7d56b9c1f48f},1582229924361,1582495972623,1582229924363`, + `https://example1.com,error,,,,,,,`, + ]; + + await CsvImportHelper.clickImportFromCsvMenu(browser, initialCsvData); + await CsvImportHelper.waitForImportToComplete(); + await BrowserTestUtils.synthesizeMouseAtCenter( + "dismiss-button", + {}, + browser + ); + await CsvImportHelper.clickImportFromCsvMenu(browser, updatedCsvData); + await CsvImportHelper.waitForImportToComplete(); + + let summary = await CsvImportHelper.getCsvImportSuccessDialogData( + browser + ); + Assert.equal(summary.added, "1", "It should have one item as added"); + Assert.equal( + summary.modified, + "1", + "It should have one item as modified" + ); + Assert.equal( + summary.noChange, + "1", + "It should have one item as unchanged" + ); + Assert.equal(summary.errors, "1", "It should have one item as error"); + } + ); +}); + +add_task(async function test_open_import_all_four_detailed_report() { + await BrowserTestUtils.withNewTab( + { gBrowser, url: "about:logins" }, + async browser => { + const initialCsvData = [ + "url,username,password,httpRealm,formActionOrigin,guid,timeCreated,timeLastUsed,timePasswordChanged", + `https://example2.com,existing${random},existing,,,{${random}-07a1-4bcf-86f0-7d56b9c1f48f},1582229924361,1582495972623,1582229924000`, + "https://example2.com,duplicate,duplicate,,,{dddd0080-07a1-4bcf-86f0-7d56b9c1f48f},1582229924361,1582495972623,1582229924363", + ]; + const updatedCsvData = [ + "url,username,password,httpRealm,formActionOrigin,guid,timeCreated,timeLastUsed,timePasswordChanged", + `https://example2.com,added${random},added,,,,,,`, + `https://example2.com,existing${random},modified,,,{${random}-07a1-4bcf-86f0-7d56b9c1f48f},1582229924361,1582495972623,1582229924363`, + "https://example2.com,duplicate,duplicate,,,{dddd0080-07a1-4bcf-86f0-7d56b9c1f48f},1582229924361,1582495972623,1582229924363", + "https://example2.com,error,,,,,,,", + ]; + + await CsvImportHelper.clickImportFromCsvMenu(browser, initialCsvData); + await CsvImportHelper.waitForImportToComplete(); + await BrowserTestUtils.synthesizeMouseAtCenter( + "dismiss-button", + {}, + browser + ); + await CsvImportHelper.clickImportFromCsvMenu(browser, updatedCsvData); + await CsvImportHelper.waitForImportToComplete(); + const reportTab = await CsvImportHelper.clickDetailedReport(browser); + const report = await CsvImportHelper.getDetailedReportData(browser); + BrowserTestUtils.removeTab(reportTab); + const { added, modified, noChange, errors, rows } = report; + Assert.equal(added, 1, "It should have one item as added"); + Assert.equal(modified, 1, "It should have one item as modified"); + Assert.equal(noChange, 1, "It should have one item as unchanged"); + Assert.equal(errors, 1, "It should have one item as error"); + Assert.deepEqual( + [ + "about-logins-import-report-row-description-added", + "about-logins-import-report-row-description-modified", + "about-logins-import-report-row-description-no-change", + "about-logins-import-report-row-description-error-missing-field", + ], + rows, + "It should have expected rows in order" + ); + } + ); +}); + +add_task(async function test_open_import_from_csv_with_invalid_file() { + await BrowserTestUtils.withNewTab( + { gBrowser, url: "about:logins" }, + async browser => { + await CsvImportHelper.clickImportFromCsvMenu(browser, [ + "invalid csv file", + ]); + + info("Waiting for the import error dialog"); + const errorDialog = await CsvImportHelper.getCsvImportErrorDialogData( + browser + ); + Assert.equal(errorDialog.hidden, false, "Dialog should not be hidden"); + Assert.equal( + errorDialog.l10nTitle, + "about-logins-import-dialog-error-file-format-title", + "Dialog error title should be correct" + ); + Assert.equal( + errorDialog.l10nDescription, + "about-logins-import-dialog-error-file-format-description", + "Dialog error description should be correct" + ); + Assert.equal( + errorDialog.l10nFocused, + "about-logins-import-dialog-error-learn-more", + "Learn more link should be focused." + ); + } + ); +}); diff --git a/browser/components/aboutlogins/tests/browser/browser_openPreferences.js b/browser/components/aboutlogins/tests/browser/browser_openPreferences.js new file mode 100644 index 0000000000..57ca74ba87 --- /dev/null +++ b/browser/components/aboutlogins/tests/browser/browser_openPreferences.js @@ -0,0 +1,82 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +let { TelemetryTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TelemetryTestUtils.sys.mjs" +); + +add_setup(async function () { + await TestUtils.waitForCondition(() => { + Services.telemetry.clearEvents(); + let events = Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS, + true + ).content; + return !events || !events.length; + }, "Waiting for content telemetry events to get cleared"); + + await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: "about:logins", + }); + registerCleanupFunction(() => { + BrowserTestUtils.removeTab(gBrowser.selectedTab); + }); +}); + +add_task(async function test_open_preferences() { + // We want to make sure we visit about:preferences#privacy-logins , as that is + // what causes us to scroll to and highlight the "logins" section. However, + // about:preferences will redirect the URL, so the eventual load event will happen + // on about:preferences#privacy . The `wantLoad` parameter we pass to + // `waitForNewTab` needs to take this into account: + let seenFirstURL = false; + let promiseNewTab = BrowserTestUtils.waitForNewTab( + gBrowser, + url => { + if (url == "about:preferences#privacy-logins") { + seenFirstURL = true; + return true; + } else if (url == "about:preferences#privacy") { + Assert.ok( + seenFirstURL, + "Must have seen an onLocationChange notification for the privacy-logins hash" + ); + return true; + } + return false; + }, + true + ); + + let browser = gBrowser.selectedBrowser; + await BrowserTestUtils.synthesizeMouseAtCenter("menu-button", {}, browser); + await SpecialPowers.spawn(browser, [], async () => { + return ContentTaskUtils.waitForCondition(() => { + let menuButton = Cu.waiveXrays( + content.document.querySelector("menu-button") + ); + return !menuButton.shadowRoot.querySelector(".menu").hidden; + }, "waiting for menu to open"); + }); + + function getPrefsItem() { + let menuButton = window.document.querySelector("menu-button"); + return menuButton.shadowRoot.querySelector(".menuitem-preferences"); + } + await BrowserTestUtils.synthesizeMouseAtCenter(getPrefsItem, {}, browser); + + info("waiting for new tab to get opened"); + let newTab = await promiseNewTab; + Assert.ok(true, "New tab opened to about:preferences"); + + BrowserTestUtils.removeTab(newTab); + + // First event is for opening about:logins + await LoginTestUtils.telemetry.waitForEventCount(2); + TelemetryTestUtils.assertEvents( + [["pwmgr", "mgmt_menu_item_used", "preferences"]], + { category: "pwmgr", method: "mgmt_menu_item_used" }, + { process: "content" } + ); +}); diff --git a/browser/components/aboutlogins/tests/browser/browser_openPreferencesExternal.js b/browser/components/aboutlogins/tests/browser/browser_openPreferencesExternal.js new file mode 100644 index 0000000000..e4290371fb --- /dev/null +++ b/browser/components/aboutlogins/tests/browser/browser_openPreferencesExternal.js @@ -0,0 +1,65 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_setup(async function () { + await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: "about:logins", + }); + registerCleanupFunction(() => { + BrowserTestUtils.removeTab(gBrowser.selectedTab); + }); +}); + +add_task(async function test_open_feedback() { + const menuArray = [ + { + urlFinal: + "https://example.com/password-manager-remember-delete-edit-logins", + urlBase: "https://example.com/", + pref: "app.support.baseURL", + selector: ".menuitem-help", + }, + ]; + + for (const { urlFinal, urlBase, pref, selector } of menuArray) { + info("Test on " + urlFinal); + + await SpecialPowers.pushPrefEnv({ + set: [[pref, urlBase]], + }); + + let promiseNewTab = BrowserTestUtils.waitForNewTab(gBrowser, urlFinal); + + let browser = gBrowser.selectedBrowser; + await BrowserTestUtils.synthesizeMouseAtCenter("menu-button", {}, browser); + await SpecialPowers.spawn(browser, [], async () => { + return ContentTaskUtils.waitForCondition(() => { + let menuButton = content.document.querySelector("menu-button"); + return !menuButton.shadowRoot.querySelector(".menu").hidden; + }, "waiting for menu to open"); + }); + + // Not using synthesizeMouseAtCenter here because the element we want clicked on + // is in the shadow DOM and therefore requires using a function 1st argument + // to BrowserTestUtils.synthesizeMouseAtCenter but we need to pass an + // arbitrary selector. See bug 1557489 for more info. As a workaround, this + // manually calculates the position to click. + let { x, y } = await SpecialPowers.spawn( + browser, + [selector], + async menuItemSelector => { + let menuButton = content.document.querySelector("menu-button"); + let prefsItem = menuButton.shadowRoot.querySelector(menuItemSelector); + return prefsItem.getBoundingClientRect(); + } + ); + await BrowserTestUtils.synthesizeMouseAtPoint(x + 5, y + 5, {}, browser); + + info("waiting for new tab to get opened"); + let newTab = await promiseNewTab; + Assert.ok(true, "New tab opened to" + urlFinal); + + BrowserTestUtils.removeTab(newTab); + } +}); diff --git a/browser/components/aboutlogins/tests/browser/browser_openSite.js b/browser/components/aboutlogins/tests/browser/browser_openSite.js new file mode 100644 index 0000000000..f33d57a8e4 --- /dev/null +++ b/browser/components/aboutlogins/tests/browser/browser_openSite.js @@ -0,0 +1,94 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_setup(async function () { + TEST_LOGIN1 = await addLogin(TEST_LOGIN1); + await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: "about:logins", + }); + registerCleanupFunction(() => { + BrowserTestUtils.removeTab(gBrowser.selectedTab); + Services.logins.removeAllUserFacingLogins(); + }); +}); + +add_task(async function test_launch_login_item() { + let promiseNewTab = BrowserTestUtils.waitForNewTab( + gBrowser, + TEST_LOGIN1.origin + "/" + ); + + let browser = gBrowser.selectedBrowser; + + await SpecialPowers.spawn(browser, [], async () => { + let loginItem = Cu.waiveXrays(content.document.querySelector("login-item")); + let originInput = loginItem.shadowRoot.querySelector("a[name='origin']"); + let EventUtils = ContentTaskUtils.getEventUtils(content); + // Use synthesizeMouseAtCenter to generate an event that more closely resembles the + // properties of the event object that will be seen when the user clicks the element + // (.click() sets originalTarget while synthesizeMouse has originalTarget as a Restricted object). + await EventUtils.synthesizeMouseAtCenter(originInput, {}, content); + }); + + info("waiting for new tab to get opened"); + let newTab = await promiseNewTab; + Assert.ok(true, "New tab opened to " + TEST_LOGIN1.origin); + BrowserTestUtils.removeTab(newTab); + + if (!OSKeyStoreTestUtils.canTestOSKeyStoreLogin()) { + return; + } + + promiseNewTab = BrowserTestUtils.waitForNewTab( + gBrowser, + TEST_LOGIN1.origin + "/" + ); + let reauthObserved = OSKeyStoreTestUtils.waitForOSKeyStoreLogin(true); + await SpecialPowers.spawn(browser, [], async () => { + let loginItem = Cu.waiveXrays(content.document.querySelector("login-item")); + loginItem._editButton.click(); + }); + await reauthObserved; + await SpecialPowers.spawn(browser, [], async () => { + let loginItem = Cu.waiveXrays(content.document.querySelector("login-item")); + loginItem._usernameInput.value += "-changed"; + + Assert.ok( + content.document.querySelector("confirmation-dialog").hidden, + "discard-changes confirmation-dialog should be hidden before opening the site" + ); + + let originInput = loginItem.shadowRoot.querySelector("a[name='origin']"); + let EventUtils = ContentTaskUtils.getEventUtils(content); + // Use synthesizeMouseAtCenter to generate an event that more closely resembles the + // properties of the event object that will be seen when the user clicks the element + // (.click() sets originalTarget while synthesizeMouse has originalTarget as a Restricted object). + await EventUtils.synthesizeMouseAtCenter(originInput, {}, content); + }); + + info("waiting for new tab to get opened"); + newTab = await promiseNewTab; + Assert.ok(true, "New tab opened to " + TEST_LOGIN1.origin); + + let modifiedLogin = TEST_LOGIN1.clone(); + modifiedLogin.timeLastUsed = 9000; + let storageChangedPromised = TestUtils.topicObserved( + "passwordmgr-storage-changed", + (_, data) => data == "modifyLogin" + ); + Services.logins.modifyLogin(TEST_LOGIN1, modifiedLogin); + await storageChangedPromised; + + BrowserTestUtils.removeTab(newTab); + + await SpecialPowers.spawn(browser, [], async () => { + await ContentTaskUtils.waitForCondition(() => { + return !content.document.querySelector("confirmation-dialog").hidden; + }, "waiting for confirmation-dialog to appear"); + Assert.ok( + !content.document.querySelector("confirmation-dialog").hidden, + "discard-changes confirmation-dialog should be visible after logging in to a site with a modified login present in the form" + ); + }); +}); diff --git a/browser/components/aboutlogins/tests/browser/browser_osAuthDialog.js b/browser/components/aboutlogins/tests/browser/browser_osAuthDialog.js new file mode 100644 index 0000000000..ca054e449a --- /dev/null +++ b/browser/components/aboutlogins/tests/browser/browser_osAuthDialog.js @@ -0,0 +1,165 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_task(async function test() { + info( + `updatechannel: ${UpdateUtils.getUpdateChannel(false)}; platform: ${ + AppConstants.platform + }` + ); + if (!OSKeyStoreTestUtils.canTestOSKeyStoreLogin()) { + Assert.ok( + true, + `skipping test since oskeystore cannot be automated in this environment` + ); + return; + } + + TEST_LOGIN1 = await addLogin(TEST_LOGIN1); + + await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: "about:logins", + }); + + registerCleanupFunction(function () { + Services.logins.removeAllUserFacingLogins(); + BrowserTestUtils.removeTab(gBrowser.selectedTab); + }); + + // Show OS auth dialog when Reveal Password checkbox is checked if not on a new login + let osAuthDialogShown = OSKeyStoreTestUtils.waitForOSKeyStoreLogin(false); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + let loginItem = content.document.querySelector("login-item"); + let revealCheckbox = loginItem.shadowRoot.querySelector( + ".reveal-password-checkbox" + ); + revealCheckbox.click(); + }); + await osAuthDialogShown; + info("OS auth dialog shown and canceled"); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + let loginItem = content.document.querySelector("login-item"); + let revealCheckbox = loginItem.shadowRoot.querySelector( + ".reveal-password-checkbox" + ); + Assert.ok( + !revealCheckbox.checked, + "reveal checkbox should be unchecked if OS auth dialog canceled" + ); + }); + osAuthDialogShown = OSKeyStoreTestUtils.waitForOSKeyStoreLogin(true); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + let loginItem = content.document.querySelector("login-item"); + let revealCheckbox = loginItem.shadowRoot.querySelector( + ".reveal-password-checkbox" + ); + revealCheckbox.click(); + }); + await osAuthDialogShown; + info("OS auth dialog shown and authenticated"); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + let loginItem = content.document.querySelector("login-item"); + let revealCheckbox = loginItem.shadowRoot.querySelector( + ".reveal-password-checkbox" + ); + Assert.ok( + revealCheckbox.checked, + "reveal checkbox should be checked if OS auth dialog authenticated" + ); + }); + + info("'Edit' shouldn't show the prompt since the user has authenticated now"); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + let loginItem = content.document.querySelector("login-item"); + Assert.ok( + !loginItem.dataset.editing, + "Not in edit mode before clicking 'Edit'" + ); + let editButton = loginItem.shadowRoot.querySelector(".edit-button"); + editButton.click(); + + await ContentTaskUtils.waitForCondition( + () => loginItem.dataset.editing, + "waiting for 'edit' mode" + ); + Assert.ok(loginItem.dataset.editing, "In edit mode"); + }); + + info("Test that the OS auth prompt is shown after about:logins is reopened"); + BrowserTestUtils.removeTab(gBrowser.selectedTab); + await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: "about:logins", + }); + + // Show OS auth dialog since the page has been reloaded. + osAuthDialogShown = OSKeyStoreTestUtils.waitForOSKeyStoreLogin(false); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + let loginItem = content.document.querySelector("login-item"); + let revealCheckbox = loginItem.shadowRoot.querySelector( + ".reveal-password-checkbox" + ); + revealCheckbox.click(); + }); + await osAuthDialogShown; + info("OS auth dialog shown and canceled"); + + // Show OS auth dialog since the previous attempt was canceled + osAuthDialogShown = OSKeyStoreTestUtils.waitForOSKeyStoreLogin(true); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + let loginItem = content.document.querySelector("login-item"); + let revealCheckbox = loginItem.shadowRoot.querySelector( + ".reveal-password-checkbox" + ); + revealCheckbox.click(); + info("clicking on reveal checkbox to hide the password"); + revealCheckbox.click(); + }); + await osAuthDialogShown; + info("OS auth dialog shown and passed"); + + // Show OS auth dialog since the timeout will have expired + osAuthDialogShown = forceAuthTimeoutAndWaitForOSKeyStoreLogin({ + loginResult: true, + }); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + let loginItem = content.document.querySelector("login-item"); + let revealCheckbox = loginItem.shadowRoot.querySelector( + ".reveal-password-checkbox" + ); + info("clicking on reveal checkbox to reveal password"); + revealCheckbox.click(); + }); + info("waiting for os auth dialog"); + await osAuthDialogShown; + info("OS auth dialog shown and passed after timeout expiration"); + + // Disable the OS auth feature and confirm the prompt doesn't appear + await SpecialPowers.pushPrefEnv({ + set: [["signon.management.page.os-auth.enabled", false]], + }); + info("Reload about:logins to reset the timeout"); + BrowserTestUtils.removeTab(gBrowser.selectedTab); + await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: "about:logins", + }); + + info("'Edit' shouldn't show the prompt since the feature has been disabled"); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + let loginItem = content.document.querySelector("login-item"); + Assert.ok( + !loginItem.dataset.editing, + "Not in edit mode before clicking 'Edit'" + ); + let editButton = loginItem.shadowRoot.querySelector(".edit-button"); + editButton.click(); + + await ContentTaskUtils.waitForCondition( + () => loginItem.dataset.editing, + "waiting for 'edit' mode" + ); + Assert.ok(loginItem.dataset.editing, "In edit mode"); + }); +}); diff --git a/browser/components/aboutlogins/tests/browser/browser_primaryPassword.js b/browser/components/aboutlogins/tests/browser/browser_primaryPassword.js new file mode 100644 index 0000000000..79a1e9a1da --- /dev/null +++ b/browser/components/aboutlogins/tests/browser/browser_primaryPassword.js @@ -0,0 +1,282 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +function waitForLoginCountToReach(browser, loginCount) { + return SpecialPowers.spawn( + browser, + [loginCount], + async expectedLoginCount => { + let loginList = Cu.waiveXrays( + content.document.querySelector("login-list") + ); + await ContentTaskUtils.waitForCondition(() => { + return loginList._loginGuidsSortedOrder.length == expectedLoginCount; + }); + return loginList._loginGuidsSortedOrder.length; + } + ); +} + +add_setup(async function () { + await addLogin(TEST_LOGIN1); + registerCleanupFunction(() => { + Services.logins.removeAllUserFacingLogins(); + LoginTestUtils.primaryPassword.disable(); + }); +}); + +add_task(async function test() { + // Confirm that the mocking of the OS auth dialog isn't enabled so the + // test will timeout if a real OS auth dialog is shown. We don't show + // the OS auth dialog when Primary Password is enabled. + Assert.equal( + Services.prefs.getStringPref( + "toolkit.osKeyStore.unofficialBuildOnlyLogin", + "" + ), + "", + "Pref should be set to default value of empty string to start the test" + ); + LoginTestUtils.primaryPassword.enable(); + + let mpDialogShown = forceAuthTimeoutAndWaitForMPDialog("cancel"); + await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: "about:logins", + }); + await mpDialogShown; + + let browser = gBrowser.selectedBrowser; + let logins = await waitForLoginCountToReach(browser, 0); + Assert.equal( + logins, + 0, + "No logins should be displayed when MP is set and unauthenticated" + ); + + let notification; + await TestUtils.waitForCondition( + () => + (notification = gBrowser + .getNotificationBox() + .getNotificationWithValue("primary-password-login-required")), + "waiting for primary-password-login-required notification" + ); + + Assert.ok( + notification, + "primary-password-login-required notification should be visible" + ); + + let buttons = notification.buttonContainer.querySelectorAll( + ".notification-button" + ); + Assert.equal(buttons.length, 1, "Should have one button."); + + let refreshPromise = BrowserTestUtils.browserLoaded(browser); + // Sign in with the Primary Password this time the dialog is shown + mpDialogShown = forceAuthTimeoutAndWaitForMPDialog("authenticate"); + // Click the button to reload the page. + buttons[0].click(); + await refreshPromise; + info("Page reloaded"); + + await mpDialogShown; + info("Primary Password dialog shown and authenticated"); + + logins = await waitForLoginCountToReach(browser, 1); + Assert.equal( + logins, + 1, + "Logins should be displayed when MP is set and authenticated" + ); + + // Show MP dialog when Copy Password button clicked + mpDialogShown = forceAuthTimeoutAndWaitForMPDialog("cancel"); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + let loginItem = content.document.querySelector("login-item"); + let copyButton = loginItem.shadowRoot.querySelector( + ".copy-password-button" + ); + copyButton.click(); + }); + await mpDialogShown; + info("Primary Password dialog shown and canceled"); + mpDialogShown = forceAuthTimeoutAndWaitForMPDialog("authenticate"); + info("Clicking copy password button again"); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + let loginItem = content.document.querySelector("login-item"); + let copyButton = loginItem.shadowRoot.querySelector( + ".copy-password-button" + ); + copyButton.click(); + }); + await mpDialogShown; + info("Primary Password dialog shown and authenticated"); + await SpecialPowers.spawn(browser, [], async function () { + let loginItem = content.document.querySelector("login-item"); + let copyButton = loginItem.shadowRoot.querySelector( + ".copy-password-button" + ); + await ContentTaskUtils.waitForCondition(() => { + return copyButton.disabled; + }, "Waiting for copy button to be disabled"); + info("Password was copied to clipboard"); + }); + + // Show MP dialog when Reveal Password checkbox is checked if not on a new login + mpDialogShown = forceAuthTimeoutAndWaitForMPDialog("cancel"); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + let loginItem = content.document.querySelector("login-item"); + let revealCheckbox = loginItem.shadowRoot.querySelector( + ".reveal-password-checkbox" + ); + revealCheckbox.click(); + }); + await mpDialogShown; + info("Primary Password dialog shown and canceled"); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + let loginItem = content.document.querySelector("login-item"); + let revealCheckbox = loginItem.shadowRoot.querySelector( + ".reveal-password-checkbox" + ); + Assert.ok( + !revealCheckbox.checked, + "reveal checkbox should be unchecked if MP dialog canceled" + ); + }); + mpDialogShown = forceAuthTimeoutAndWaitForMPDialog("authenticate"); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + let loginItem = content.document.querySelector("login-item"); + let revealCheckbox = loginItem.shadowRoot.querySelector( + ".reveal-password-checkbox" + ); + revealCheckbox.click(); + }); + await mpDialogShown; + info("Primary Password dialog shown and authenticated"); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + let loginItem = content.document.querySelector("login-item"); + let revealCheckbox = loginItem.shadowRoot.querySelector( + ".reveal-password-checkbox" + ); + Assert.ok( + revealCheckbox.checked, + "reveal checkbox should be checked if MP dialog authenticated" + ); + }); + + info("Test toggling the password visibility on a new login"); + await SpecialPowers.spawn(browser, [], async function createNewToggle() { + let createButton = content.document + .querySelector("login-list") + .shadowRoot.querySelector(".create-login-button"); + createButton.click(); + + let loginItem = Cu.waiveXrays(content.document.querySelector("login-item")); + let passwordField = loginItem.shadowRoot.querySelector( + "input[name='password']" + ); + let revealCheckbox = loginItem.shadowRoot.querySelector( + ".reveal-password-checkbox" + ); + Assert.ok(ContentTaskUtils.is_visible(revealCheckbox), "Toggle visible"); + Assert.ok(!revealCheckbox.checked, "Not revealed initially"); + Assert.equal(passwordField.type, "password", "type is password"); + revealCheckbox.click(); + + await ContentTaskUtils.waitForCondition(() => { + return passwordField.type == "text"; + }, "Waiting for type='text'"); + Assert.ok(revealCheckbox.checked, "Not revealed after click"); + + let cancelButton = loginItem.shadowRoot.querySelector(".cancel-button"); + cancelButton.click(); + }); + + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + const loginList = Cu.waiveXrays( + content.document.querySelector("login-list") + ); + + const loginFilter = Cu.waiveXrays( + loginList.shadowRoot.querySelector("login-filter") + ); + loginFilter.value = "pass1"; + Assert.equal( + loginList._list.querySelectorAll( + ".login-list-item[data-guid]:not([hidden])" + ).length, + 1, + "login-list should show corresponding result when primary password is enabled" + ); + loginFilter.value = ""; + Assert.equal( + loginList._list.querySelectorAll( + ".login-list-item[data-guid]:not([hidden])" + ).length, + 1, + "login-list should show all results since the filter is empty" + ); + }); + LoginTestUtils.primaryPassword.disable(); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + Cu.waiveXrays(content).AboutLoginsUtils.primaryPasswordEnabled = false; + const loginList = Cu.waiveXrays( + content.document.querySelector("login-list") + ); + const loginFilter = Cu.waiveXrays( + loginList.shadowRoot.querySelector("login-filter") + ); + loginFilter.value = "pass1"; + Assert.equal( + loginList._list.querySelectorAll( + ".login-list-item[data-guid]:not([hidden])" + ).length, + 1, + "login-list should show login with matching password since MP is disabled" + ); + }); + + BrowserTestUtils.removeTab(gBrowser.selectedTab); +}); + +add_task(async function test_login_item_after_successful_auth() { + // Confirm that the mocking of the OS auth dialog isn't enabled so the + // test will timeout if a real OS auth dialog is shown. We don't show + // the OS auth dialog when Primary Password is enabled. + Assert.equal( + Services.prefs.getStringPref( + "toolkit.osKeyStore.unofficialBuildOnlyLogin", + "" + ), + "", + "Pref should be set to default value of empty string to start the test" + ); + LoginTestUtils.primaryPassword.enable(); + + let mpDialogShown = forceAuthTimeoutAndWaitForMPDialog("authenticate"); + await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: "about:logins", + }); + await mpDialogShown; + + let browser = gBrowser.selectedBrowser; + let logins = await waitForLoginCountToReach(browser, 1); + Assert.equal( + logins, + 1, + "Logins should be displayed when MP is set and authenticated" + ); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + let loginItem = content.document.querySelector("login-item"); + Assert.ok( + !loginItem.classList.contains("no-logins"), + "Login item should have content after MP is authenticated" + ); + }); + + LoginTestUtils.primaryPassword.disable(); + BrowserTestUtils.removeTab(gBrowser.selectedTab); +}); diff --git a/browser/components/aboutlogins/tests/browser/browser_removeAllDialog.js b/browser/components/aboutlogins/tests/browser/browser_removeAllDialog.js new file mode 100644 index 0000000000..41503e2b4d --- /dev/null +++ b/browser/components/aboutlogins/tests/browser/browser_removeAllDialog.js @@ -0,0 +1,555 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ +/* eslint-disable mozilla/no-arbitrary-setTimeout */ + +const OS_REAUTH_PREF = "signon.management.page.os-auth.enabled"; + +async function openRemoveAllDialog(browser) { + await SimpleTest.promiseFocus(browser); + await BrowserTestUtils.synthesizeMouseAtCenter("menu-button", {}, browser); + await SpecialPowers.spawn(browser, [], async () => { + let menuButton = content.document.querySelector("menu-button"); + let menu = menuButton.shadowRoot.querySelector("ul.menu"); + await ContentTaskUtils.waitForCondition(() => !menu.hidden); + }); + function getRemoveAllMenuButton() { + let menuButton = window.document.querySelector("menu-button"); + return menuButton.shadowRoot.querySelector(".menuitem-remove-all-logins"); + } + await BrowserTestUtils.synthesizeMouseAtCenter( + getRemoveAllMenuButton, + {}, + browser + ); + info("remove all dialog should be opened"); +} + +async function activateLoginItemEdit(browser) { + await SimpleTest.promiseFocus(browser); + await SpecialPowers.spawn(browser, [], async () => { + let loginItem = content.document.querySelector("login-item"); + Assert.ok(loginItem, "Login item should exist"); + }); + function getLoginItemEditButton() { + let loginItem = window.document.querySelector("login-item"); + return loginItem.shadowRoot.querySelector(".edit-button"); + } + await BrowserTestUtils.synthesizeMouseAtCenter( + getLoginItemEditButton, + {}, + browser + ); + await SpecialPowers.spawn(browser, [], async () => { + let loginItem = content.document.querySelector("login-item"); + loginItem.shadowRoot.querySelector(".edit-button").click(); + await ContentTaskUtils.waitForCondition( + () => loginItem.dataset.editing, + "Waiting for login-item to enter edit mode" + ); + }); + info("login-item should be in edit mode"); +} + +async function activateCreateNewLogin(browser) { + await SimpleTest.promiseFocus(browser); + function getCreateNewLoginButton() { + let loginList = window.document.querySelector("login-list"); + return loginList.shadowRoot.querySelector(".create-login-button"); + } + await BrowserTestUtils.synthesizeMouseAtCenter( + getCreateNewLoginButton, + {}, + browser + ); +} + +async function waitForRemoveAllLogins() { + return new Promise(resolve => { + Services.obs.addObserver(function observer(subject, topic, changeType) { + if (changeType != "removeAllLogins") { + return; + } + + Services.obs.removeObserver(observer, "passwordmgr-storage-changed"); + resolve(); + }, "passwordmgr-storage-changed"); + }); +} + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [[OS_REAUTH_PREF, false]], + }); + await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: "about:logins", + }); + registerCleanupFunction(async () => { + BrowserTestUtils.removeTab(gBrowser.selectedTab); + Services.logins.removeAllUserFacingLogins(); + await SpecialPowers.popPrefEnv(); + }); + TEST_LOGIN1 = await addLogin(TEST_LOGIN1); +}); + +add_task(async function test_remove_all_dialog_l10n() { + Assert.ok(TEST_LOGIN1, "test_login1"); + let browser = gBrowser.selectedBrowser; + await openRemoveAllDialog(browser); + await SpecialPowers.spawn(browser, [], async () => { + const EventUtils = ContentTaskUtils.getEventUtils(content); + let dialog = Cu.waiveXrays( + content.document.querySelector("remove-logins-dialog") + ); + Assert.ok(!dialog.hidden); + let title = dialog.shadowRoot.querySelector(".title"); + let message = dialog.shadowRoot.querySelector(".message"); + let label = dialog.shadowRoot.querySelector(".checkbox-text"); + let cancelButton = dialog.shadowRoot.querySelector(".cancel-button"); + let removeAllButton = dialog.shadowRoot.querySelector(".confirm-button"); + await content.document.l10n.translateElements([ + title, + message, + label, + cancelButton, + removeAllButton, + ]); + Assert.equal( + title.dataset.l10nId, + "about-logins-confirm-remove-all-dialog-title", + "Title contents should match l10n-id attribute set on element" + ); + Assert.equal( + message.dataset.l10nId, + "about-logins-confirm-remove-all-dialog-message", + "Message contents should match l10n-id attribute set on element" + ); + Assert.equal( + label.dataset.l10nId, + "about-logins-confirm-remove-all-dialog-checkbox-label", + "Label contents should match l10n-id attribute set on outer element" + ); + Assert.equal( + cancelButton.dataset.l10nId, + "confirmation-dialog-cancel-button", + "Cancel button contents should match l10n-id attribute set on outer element" + ); + Assert.equal( + removeAllButton.dataset.l10nId, + "about-logins-confirm-remove-all-dialog-confirm-button-label", + "Remove all button contents should match l10n-id attribute set on outer element" + ); + Assert.equal( + JSON.parse(title.dataset.l10nArgs).count, + 1, + "Title contents should match l10n-args attribute set on element" + ); + Assert.equal( + JSON.parse(message.dataset.l10nArgs).count, + 1, + "Message contents should match l10n-args attribute set on element" + ); + Assert.equal( + JSON.parse(label.dataset.l10nArgs).count, + 1, + "Label contents should match l10n-id attribute set on outer element" + ); + EventUtils.synthesizeMouseAtCenter( + dialog.shadowRoot.querySelector(".cancel-button"), + {}, + content + ); + await ContentTaskUtils.waitForCondition( + () => dialog.hidden, + "Waiting for the dialog to be hidden after clicking cancel button" + ); + }); +}); + +add_task(async function test_remove_all_dialog_keyboard_navigation() { + let browser = gBrowser.selectedBrowser; + await openRemoveAllDialog(browser); + await SpecialPowers.spawn(browser, [], async () => { + const EventUtils = ContentTaskUtils.getEventUtils(content); + let dialog = Cu.waiveXrays( + content.document.querySelector("remove-logins-dialog") + ); + let cancelButton = dialog.shadowRoot.querySelector(".cancel-button"); + let removeAllButton = dialog.shadowRoot.querySelector(".confirm-button"); + Assert.equal( + removeAllButton.disabled, + true, + "Remove all should be disabled on dialog open" + ); + await EventUtils.synthesizeKey(" ", {}, content); + Assert.equal( + removeAllButton.disabled, + false, + "Remove all should be enabled when activating the checkbox" + ); + await EventUtils.synthesizeKey(" ", {}, content); + Assert.equal( + removeAllButton.disabled, + true, + "Remove all should be disabled after deactivating the checkbox" + ); + await EventUtils.synthesizeKey("KEY_Tab", {}, content); + Assert.equal( + dialog.shadowRoot.activeElement, + cancelButton, + "Cancel button should be the next element in tab order" + ); + await EventUtils.synthesizeKey(" ", {}, content); + await ContentTaskUtils.waitForCondition( + () => dialog.hidden, + "Waiting for the dialog to be hidden after activating cancel button via Space key" + ); + }); + await openRemoveAllDialog(browser); + await SpecialPowers.spawn(browser, [], async () => { + let dialog = Cu.waiveXrays( + content.document.querySelector("remove-logins-dialog") + ); + await EventUtils.synthesizeKey("KEY_Escape", {}, content); + await ContentTaskUtils.waitForCondition( + () => dialog.hidden, + "Waiting for the dialog to be hidden after activating Escape key" + ); + }); + await openRemoveAllDialog(browser); + await SpecialPowers.spawn(browser, [], async () => { + let dialog = Cu.waiveXrays( + content.document.querySelector("remove-logins-dialog") + ); + let dismissButton = dialog.shadowRoot.querySelector(".dismiss-button"); + await EventUtils.synthesizeKey("KEY_Tab", { shiftKey: true }, content); + Assert.equal( + dialog.shadowRoot.activeElement, + dismissButton, + "dismiss button should be focused" + ); + await EventUtils.synthesizeKey(" ", {}, content); + await ContentTaskUtils.waitForCondition( + () => dialog.hidden, + "Waiting for the dialog to be hidden after activating X button" + ); + }); +}); + +add_task(async function test_remove_all_dialog_remove_logins() { + TEST_LOGIN2 = await addLogin(TEST_LOGIN2); + let browser = gBrowser.selectedBrowser; + let removeAllPromise = waitForRemoveAllLogins(); + + await openRemoveAllDialog(browser); + await SpecialPowers.spawn(browser, [], async () => { + let dialog = Cu.waiveXrays( + content.document.querySelector("remove-logins-dialog") + ); + let title = dialog.shadowRoot.querySelector(".title"); + let message = dialog.shadowRoot.querySelector(".message"); + let label = dialog.shadowRoot.querySelector(".checkbox-text"); + let cancelButton = dialog.shadowRoot.querySelector(".cancel-button"); + let removeAllButton = dialog.shadowRoot.querySelector(".confirm-button"); + + let checkbox = dialog.shadowRoot.querySelector(".checkbox"); + + await content.document.l10n.translateElements([ + title, + message, + cancelButton, + removeAllButton, + label, + checkbox, + ]); + Assert.equal( + dialog.shadowRoot.activeElement, + checkbox, + "Checkbox should be the focused element on dialog open" + ); + Assert.equal( + title.dataset.l10nId, + "about-logins-confirm-remove-all-dialog-title", + "Title contents should match l10n-id attribute set on element" + ); + Assert.equal( + JSON.parse(title.dataset.l10nArgs).count, + 2, + "Title contents should match l10n-args attribute set on element" + ); + Assert.equal( + message.dataset.l10nId, + "about-logins-confirm-remove-all-dialog-message", + "Message contents should match l10n-id attribute set on element" + ); + Assert.equal( + JSON.parse(message.dataset.l10nArgs).count, + 2, + "Message contents should match l10n-args attribute set on element" + ); + Assert.equal( + label.dataset.l10nId, + "about-logins-confirm-remove-all-dialog-checkbox-label", + "Label contents should match l10n-id attribute set on outer element" + ); + Assert.equal( + JSON.parse(label.dataset.l10nArgs).count, + 2, + "Label contents should match l10n-id attribute set on outer element" + ); + Assert.equal( + cancelButton.dataset.l10nId, + "confirmation-dialog-cancel-button", + "Cancel button contents should match l10n-id attribute set on outer element" + ); + Assert.equal( + removeAllButton.dataset.l10nId, + "about-logins-confirm-remove-all-dialog-confirm-button-label", + "Remove all button contents should match l10n-id attribute set on outer element" + ); + Assert.equal( + removeAllButton.disabled, + true, + "Remove all button should be disabled on dialog open" + ); + }); + function activateConfirmCheckbox() { + let dialog = window.document.querySelector("remove-logins-dialog"); + return dialog.shadowRoot.querySelector(".checkbox"); + } + + await BrowserTestUtils.synthesizeMouseAtCenter( + activateConfirmCheckbox, + {}, + browser + ); + + await SpecialPowers.spawn(browser, [], async () => { + let dialog = Cu.waiveXrays( + content.document.querySelector("remove-logins-dialog") + ); + let removeAllButton = dialog.shadowRoot.querySelector(".confirm-button"); + Assert.equal( + removeAllButton.disabled, + false, + "Remove all should be enabled after clicking the checkbox" + ); + }); + function getDialogRemoveAllButton() { + let dialog = window.document.querySelector("remove-logins-dialog"); + return dialog.shadowRoot.querySelector(".confirm-button"); + } + await BrowserTestUtils.synthesizeMouseAtCenter( + getDialogRemoveAllButton, + {}, + browser + ); + await removeAllPromise; + await SpecialPowers.spawn(browser, [], async () => { + let loginList = Cu.waiveXrays(content.document.querySelector("login-list")); + await ContentTaskUtils.waitForCondition( + () => content.document.documentElement.classList.contains("no-logins"), + "Waiting for no logins view since all logins should be deleted" + ); + await ContentTaskUtils.waitForCondition( + () => + !content.document.documentElement.classList.contains("login-selected"), + "Waiting for the FxA Sync illustration to reappear" + ); + await ContentTaskUtils.waitForCondition( + () => loginList.classList.contains("no-logins"), + "Waiting for login-list to be in no logins view as all logins should be deleted" + ); + }); + await BrowserTestUtils.synthesizeMouseAtCenter("menu-button", {}, browser); + await SpecialPowers.spawn(browser, [], async () => { + let menuButton = content.document.querySelector("menu-button"); + let removeAllMenuButton = menuButton.shadowRoot.querySelector( + ".menuitem-remove-all-logins" + ); + Assert.ok( + removeAllMenuButton.disabled, + "Remove all logins menu button is disabled if there are no logins" + ); + }); + await SpecialPowers.spawn(browser, [], async () => { + let menuButton = Cu.waiveXrays( + content.document.querySelector("menu-button") + ); + let menu = menuButton.shadowRoot.querySelector("ul.menu"); + await EventUtils.synthesizeKey("KEY_Escape", {}, content); + await ContentTaskUtils.waitForCondition( + () => menu.hidden, + "Waiting for menu to close" + ); + }); +}); + +add_task(async function test_edit_mode_resets_on_remove_all_with_login() { + TEST_LOGIN2 = await addLogin(TEST_LOGIN2); + let removeAllPromise = waitForRemoveAllLogins(); + let browser = gBrowser.selectedBrowser; + await activateLoginItemEdit(browser); + await openRemoveAllDialog(browser); + await SpecialPowers.spawn(browser, [], async () => { + let loginItem = content.document.querySelector("login-item"); + Assert.ok( + loginItem.dataset.editing, + "Login item is still in edit mode when the remove all dialog opens" + ); + }); + function getDialogCancelButton() { + let dialog = window.document.querySelector("remove-logins-dialog"); + return dialog.shadowRoot.querySelector(".cancel-button"); + } + await BrowserTestUtils.synthesizeMouseAtCenter( + getDialogCancelButton, + {}, + browser + ); + await TestUtils.waitForTick(); + await SpecialPowers.spawn(browser, [], async () => { + let loginItem = content.document.querySelector("login-item"); + Assert.ok( + loginItem.dataset.editing, + "Login item should be in editing mode after activating the cancel button in the remove all dialog" + ); + }); + + await openRemoveAllDialog(browser); + function activateConfirmCheckbox() { + let dialog = window.document.querySelector("remove-logins-dialog"); + return dialog.shadowRoot.querySelector(".checkbox"); + } + + await BrowserTestUtils.synthesizeMouseAtCenter( + activateConfirmCheckbox, + {}, + browser + ); + await SpecialPowers.spawn(browser, [], async () => { + let dialog = Cu.waiveXrays( + content.document.querySelector("remove-logins-dialog") + ); + let removeAllButton = dialog.shadowRoot.querySelector(".confirm-button"); + Assert.equal( + removeAllButton.disabled, + false, + "Remove all should be enabled after clicking the checkbox" + ); + }); + function getDialogRemoveAllButton() { + let dialog = window.document.querySelector("remove-logins-dialog"); + return dialog.shadowRoot.querySelector(".confirm-button"); + } + await BrowserTestUtils.synthesizeMouseAtCenter( + getDialogRemoveAllButton, + {}, + browser + ); + await TestUtils.waitForTick(); + await SpecialPowers.spawn(browser, [], async () => { + let loginItem = content.document.querySelector("login-item"); + Assert.ok( + !loginItem.dataset.editing, + "Login item should not be in editing mode after activating the confirm button in the remove all dialog" + ); + }); + await removeAllPromise; +}); + +add_task(async function test_remove_all_when_creating_new_login() { + TEST_LOGIN2 = await addLogin(TEST_LOGIN2); + let removeAllPromise = waitForRemoveAllLogins(); + let browser = gBrowser.selectedBrowser; + await activateCreateNewLogin(browser); + await openRemoveAllDialog(browser); + await SpecialPowers.spawn(browser, [], async () => { + let loginItem = content.document.querySelector("login-item"); + Assert.ok( + loginItem.dataset.editing, + "Login item should be in edit mode when the remove all dialog opens" + ); + Assert.ok( + loginItem.dataset.isNewLogin, + "Login item should be in the 'new login' state when the remove all dialog opens" + ); + }); + function getDialogCancelButton() { + let dialog = window.document.querySelector("remove-logins-dialog"); + return dialog.shadowRoot.querySelector(".cancel-button"); + } + await BrowserTestUtils.synthesizeMouseAtCenter( + getDialogCancelButton, + {}, + browser + ); + await SpecialPowers.spawn(browser, [], async () => { + let loginItem = content.document.querySelector("login-item"); + Assert.ok( + loginItem.dataset.editing, + "Login item is still in edit mode after cancelling out of the remove all dialog" + ); + Assert.ok( + loginItem.dataset.isNewLogin, + "Login item should be in the 'newLogin' state after cancelling out of the remove all dialog" + ); + }); + + await openRemoveAllDialog(browser); + function activateConfirmCheckbox() { + let dialog = window.document.querySelector("remove-logins-dialog"); + return dialog.shadowRoot.querySelector(".checkbox"); + } + + await BrowserTestUtils.synthesizeMouseAtCenter( + activateConfirmCheckbox, + {}, + browser + ); + await SpecialPowers.spawn(browser, [], async () => { + let dialog = Cu.waiveXrays( + content.document.querySelector("remove-logins-dialog") + ); + let removeAllButton = dialog.shadowRoot.querySelector(".confirm-button"); + Assert.equal( + removeAllButton.disabled, + false, + "Remove all should be enabled after clicking the checkbox" + ); + }); + function getDialogRemoveAllButton() { + let dialog = window.document.querySelector("remove-logins-dialog"); + return dialog.shadowRoot.querySelector(".confirm-button"); + } + await BrowserTestUtils.synthesizeMouseAtCenter( + getDialogRemoveAllButton, + {}, + browser + ); + await SpecialPowers.spawn(browser, [], async () => { + let loginItem = content.document.querySelector("login-item"); + Assert.ok( + !loginItem.dataset.editing, + "Login item should not be in editing mode after activating the confirm button in the remove all dialog" + ); + Assert.ok( + !loginItem.dataset.isNewLogin, + "Login item should not be in 'new login' mode after activating the confirm button in the remove all dialog" + ); + }); + await removeAllPromise; +}); + +add_task(async function test_ensure_icons_are_not_draggable() { + TEST_LOGIN2 = await addLogin(TEST_LOGIN2); + let browser = gBrowser.selectedBrowser; + await openRemoveAllDialog(browser); + await SpecialPowers.spawn(browser, [], async () => { + let dialog = content.document.querySelector("remove-logins-dialog"); + let warningIcon = dialog.shadowRoot.querySelector(".warning-icon"); + Assert.ok(!warningIcon.draggable, "Warning icon should not be draggable"); + let dismissIcon = dialog.shadowRoot.querySelector(".dismiss-icon"); + Assert.ok(!dismissIcon.draggable, "Dismiss icon should not be draggable"); + }); +}); diff --git a/browser/components/aboutlogins/tests/browser/browser_sessionRestore.js b/browser/components/aboutlogins/tests/browser/browser_sessionRestore.js new file mode 100644 index 0000000000..5ab03f9867 --- /dev/null +++ b/browser/components/aboutlogins/tests/browser/browser_sessionRestore.js @@ -0,0 +1,62 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +async function checkLoginDisplayed(browser, testGuid) { + await SpecialPowers.spawn(browser, [testGuid], async function (guid) { + let loginList = Cu.waiveXrays(content.document.querySelector("login-list")); + let loginFound = await ContentTaskUtils.waitForCondition(() => { + return ( + loginList._loginGuidsSortedOrder.length == 1 && + loginList._loginGuidsSortedOrder[0] == guid + ); + }, "Waiting for login to be displayed in page"); + Assert.ok(loginFound, "Confirming that login is displayed in page"); + }); +} + +add_task(async function () { + TEST_LOGIN1 = await addLogin(TEST_LOGIN1); + registerCleanupFunction(() => { + Services.logins.removeAllUserFacingLogins(); + }); + + const testGuid = TEST_LOGIN1.guid; + const tab = BrowserTestUtils.addTab(gBrowser, "about:logins"); + const browser = gBrowser.getBrowserForTab(tab); + await BrowserTestUtils.browserLoaded(browser); + + await checkLoginDisplayed(browser, testGuid); + + BrowserTestUtils.removeTab(tab); + info("Adding a lazy about:logins tab..."); + let lazyTab = BrowserTestUtils.addTab(gBrowser, "about:logins", { + createLazyBrowser: true, + }); + + Assert.equal(lazyTab.linkedPanel, "", "Tab is lazy"); + let tabLoaded = new Promise(resolve => { + gBrowser.addTabsProgressListener({ + async onLocationChange(aBrowser) { + if (lazyTab.linkedBrowser == aBrowser) { + gBrowser.removeTabsProgressListener(this); + await Promise.resolve(); + resolve(); + } + }, + }); + }); + + info("Switching tab to cause it to get restored"); + const browserLoaded = BrowserTestUtils.browserLoaded(lazyTab.linkedBrowser); + await BrowserTestUtils.switchTab(gBrowser, lazyTab); + + await tabLoaded; + await browserLoaded; + + let lazyBrowser = lazyTab.linkedBrowser; + await checkLoginDisplayed(lazyBrowser, testGuid); + + BrowserTestUtils.removeTab(lazyTab); +}); diff --git a/browser/components/aboutlogins/tests/browser/browser_tabKeyNav.js b/browser/components/aboutlogins/tests/browser/browser_tabKeyNav.js new file mode 100644 index 0000000000..0305107d23 --- /dev/null +++ b/browser/components/aboutlogins/tests/browser/browser_tabKeyNav.js @@ -0,0 +1,276 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +EXPECTED_BREACH = { + AddedDate: "2018-12-20T23:56:26Z", + BreachDate: "2018-12-16", + Domain: "breached.example.com", + Name: "Breached", + PwnCount: 1643100, + DataClasses: ["Email addresses", "Usernames", "Passwords", "IP addresses"], + _status: "synced", + id: "047940fe-d2fd-4314-b636-b4a952ee0043", + last_modified: "1541615610052", + schema: "1541615609018", +}; + +add_setup(async function () { + TEST_LOGIN1 = await addLogin(TEST_LOGIN1); + await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: "about:logins", + }); + registerCleanupFunction(() => { + BrowserTestUtils.removeTab(gBrowser.selectedTab); + Services.logins.removeAllUserFacingLogins(); + }); +}); + +add_task(async function test_tab_key_nav() { + const browser = gBrowser.selectedBrowser; + await SpecialPowers.spawn(browser, [], async () => { + // Helper function for getting the resulting DOM element given a list of selectors possibly inside shadow DOM + const selectWithShadowRootIfNeeded = (document, selectorsArray) => + selectorsArray.reduce( + (selectionSoFar, currentSelector) => + selectionSoFar.shadowRoot + ? selectionSoFar.shadowRoot.querySelector(currentSelector) + : selectionSoFar.querySelector(currentSelector), + document + ); + + const EventUtils = ContentTaskUtils.getEventUtils(content); + // list [selector, shadow root selector] for each element + // in the order we expect them to be navigated. + const expectedElementsInOrder = [ + ["login-list", "login-filter", "input"], + ["login-list", "button.create-login-button"], + ["login-list", "select#login-sort"], + ["login-list", "ol"], + ["login-item", "button.edit-button"], + ["login-item", "button.delete-button"], + ["login-item", "a.origin-input"], + ["login-item", "button.copy-username-button"], + ["login-item", "input.reveal-password-checkbox"], + ["login-item", "button.copy-password-button"], + ]; + + const firstElement = selectWithShadowRootIfNeeded( + content.document, + expectedElementsInOrder.at(0) + ); + + const lastElement = selectWithShadowRootIfNeeded( + content.document, + expectedElementsInOrder.at(-1) + ); + + async function tab() { + EventUtils.synthesizeKey("KEY_Tab", {}, content); + await new Promise(resolve => content.requestAnimationFrame(resolve)); + // The following line can help with focus trap debugging: + // await new Promise(resolve => content.window.setTimeout(resolve, 500)); + } + async function shiftTab() { + EventUtils.synthesizeKey("KEY_Tab", { shiftKey: true }, content); + await new Promise(resolve => content.requestAnimationFrame(resolve)); + // await new Promise(resolve => content.window.setTimeout(resolve, 500)); + } + + // Getting focused shadow DOM element itself instead of shadowRoot, + // using recursion for any component-nesting level, as in: + // document.activeElement.shadowRoot.activeElement.shadowRoot.activeElement + function getFocusedElement() { + let element = content.document.activeElement; + const getShadowRootFocus = e => { + if (e.shadowRoot) { + return getShadowRootFocus(e.shadowRoot.activeElement); + } + return e; + }; + return getShadowRootFocus(element); + } + + // Ensure the test starts in a valid state + firstElement.focus(); + // Assert that we tab navigate correctly + for (let expectedSelector of expectedElementsInOrder) { + const expectedElement = selectWithShadowRootIfNeeded( + content.document, + expectedSelector + ); + + // By default, MacOS will skip over certain text controls, such as links. + if ( + content.window.navigator.platform.toLowerCase().includes("mac") && + expectedElement.tagName === "A" + ) { + continue; + } + + const actualElement = getFocusedElement(); + + Assert.equal( + actualElement, + expectedElement, + "Actual focused element should equal the expected focused element" + ); + await tab(); + } + + lastElement.focus(); + + // Assert that we shift + tab navigate correctly starting from the last ordered element + for (let expectedSelector of expectedElementsInOrder.reverse()) { + const expectedElement = selectWithShadowRootIfNeeded( + content.document, + expectedSelector + ); + // By default, MacOS will skip over certain text controls, such as links. + if ( + content.window.navigator.platform.toLowerCase().includes("mac") && + expectedElement.tagName === "A" + ) { + continue; + } + + const actualElement = getFocusedElement(); + Assert.equal( + actualElement, + expectedElement, + "Actual focused element should equal the expected focused element" + ); + await shiftTab(); + } + await tab(); // tab back to the first element + }); +}); + +add_task(async function test_tab_to_create_button() { + const browser = gBrowser.selectedBrowser; + await SpecialPowers.spawn(browser, [], async () => { + const EventUtils = ContentTaskUtils.getEventUtils(content); + + function waitForAnimationFrame() { + return new Promise(resolve => content.requestAnimationFrame(resolve)); + } + + async function tab() { + EventUtils.synthesizeKey("KEY_Tab", {}, content); + await waitForAnimationFrame(); + } + + const loginList = content.document.querySelector("login-list"); + const loginFilter = loginList.shadowRoot.querySelector("login-filter"); + const loginSort = loginList.shadowRoot.getElementById("login-sort"); + const loginListbox = loginList.shadowRoot.querySelector("ol"); + const createButton = loginList.shadowRoot.querySelector( + ".create-login-button" + ); + + const getFocusedElement = () => loginList.shadowRoot.activeElement; + Assert.equal(getFocusedElement(), loginFilter, "login-filter is focused"); + + await tab(); + Assert.equal(getFocusedElement(), createButton, "create button is focused"); + + await tab(); + Assert.equal(getFocusedElement(), loginSort, "login sort is focused"); + + await tab(); + Assert.equal(getFocusedElement(), loginListbox, "listbox is focused next"); + + await tab(); + Assert.equal(getFocusedElement(), null, "login-list isn't focused again"); + }); +}); + +add_task(async function test_tab_to_edit_button() { + TEST_LOGIN3 = await addLogin(TEST_LOGIN3); + let browser = gBrowser.selectedBrowser; + await SpecialPowers.spawn( + browser, + [[TEST_LOGIN1.guid, TEST_LOGIN3.guid]], + async ([testLoginNormalGuid, testLoginBreachedGuid]) => { + const EventUtils = ContentTaskUtils.getEventUtils(content); + + function waitForAnimationFrame() { + return new Promise(resolve => content.requestAnimationFrame(resolve)); + } + + async function tab() { + EventUtils.synthesizeKey("KEY_Tab", {}, content); + await waitForAnimationFrame(); + } + + const loginList = content.document.querySelector("login-list"); + const loginItem = content.document.querySelector("login-item"); + const loginFilter = loginList.shadowRoot.querySelector("login-filter"); + const createButton = loginList.shadowRoot.querySelector( + ".create-login-button" + ); + const loginSort = loginList.shadowRoot.getElementById("login-sort"); + const loginListbox = loginList.shadowRoot.querySelector("ol"); + const editButton = loginItem.shadowRoot.querySelector(".edit-button"); + const breachAlert = loginItem.shadowRoot.querySelector(".breach-alert"); + const getFocusedElement = () => { + if (content.document.activeElement == loginList) { + return loginList.shadowRoot.activeElement; + } + if (content.document.activeElement == loginItem) { + return loginItem.shadowRoot.activeElement; + } + if (content.document.activeElement == loginFilter) { + return loginFilter.shadowRoot.activeElement; + } + Assert.ok( + false, + "not expecting a different element to get focused in this test: " + + content.document.activeElement.outerHTML + ); + return undefined; + }; + + for (let guidToSelect of [testLoginNormalGuid, testLoginBreachedGuid]) { + let loginListItem = loginList.shadowRoot.querySelector( + `.login-list-item[data-guid="${guidToSelect}"]` + ); + loginListItem.click(); + await ContentTaskUtils.waitForCondition(() => { + let waivedLoginItem = Cu.waiveXrays(loginItem); + return ( + waivedLoginItem._login && + waivedLoginItem._login.guid == guidToSelect + ); + }, "waiting for login-item to show the selected login"); + + Assert.equal( + breachAlert.hidden, + guidToSelect == testLoginNormalGuid, + ".breach-alert should be hidden if the login is not breached. current login breached? " + + (guidToSelect == testLoginBreachedGuid) + ); + + createButton.focus(); + Assert.equal( + getFocusedElement(), + createButton, + "create button is focused" + ); + + await tab(); + Assert.equal(getFocusedElement(), loginSort, "login sort is focused"); + + await tab(); + Assert.equal( + getFocusedElement(), + loginListbox, + "listbox is focused next" + ); + + await tab(); + Assert.equal(getFocusedElement(), editButton, "edit button is focused"); + } + } + ); +}); diff --git a/browser/components/aboutlogins/tests/browser/browser_updateLogin.js b/browser/components/aboutlogins/tests/browser/browser_updateLogin.js new file mode 100644 index 0000000000..efa8bbdd7b --- /dev/null +++ b/browser/components/aboutlogins/tests/browser/browser_updateLogin.js @@ -0,0 +1,421 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { CONCEALED_PASSWORD_TEXT } = ChromeUtils.importESModule( + "chrome://browser/content/aboutlogins/aboutLoginsUtils.mjs" +); + +add_setup(async function () { + TEST_LOGIN1 = await addLogin(TEST_LOGIN1); + await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: "about:logins", + }); + registerCleanupFunction(() => { + BrowserTestUtils.removeTab(gBrowser.selectedTab); + Services.logins.removeAllUserFacingLogins(); + }); +}); + +add_task(async function test_show_logins() { + let browser = gBrowser.selectedBrowser; + await SpecialPowers.spawn(browser, [TEST_LOGIN1.guid], async loginGuid => { + let loginList = Cu.waiveXrays(content.document.querySelector("login-list")); + let loginFound = await ContentTaskUtils.waitForCondition(() => { + return ( + loginList._loginGuidsSortedOrder.length == 1 && + loginList._loginGuidsSortedOrder[0] == loginGuid + ); + }, "Waiting for login to be displayed"); + Assert.ok( + loginFound, + "Stored logins should be displayed upon loading the page" + ); + }); +}); + +add_task(async function test_login_item() { + if (!OSKeyStoreTestUtils.canTestOSKeyStoreLogin()) { + return; + } + + async function test_discard_dialog( + login, + exitPointSelector, + concealedPasswordText + ) { + let loginItem = Cu.waiveXrays(content.document.querySelector("login-item")); + let loginList = Cu.waiveXrays(content.document.querySelector("login-list")); + await ContentTaskUtils.waitForCondition( + () => loginItem.dataset.editing, + "Entering edit mode" + ); + await Promise.resolve(); + + let usernameInput = loginItem.shadowRoot.querySelector( + "input[name='username']" + ); + let passwordInput = loginItem._passwordInput; + usernameInput.value += "-undome"; + passwordInput.value += "-undome"; + + let dialog = content.document.querySelector("confirmation-dialog"); + Assert.ok(dialog.hidden, "Confirm dialog should initially be hidden"); + + let exitPoint = + loginItem.shadowRoot.querySelector(exitPointSelector) || + loginList.shadowRoot.querySelector(exitPointSelector); + exitPoint.click(); + + Assert.ok(!dialog.hidden, "Confirm dialog should be visible"); + + let confirmDiscardButton = + dialog.shadowRoot.querySelector(".confirm-button"); + await content.document.l10n.translateElements([ + dialog.shadowRoot.querySelector(".title"), + dialog.shadowRoot.querySelector(".message"), + confirmDiscardButton, + ]); + + confirmDiscardButton.click(); + + Assert.ok( + dialog.hidden, + "Confirm dialog should be hidden after confirming" + ); + + await Promise.resolve(); + + let loginListItem = Cu.waiveXrays( + loginList.shadowRoot.querySelector(".login-list-item[data-guid]") + ); + + loginListItem.click(); + + await ContentTaskUtils.waitForCondition( + () => usernameInput.value == login.username + ); + + Assert.equal( + usernameInput.value, + login.username, + "Username change should be reverted" + ); + Assert.equal( + passwordInput.value, + login.password, + "Password change should be reverted" + ); + let passwordDisplayInput = loginItem._passwordDisplayInput; + Assert.equal( + passwordDisplayInput.value, + concealedPasswordText, + "Password change should be reverted for display" + ); + Assert.ok( + !passwordInput.hasAttribute("value"), + "Password shouldn't be exposed in @value" + ); + Assert.equal( + passwordInput.style.width, + login.password.length + "ch", + "Password field width shouldn't have changed" + ); + } + + let browser = gBrowser.selectedBrowser; + let reauthObserved = OSKeyStoreTestUtils.waitForOSKeyStoreLogin(true); + await SpecialPowers.spawn( + browser, + [LoginHelper.loginToVanillaObject(TEST_LOGIN1)], + async login => { + let loginList = Cu.waiveXrays( + content.document.querySelector("login-list") + ); + let loginListItem = Cu.waiveXrays( + loginList.shadowRoot.querySelector(".login-list-item[data-guid]") + ); + loginListItem.click(); + + let loginItem = Cu.waiveXrays( + content.document.querySelector("login-item") + ); + let loginItemPopulated = await ContentTaskUtils.waitForCondition(() => { + return ( + loginItem._login.guid == loginListItem.dataset.guid && + loginItem._login.guid == login.guid + ); + }, "Waiting for login item to get populated"); + Assert.ok(loginItemPopulated, "The login item should get populated"); + + let editButton = loginItem.shadowRoot.querySelector(".edit-button"); + editButton.click(); + } + ); + info("waiting for oskeystore auth #1"); + await reauthObserved; + await SpecialPowers.spawn( + browser, + [ + LoginHelper.loginToVanillaObject(TEST_LOGIN1), + ".create-login-button", + CONCEALED_PASSWORD_TEXT, + ], + test_discard_dialog + ); + reauthObserved = forceAuthTimeoutAndWaitForOSKeyStoreLogin({ + loginResult: true, + }); + await SpecialPowers.spawn(browser, [], async () => { + let loginItem = Cu.waiveXrays(content.document.querySelector("login-item")); + let editButton = loginItem.shadowRoot.querySelector(".edit-button"); + editButton.click(); + }); + info("waiting for oskeystore auth #2"); + await reauthObserved; + await SpecialPowers.spawn( + browser, + [ + LoginHelper.loginToVanillaObject(TEST_LOGIN1), + ".cancel-button", + CONCEALED_PASSWORD_TEXT, + ], + test_discard_dialog + ); + reauthObserved = forceAuthTimeoutAndWaitForOSKeyStoreLogin({ + loginResult: true, + }); + await SpecialPowers.spawn(browser, [], async () => { + let loginItem = Cu.waiveXrays(content.document.querySelector("login-item")); + let editButton = loginItem.shadowRoot.querySelector(".edit-button"); + editButton.click(); + }); + info("waiting for oskeystore auth #3"); + await reauthObserved; + await SpecialPowers.spawn( + browser, + [LoginHelper.loginToVanillaObject(TEST_LOGIN1), CONCEALED_PASSWORD_TEXT], + async (login, concealedPasswordText) => { + let loginItem = Cu.waiveXrays( + content.document.querySelector("login-item") + ); + await ContentTaskUtils.waitForCondition( + () => loginItem.dataset.editing, + "Entering edit mode" + ); + await Promise.resolve(); + let usernameInput = loginItem.shadowRoot.querySelector( + "input[name='username']" + ); + let passwordInput = loginItem._passwordInput; + let passwordDisplayInput = loginItem._passwordDisplayInput; + + Assert.ok( + loginItem.dataset.editing, + "LoginItem should be in 'edit' mode" + ); + Assert.equal( + passwordInput.type, + "password", + "Password should still be hidden before revealed in edit mode" + ); + + passwordDisplayInput.focus(); + + let revealCheckbox = loginItem.shadowRoot.querySelector( + ".reveal-password-checkbox" + ); + Assert.ok( + revealCheckbox.checked, + "reveal-checkbox should be checked when password input is focused" + ); + + Assert.equal( + passwordInput.type, + "text", + "Password should be shown as text when focused" + ); + let saveChangesButton = loginItem.shadowRoot.querySelector( + ".save-changes-button" + ); + + saveChangesButton.click(); + + await ContentTaskUtils.waitForCondition(() => { + let editButton = loginItem.shadowRoot.querySelector(".edit-button"); + return !editButton.disabled; + }, "Waiting to exit edit mode"); + + Assert.ok( + !revealCheckbox.checked, + "reveal-checkbox should be unchecked after saving changes" + ); + Assert.ok( + !loginItem.dataset.editing, + "LoginItem should not be in 'edit' mode after saving" + ); + Assert.equal( + passwordInput.type, + "password", + "Password should be hidden after exiting edit mode" + ); + Assert.equal( + usernameInput.value, + login.username, + "Username change should be reverted" + ); + Assert.equal( + passwordInput.value, + login.password, + "Password change should be reverted" + ); + Assert.equal( + passwordDisplayInput.value, + concealedPasswordText, + "Password change should be reverted for display" + ); + Assert.ok( + !passwordInput.hasAttribute("value"), + "Password shouldn't be exposed in @value" + ); + Assert.equal( + passwordInput.style.width, + login.password.length + "ch", + "Password field width shouldn't have changed" + ); + } + ); + reauthObserved = forceAuthTimeoutAndWaitForOSKeyStoreLogin({ + loginResult: true, + }); + await SpecialPowers.spawn(browser, [], async () => { + let loginItem = Cu.waiveXrays(content.document.querySelector("login-item")); + let editButton = loginItem.shadowRoot.querySelector(".edit-button"); + editButton.click(); + }); + info("waiting for oskeystore auth #4"); + await reauthObserved; + await SpecialPowers.spawn( + browser, + [LoginHelper.loginToVanillaObject(TEST_LOGIN1)], + async login => { + let loginItem = Cu.waiveXrays( + content.document.querySelector("login-item") + ); + await ContentTaskUtils.waitForCondition( + () => loginItem.dataset.editing, + "Entering edit mode" + ); + await Promise.resolve(); + + let revealCheckbox = loginItem.shadowRoot.querySelector( + ".reveal-password-checkbox" + ); + revealCheckbox.click(); + Assert.ok( + revealCheckbox.checked, + "reveal-checkbox should be checked after clicking" + ); + + let usernameInput = loginItem.shadowRoot.querySelector( + "input[name='username']" + ); + let passwordInput = loginItem._passwordInput; + + usernameInput.value += "-saveme"; + passwordInput.value += "-saveme"; + + Assert.ok( + loginItem.dataset.editing, + "LoginItem should be in 'edit' mode" + ); + + let saveChangesButton = loginItem.shadowRoot.querySelector( + ".save-changes-button" + ); + saveChangesButton.click(); + + await ContentTaskUtils.waitForCondition(() => { + let loginList = Cu.waiveXrays( + content.document.querySelector("login-list") + ); + let guid = loginList._loginGuidsSortedOrder[0]; + let updatedLogin = loginList._logins[guid].login; + return ( + updatedLogin && + updatedLogin.username == usernameInput.value && + updatedLogin.password == passwordInput.value + ); + }, "Waiting for corresponding login in login list to update"); + + Assert.ok( + !revealCheckbox.checked, + "reveal-checkbox should be unchecked after saving changes" + ); + Assert.ok( + !loginItem.dataset.editing, + "LoginItem should not be in 'edit' mode after saving" + ); + Assert.equal( + passwordInput.style.width, + passwordInput.value.length + "ch", + "Password field width should be correctly updated" + ); + } + ); + reauthObserved = forceAuthTimeoutAndWaitForOSKeyStoreLogin({ + loginResult: true, + }); + await SpecialPowers.spawn(browser, [], async () => { + let loginItem = Cu.waiveXrays(content.document.querySelector("login-item")); + let editButton = loginItem.shadowRoot.querySelector(".edit-button"); + editButton.click(); + }); + info("waiting for oskeystore auth #5"); + await reauthObserved; + await SpecialPowers.spawn( + browser, + [LoginHelper.loginToVanillaObject(TEST_LOGIN1)], + async login => { + let loginItem = Cu.waiveXrays( + content.document.querySelector("login-item") + ); + await ContentTaskUtils.waitForCondition( + () => loginItem.dataset.editing, + "Entering edit mode" + ); + await Promise.resolve(); + + Assert.ok( + loginItem.dataset.editing, + "LoginItem should be in 'edit' mode" + ); + let deleteButton = loginItem.shadowRoot.querySelector(".delete-button"); + deleteButton.click(); + let confirmDeleteDialog = Cu.waiveXrays( + content.document.querySelector("confirmation-dialog") + ); + let confirmDeleteButton = + confirmDeleteDialog.shadowRoot.querySelector(".confirm-button"); + confirmDeleteButton.click(); + + let loginList = Cu.waiveXrays( + content.document.querySelector("login-list") + ); + let loginListItem = Cu.waiveXrays( + loginList.shadowRoot.querySelector(".login-list-item[data-guid]") + ); + await ContentTaskUtils.waitForCondition(() => { + loginListItem = loginList.shadowRoot.querySelector( + ".login-list-item[data-guid]" + ); + return !loginListItem; + }, "Waiting for login to be removed from list"); + + Assert.ok( + !loginItem.dataset.editing, + "LoginItem should not be in 'edit' mode after deleting" + ); + } + ); +}); diff --git a/browser/components/aboutlogins/tests/browser/browser_vulnerableLoginAddedInSecondaryWindow.js b/browser/components/aboutlogins/tests/browser/browser_vulnerableLoginAddedInSecondaryWindow.js new file mode 100644 index 0000000000..fac3e91af4 --- /dev/null +++ b/browser/components/aboutlogins/tests/browser/browser_vulnerableLoginAddedInSecondaryWindow.js @@ -0,0 +1,223 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +EXPECTED_BREACH = { + AddedDate: "2018-12-20T23:56:26Z", + BreachDate: "2018-12-16", + Domain: "breached.example.com", + Name: "Breached", + PwnCount: 1643100, + DataClasses: ["Email addresses", "Usernames", "Passwords", "IP addresses"], + _status: "synced", + id: "047940fe-d2fd-4314-b636-b4a952ee0043", + last_modified: "1541615610052", + schema: "1541615609018", +}; + +let tabInSecondWindow; + +add_setup(async function () { + TEST_LOGIN1 = await addLogin(TEST_LOGIN1); + TEST_LOGIN2 = await addLogin(TEST_LOGIN2); + TEST_LOGIN3 = await addLogin(TEST_LOGIN3); + + let breaches = await LoginBreaches.getPotentialBreachesByLoginGUID([ + TEST_LOGIN3, + ]); + Assert.ok(breaches.size, "TEST_LOGIN3 should be marked as breached"); + + // Remove the breached login so the 'alerts' option + // is hidden when opening about:logins. + Services.logins.removeLogin(TEST_LOGIN3); + + await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: "about:logins", + }); + + let newWin = await BrowserTestUtils.openNewBrowserWindow(); + tabInSecondWindow = await BrowserTestUtils.openNewForegroundTab({ + gBrowser: newWin.gBrowser, + url: "about:logins", + }); + + registerCleanupFunction(async () => { + BrowserTestUtils.removeTab(gBrowser.selectedTab); + Services.logins.removeAllUserFacingLogins(); + await BrowserTestUtils.closeWindow(newWin); + }); +}); + +add_task(async function test_new_login_marked_vulnerable_in_both_windows() { + const ORIGIN_FOR_NEW_VULNERABLE_LOGIN = "https://vulnerable"; + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async () => { + let loginList = Cu.waiveXrays(content.document.querySelector("login-list")); + Assert.ok( + loginList.shadowRoot.querySelector("#login-sort").namedItem("alerts") + .hidden, + "The 'alerts' option should be hidden before adding a vulnerable login to the list" + ); + }); + + await SpecialPowers.spawn( + tabInSecondWindow.linkedBrowser, + [[TEST_LOGIN3.password, ORIGIN_FOR_NEW_VULNERABLE_LOGIN]], + async ([passwordOfBreachedAccount, originForNewVulnerableLogin]) => { + let loginList = content.document.querySelector("login-list"); + + await ContentTaskUtils.waitForCondition( + () => + loginList.shadowRoot.querySelectorAll(".login-list-item[data-guid]") + .length == 2, + "waiting for all two initials logins to get added to login-list" + ); + + let loginSort = loginList.shadowRoot.querySelector("#login-sort"); + Assert.ok( + loginSort.namedItem("alerts").hidden, + "The 'alerts' option should be hidden when there are no breached or vulnerable logins in the list" + ); + + let createButton = loginList.shadowRoot.querySelector( + ".create-login-button" + ); + createButton.click(); + + let loginItem = content.document.querySelector("login-item"); + await ContentTaskUtils.waitForCondition( + () => loginItem.dataset.isNewLogin, + "waiting for create login form to be visible" + ); + + let originInput = loginItem.shadowRoot.querySelector( + "input[name='origin']" + ); + originInput.value = originForNewVulnerableLogin; + let passwordInput = loginItem.shadowRoot.querySelector( + "input[name='password']" + ); + passwordInput.value = passwordOfBreachedAccount; + + let saveButton = loginItem.shadowRoot.querySelector( + ".save-changes-button" + ); + saveButton.click(); + + await ContentTaskUtils.waitForCondition( + () => + loginList.shadowRoot.querySelectorAll(".login-list-item[data-guid]") + .length == 3, + "waiting for new login to get added to login-list" + ); + + let vulnerableLoginGuid = Cu.waiveXrays(loginItem)._login.guid; + let vulnerableListItem = loginList.shadowRoot.querySelector( + `.login-list-item[data-guid="${vulnerableLoginGuid}"]` + ); + + Assert.ok( + vulnerableListItem.classList.contains("vulnerable"), + "vulnerable login list item should be marked as such" + ); + Assert.ok( + !loginItem.shadowRoot.querySelector(".vulnerable-alert").hidden, + "vulnerable alert on login-item should be visible" + ); + + Assert.ok( + !loginSort.namedItem("alerts").hidden, + "The 'alerts' option should be visible after adding a vulnerable login to the list" + ); + } + ); + console.log("xxxxxxx ---- 0"); + + tabInSecondWindow.linkedBrowser.reload(); + await BrowserTestUtils.browserLoaded( + tabInSecondWindow.linkedBrowser, + false, + url => url.includes("about:logins") + ); + + console.log("xxxxxxx ---- 1"); + + await SpecialPowers.spawn(tabInSecondWindow.linkedBrowser, [], async () => { + let loginList = content.document.querySelector("login-list"); + let loginSort = loginList.shadowRoot.querySelector("#login-sort"); + + await ContentTaskUtils.waitForCondition( + () => loginSort.value == "alerts", + "waiting for sort to get updated to 'alerts'" + ); + + Assert.equal( + loginSort.value, + "alerts", + "The login list should be sorted by Alerts" + ); + let loginListItems = loginList.shadowRoot.querySelectorAll( + ".login-list-item[data-guid]" + ); + for (let i = 1; i < loginListItems.length; i++) { + if (loginListItems[i].matches(".vulnerable, .breached")) { + Assert.ok( + loginListItems[i - 1].matches(".vulnerable, .breached"), + `The previous login-list-item must be vulnerable or breached if the current one is (second window, i=${i})` + ); + } + } + }); + console.log("xxxxxxx ---- 2"); + + await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [ORIGIN_FOR_NEW_VULNERABLE_LOGIN], + async originForNewVulnerableLogin => { + let loginList = Cu.waiveXrays( + content.document.querySelector("login-list") + ); + let vulnerableListItem; + await ContentTaskUtils.waitForCondition(() => { + let entry = Object.entries(loginList._logins).find( + ([guid, { login, listItem }]) => + login.origin == originForNewVulnerableLogin + ); + vulnerableListItem = entry[1].listItem; + return !!entry; + }, "waiting for vulnerable list item to get added to login-list"); + Assert.ok( + vulnerableListItem.classList.contains("vulnerable"), + "vulnerable login list item should be marked as such" + ); + + Assert.ok( + !loginList.shadowRoot.querySelector("#login-sort").namedItem("alerts") + .hidden, + "The 'alerts' option should be visible after adding a vulnerable login to the list" + ); + } + ); + gBrowser.selectedBrowser.reload(); + await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser, false, url => + url.includes("about:logins") + ); + + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async () => { + let loginList = Cu.waiveXrays(content.document.querySelector("login-list")); + await ContentTaskUtils.waitForCondition( + () => loginList.shadowRoot.querySelector("#login-sort").value == "alerts", + "waiting for sort to get updated to 'alerts'" + ); + let loginListItems = loginList.shadowRoot.querySelectorAll( + ".login-list-item[data-guid]" + ); + for (let i = 1; i < loginListItems.length; i++) { + if (loginListItems[i].matches(".vulnerable, .breached")) { + Assert.ok( + loginListItems[i - 1].matches(".vulnerable, .breached"), + `The previous login-list-item must be vulnerable or breached if the current one is (first window, i=${i})` + ); + } + } + }); +}); diff --git a/browser/components/aboutlogins/tests/browser/head.js b/browser/components/aboutlogins/tests/browser/head.js new file mode 100644 index 0000000000..2aec0e632a --- /dev/null +++ b/browser/components/aboutlogins/tests/browser/head.js @@ -0,0 +1,225 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +let { LoginBreaches } = ChromeUtils.importESModule( + "resource:///modules/LoginBreaches.sys.mjs" +); +let { RemoteSettings } = ChromeUtils.importESModule( + "resource://services-settings/remote-settings.sys.mjs" +); +let { _AboutLogins } = ChromeUtils.importESModule( + "resource:///actors/AboutLoginsParent.sys.mjs" +); +let { OSKeyStoreTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/OSKeyStoreTestUtils.sys.mjs" +); +var { LoginTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/LoginTestUtils.sys.mjs" +); + +let nsLoginInfo = new Components.Constructor( + "@mozilla.org/login-manager/loginInfo;1", + Ci.nsILoginInfo, + "init" +); + +let TEST_LOGIN1 = new nsLoginInfo( + "https://example.com", + "https://example.com", + null, + "user1", + "pass1", + "username", + "password" +); +let TEST_LOGIN2 = new nsLoginInfo( + "https://2.example.com", + "https://2.example.com", + null, + "user2", + "pass2", + "username", + "password" +); + +let TEST_LOGIN3 = new nsLoginInfo( + "https://breached.example.com", + "https://breached.example.com", + null, + "breachedLogin1", + "pass3", + "breachedLogin", + "password" +); +TEST_LOGIN3.QueryInterface(Ci.nsILoginMetaInfo).timePasswordChanged = 123456; + +async function addLogin(login) { + const result = await Services.logins.addLoginAsync(login); + registerCleanupFunction(() => { + let matchData = Cc["@mozilla.org/hash-property-bag;1"].createInstance( + Ci.nsIWritablePropertyBag2 + ); + matchData.setPropertyAsAUTF8String("guid", result.guid); + + let logins = Services.logins.searchLogins(matchData); + if (!logins.length) { + return; + } + // Use the login that was returned from searchLogins + // in case the initial login object was changed by the test code, + // since removeLogin makes sure that the login argument exactly + // matches the login that it will be removing. + Services.logins.removeLogin(logins[0]); + }); + return result; +} + +let EXPECTED_BREACH = null; +let EXPECTED_ERROR_MESSAGE = null; +add_setup(async function setup_head() { + const db = RemoteSettings(LoginBreaches.REMOTE_SETTINGS_COLLECTION).db; + if (EXPECTED_BREACH) { + await db.create(EXPECTED_BREACH, { + useRecordId: true, + }); + } + await db.importChanges({}, Date.now()); + if (EXPECTED_BREACH) { + await RemoteSettings(LoginBreaches.REMOTE_SETTINGS_COLLECTION).emit( + "sync", + { data: { current: [EXPECTED_BREACH] } } + ); + } + + SpecialPowers.registerConsoleListener(function onConsoleMessage(msg) { + if (msg.isWarning || !msg.errorMessage) { + // Ignore warnings and non-errors. + return; + } + + if (msg.errorMessage.includes('Unknown event: ["jsonfile", "load"')) { + // Ignore telemetry errors from JSONFile.sys.mjs. + return; + } + + if ( + msg.errorMessage == "Refreshing device list failed." || + msg.errorMessage == "Skipping device list refresh; not signed in" + ) { + // Ignore errors from browser-sync.js. + return; + } + if ( + msg.errorMessage.includes( + "ReferenceError: MigrationWizard is not defined" + ) + ) { + // todo(Bug 1587237): Ignore error when loading the Migration Wizard in automation. + return; + } + if ( + msg.errorMessage.includes("Error detecting Chrome profiles") || + msg.errorMessage.includes( + "Library/Application Support/Chromium/Local State (No such file or directory)" + ) || + msg.errorMessage.includes( + "Library/Application Support/Google/Chrome/Local State (No such file or directory)" + ) + ) { + // Ignore errors that can occur when the migrator is looking for a + // Chrome/Chromium profile + return; + } + if (msg.errorMessage.includes("Can't find profile directory.")) { + // Ignore error messages for no profile found in old XULStore.jsm + return; + } + if (msg.errorMessage.includes("Error reading typed URL history")) { + // The Migrator when opened can log this exception if there is no Edge + // history on the machine. + return; + } + if (msg.errorMessage.includes(EXPECTED_ERROR_MESSAGE)) { + return; + } + if (msg.errorMessage == "FILE_FORMAT_ERROR") { + // Ignore errors handled by the error message dialog. + return; + } + if ( + msg.errorMessage == + "NotFoundError: No such JSWindowActor 'MarionetteEvents'" + ) { + // Ignore MarionetteEvents error (Bug 1730837, Bug 1710079). + return; + } + Assert.ok(false, msg.message || msg.errorMessage); + }); + + registerCleanupFunction(async () => { + EXPECTED_ERROR_MESSAGE = null; + await db.clear(); + Services.telemetry.clearEvents(); + SpecialPowers.postConsoleSentinel(); + }); +}); + +/** + * Waits for the primary password prompt and performs an action. + * @param {string} action Set to "authenticate" to log in or "cancel" to + * close the dialog without logging in. + */ +function waitForMPDialog(action, aWindow = window) { + const BRAND_BUNDLE = Services.strings.createBundle( + "chrome://branding/locale/brand.properties" + ); + const BRAND_FULL_NAME = BRAND_BUNDLE.GetStringFromName("brandFullName"); + let dialogShown = TestUtils.topicObserved("common-dialog-loaded"); + return dialogShown.then(function ([subject]) { + let dialog = subject.Dialog; + let expected = "Password Required - " + BRAND_FULL_NAME; + Assert.equal( + dialog.args.title, + expected, + "Dialog is the Primary Password dialog" + ); + if (action == "authenticate") { + SpecialPowers.wrap(dialog.ui.password1Textbox).setUserInput( + LoginTestUtils.primaryPassword.primaryPassword + ); + dialog.ui.button0.click(); + } else if (action == "cancel") { + dialog.ui.button1.click(); + } + return BrowserTestUtils.waitForEvent(aWindow, "DOMModalDialogClosed"); + }); +} + +/** + * Allows for tests to reset the MP auth expiration and + * return a promise that will resolve after the MP dialog has + * been presented. + * + * @param {string} action Set to "authenticate" to log in or "cancel" to + * close the dialog without logging in. + * @returns {Promise} Resolves after the MP dialog has been presented and actioned upon + */ +function forceAuthTimeoutAndWaitForMPDialog(action, aWindow = window) { + const AUTH_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes (duplicated from AboutLoginsParent.sys.mjs) + _AboutLogins._authExpirationTime -= AUTH_TIMEOUT_MS + 1; + return waitForMPDialog(action, aWindow); +} + +/** + * Allows for tests to reset the OS auth expiration and + * return a promise that will resolve after the OS auth dialog has + * been presented. + * + * @param {bool} loginResult True if the auth prompt should pass, otherwise false will fail + * @returns {Promise} Resolves after the OS auth dialog has been presented + */ +function forceAuthTimeoutAndWaitForOSKeyStoreLogin({ loginResult }) { + const AUTH_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes (duplicated from AboutLoginsParent.sys.mjs) + _AboutLogins._authExpirationTime -= AUTH_TIMEOUT_MS + 1; + return OSKeyStoreTestUtils.waitForOSKeyStoreLogin(loginResult); +} diff --git a/browser/components/aboutlogins/tests/chrome/.eslintrc.js b/browser/components/aboutlogins/tests/chrome/.eslintrc.js new file mode 100644 index 0000000000..9b6510bdd2 --- /dev/null +++ b/browser/components/aboutlogins/tests/chrome/.eslintrc.js @@ -0,0 +1,16 @@ +/* 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/. */ + +"use strict"; + +module.exports = { + overrides: [ + { + files: ["test_login_item.html"], + parserOptions: { + sourceType: "module", + }, + }, + ], +}; diff --git a/browser/components/aboutlogins/tests/chrome/aboutlogins_common.js b/browser/components/aboutlogins/tests/chrome/aboutlogins_common.js new file mode 100644 index 0000000000..d24c962da0 --- /dev/null +++ b/browser/components/aboutlogins/tests/chrome/aboutlogins_common.js @@ -0,0 +1,97 @@ +"use strict"; + +/* exported asyncElementRendered, importDependencies */ + +/** + * A helper to await on while waiting for an asynchronous rendering of a Custom + * Element. + * @returns {Promise} + */ +function asyncElementRendered() { + return Promise.resolve(); +} + +/** + * Import the templates from the real page to avoid duplication in the tests. + * @param {HTMLIFrameElement} templateFrame - Frame to copy the resources from + * @param {HTMLElement} destinationEl - Where to append the copied resources + */ +function importDependencies(templateFrame, destinationEl) { + let promises = []; + for (let template of templateFrame.contentDocument.querySelectorAll( + "template" + )) { + let imported = document.importNode(template, true); + destinationEl.appendChild(imported); + // Preload the styles in the actual page, to ensure they're loaded on time. + for (let element of imported.content.querySelectorAll( + "link[rel='stylesheet']" + )) { + let clone = element.cloneNode(true); + promises.push( + new Promise(resolve => { + clone.onload = function () { + resolve(); + clone.remove(); + }; + }) + ); + destinationEl.appendChild(clone); + } + } + return Promise.all(promises); +} + +Object.defineProperty(document, "l10n", { + configurable: true, + writable: true, + value: { + connectRoot() {}, + translateElements() { + return Promise.resolve(); + }, + getAttributes(element) { + return { + id: element.getAttribute("data-l10n-id"), + args: element.getAttribute("data-l10n-args") + ? JSON.parse(element.getAttribute("data-l10n-args")) + : {}, + }; + }, + setAttributes(element, id, args) { + element.setAttribute("data-l10n-id", id); + if (args) { + element.setAttribute("data-l10n-args", JSON.stringify(args)); + } else { + element.removeAttribute("data-l10n-args"); + } + }, + }, +}); + +Object.defineProperty(window, "AboutLoginsUtils", { + configurable: true, + writable: true, + value: { + getLoginOrigin(uriString) { + return uriString; + }, + setFocus(element) { + return element.focus(); + }, + async promptForPrimaryPassword(resolve, messageId) { + resolve(true); + }, + doLoginsMatch(login1, login2) { + return ( + login1.origin == login2.origin && + login1.username == login2.username && + login1.password == login2.password + ); + }, + fileImportEnabled: SpecialPowers.getBoolPref( + "signon.management.page.fileImport.enabled" + ), + primaryPasswordEnabled: false, + }, +}); diff --git a/browser/components/aboutlogins/tests/chrome/chrome.ini b/browser/components/aboutlogins/tests/chrome/chrome.ini new file mode 100644 index 0000000000..ac1ba7076c --- /dev/null +++ b/browser/components/aboutlogins/tests/chrome/chrome.ini @@ -0,0 +1,13 @@ +[DEFAULT] +scheme = https +prefs = + identity.fxaccounts.enabled=true +support-files = + aboutlogins_common.js + +[test_confirm_delete_dialog.html] +[test_fxaccounts_button.html] +[test_login_filter.html] +[test_login_item.html] +[test_login_list.html] +[test_menu_button.html] diff --git a/browser/components/aboutlogins/tests/chrome/test_confirm_delete_dialog.html b/browser/components/aboutlogins/tests/chrome/test_confirm_delete_dialog.html new file mode 100644 index 0000000000..68a58aee4f --- /dev/null +++ b/browser/components/aboutlogins/tests/chrome/test_confirm_delete_dialog.html @@ -0,0 +1,127 @@ +<!DOCTYPE HTML> +<html> +<!-- +Test the confirmation-dialog component +--> +<head> + <meta charset="utf-8"> + <title>Test the confirmation-dialog component</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script> + <script type="module" src="chrome://browser/content/aboutlogins/components/confirmation-dialog.mjs"></script> + <script src="aboutlogins_common.js"></script> + + <link rel="stylesheet" href="/tests/SimpleTest/test.css"/> +</head> +<body> + <p id="display"> + </p> +<div id="content" style="display: none"> + <iframe id="templateFrame" src="chrome://browser/content/aboutlogins/aboutLogins.html" + sandbox="allow-same-origin"></iframe> +</div> +<pre id="test"> +</pre> +<script> +/** Test the confirmation-dialog component **/ + +let options = { + title: "confirm-delete-dialog-title", + message: "confirm-delete-dialog-message", + confirmButtonLabel: "confirm-delete-dialog-confirm-button" +}; +let cancelButton, confirmButton, gConfirmationDialog; +add_setup(async () => { + let templateFrame = document.getElementById("templateFrame"); + let displayEl = document.getElementById("display"); + await importDependencies(templateFrame, displayEl); + + gConfirmationDialog = document.createElement("confirmation-dialog"); + displayEl.appendChild(gConfirmationDialog); + ok(gConfirmationDialog, "The dialog should exist"); + + cancelButton = gConfirmationDialog.shadowRoot.querySelector(".cancel-button"); + confirmButton = gConfirmationDialog.shadowRoot.querySelector(".confirm-button"); + ok(cancelButton, "The cancel button should exist"); + ok(confirmButton, "The confirm button should exist"); +}); + +add_task(async function test_escape_key_to_cancel() { + gConfirmationDialog.show(options); + ok(!gConfirmationDialog.hidden, "The dialog should be visible"); + sendKey("ESCAPE"); + ok(gConfirmationDialog.hidden, "The dialog should be hidden after hitting Escape"); + gConfirmationDialog.hide(); +}); + +add_task(async function test_initial_focus() { + gConfirmationDialog.show(options); + ok(!gConfirmationDialog.hidden, "The dialog should be visible"); + is(gConfirmationDialog.shadowRoot.activeElement, confirmButton, + "After initially opening the dialog, the confirm button should be focused"); + gConfirmationDialog.hide(); +}); + +add_task(async function test_tab_focus() { + gConfirmationDialog.show(options); + ok(!gConfirmationDialog.hidden, "The dialog should be visible"); + sendKey("TAB"); + is(gConfirmationDialog.shadowRoot.activeElement, cancelButton, + "After opening the dialog and tabbing once, the cancel button should be focused"); + gConfirmationDialog.hide(); +}); + +add_task(async function test_enter_key_to_cancel() { + let showPromise = gConfirmationDialog.show(options); + ok(!gConfirmationDialog.hidden, "The dialog should be visible"); + sendKey("RETURN"); + try { + await showPromise; + ok(true, "The dialog Promise should resolve after hitting Return with the confirm button focused"); + } catch (ex) { + ok(false, "The dialog Promise should not reject after hitting Return with the confirm button focused"); + } +}); + +add_task(async function test_enter_key_to_confirm() { + let showPromise = gConfirmationDialog.show(options); + ok(!gConfirmationDialog.hidden, "The dialog should be visible"); + sendKey("TAB"); + sendKey("RETURN"); + try { + await showPromise; + ok(false, "The dialog Promise should not resolve after hitting Return with the cancel button focused"); + } catch (ex) { + ok(true, "The dialog Promise should reject after hitting Return with the cancel button focused"); + } +}); + +add_task(async function test_dialog_focus_trap() { + let displayEl = document.getElementById("display"); + let displayElChildSpan = document.createElement("span"); + displayElChildSpan.tabIndex = 0; + displayElChildSpan.id = "display-child"; + displayEl.appendChild(displayElChildSpan); + + gConfirmationDialog.show(options); + + ok(!gConfirmationDialog.hidden, "The dialog should be visible"); + ok(displayElChildSpan.tabIndex === -1, "The tabIndex value for elements with a hardcoded tabIndex attribute should be reset to '-1'.") + ok(displayElChildSpan.dataset.oldTabIndex === "0", "Existing tabIndex values should be stored in `dataset.oldTabIndex`.") + + const isActiveElemDialogOrHTMLorBODY = (elemTagName) => { + return (["HTML", "BODY", "CONFIRMATION-DIALOG"].includes(elemTagName)); + } + + let iterator = 0; + while(iterator < 20) { + sendKey("TAB"); + isnot(document.activeElement.id, "display-child", "The display-child element should not gain focus when the dialog is showing"); + ok(isActiveElemDialogOrHTMLorBODY(document.activeElement.tagName), "The confirmation-dialog should always have focus when the dialog is showing"); + iterator++; + } +}); + +</script> +</body> +</html> diff --git a/browser/components/aboutlogins/tests/chrome/test_fxaccounts_button.html b/browser/components/aboutlogins/tests/chrome/test_fxaccounts_button.html new file mode 100644 index 0000000000..ce6046bf2a --- /dev/null +++ b/browser/components/aboutlogins/tests/chrome/test_fxaccounts_button.html @@ -0,0 +1,96 @@ +<!DOCTYPE HTML> +<html> +<!-- +Test the fxaccounts-button component +--> +<head> + <meta charset="utf-8"> + <title>Test the fxaccounts-button component</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script> + <script type="module" src="chrome://browser/content/aboutlogins/components/fxaccounts-button.mjs"></script> + <script src="aboutlogins_common.js"></script> + + <link rel="stylesheet" href="/tests/SimpleTest/test.css"/> +</head> +<body> + <p id="display"> + </p> +<div id="content" style="display: none"> + <iframe id="templateFrame" src="chrome://browser/content/aboutlogins/aboutLogins.html" + sandbox="allow-same-origin"></iframe> +</div> +<pre id="test"> +</pre> +<script> +/** Test the fxaccounts-button component **/ + +const TEST_AVATAR_URL = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg=="; + +let gFxAccountsButton; +add_setup(async () => { + let templateFrame = document.getElementById("templateFrame"); + let displayEl = document.getElementById("display"); + await importDependencies(templateFrame, displayEl); + + gFxAccountsButton = document.createElement("fxaccounts-button"); + displayEl.appendChild(gFxAccountsButton); +}); + +add_task(async function test_default_state() { + ok(gFxAccountsButton, "FxAccountsButton exists"); + ok(!isHidden(gFxAccountsButton.shadowRoot.querySelector(".logged-out-view")), + "logged-out-view view is visible by default"); + ok(isHidden(gFxAccountsButton.shadowRoot.querySelector(".logged-in-view")), + "logged-in-view view is hidden by default"); +}); + +add_task(async function test_logged_in_without_login_syncing() { + gFxAccountsButton.updateState({ + fxAccountsEnabled: true, + loggedIn: true, + loginSyncingEnabled: false, + }); + + ok(isHidden(gFxAccountsButton.shadowRoot.querySelector(".logged-out-view")), + "logged-out-view view is hidden"); + ok(!isHidden(gFxAccountsButton.shadowRoot.querySelector(".logged-in-view")), + "logged-in-view view is visible"); +}); + +add_task(async function test_logged_in_with_login_syncing() { + const TEST_EMAIL = "test@example.com"; + + gFxAccountsButton.updateState({ + fxAccountsEnabled: true, + loggedIn: true, + loginSyncingEnabled: true, + email: TEST_EMAIL, + avatarURL: TEST_AVATAR_URL, + }); + + ok(isHidden(gFxAccountsButton.shadowRoot.querySelector(".logged-out-view")), + "logged-out-view view is hidden"); + ok(!isHidden(gFxAccountsButton.shadowRoot.querySelector(".logged-in-view")), + "logged-in-view view is visible"); + is(gFxAccountsButton.shadowRoot.querySelector(".fxaccount-email").textContent, + TEST_EMAIL, + "email should be shown"); + is(gFxAccountsButton.shadowRoot.querySelector(".fxaccounts-avatar-button").style.getPropertyValue("--avatar-url"), + `url(${TEST_AVATAR_URL})`, + "--avatar-url should be set"); +}); + +add_task(async function test_fxaccounts_disabled() { + gFxAccountsButton.updateState({ + fxAccountsEnabled: false, + }); + + ok(isHidden(gFxAccountsButton), + "the whole button is hidden when fxaccounts are disabled"); +}); + +</script> + +</body> +</html> diff --git a/browser/components/aboutlogins/tests/chrome/test_login_filter.html b/browser/components/aboutlogins/tests/chrome/test_login_filter.html new file mode 100644 index 0000000000..00e0a96a51 --- /dev/null +++ b/browser/components/aboutlogins/tests/chrome/test_login_filter.html @@ -0,0 +1,178 @@ +<!DOCTYPE HTML> +<html> +<!-- +Test the login-filter component +--> +<head> + <meta charset="utf-8"> + <title>Test the login-filter component</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script> + <script type="module" src="chrome://browser/content/aboutlogins/components/login-filter.mjs"></script> + <script type="module" src="chrome://browser/content/aboutlogins/components/login-list.mjs"></script> + <script src="aboutlogins_common.js"></script> + + <link rel="stylesheet" href="/tests/SimpleTest/test.css"/> +</head> +<body> + <p id="display"> + </p> +<div id="content" style="display: none"> + <iframe id="templateFrame" src="chrome://browser/content/aboutlogins/aboutLogins.html" + sandbox="allow-same-origin"></iframe> +</div> +<pre id="test"> +</pre> +<script> +/** Test the login-filter component **/ + +let gLoginFilter; +let gLoginList; +add_setup(async () => { + let templateFrame = document.getElementById("templateFrame"); + let displayEl = document.getElementById("display"); + importDependencies(templateFrame, displayEl); + + gLoginFilter = document.createElement("login-filter"); + displayEl.appendChild(gLoginFilter); + + gLoginList = document.createElement("login-list"); + displayEl.appendChild(gLoginList); +}); + +add_task(async function test_empty_filter() { + ok(gLoginFilter, "loginFilter exists"); + is(gLoginFilter.shadowRoot.querySelector("input").value, "", "Initially empty"); +}); + +add_task(async function test_input_events() { + let filterEvent = null; + window.addEventListener("AboutLoginsFilterLogins", event => filterEvent = event); + let input = SpecialPowers.wrap(gLoginFilter.shadowRoot.querySelector("input")); + input.setUserInput("test"); + ok(filterEvent, "Filter event received"); + is(filterEvent.detail, "test", "Event includes input value"); +}); + +add_task(async function test_list_filtered() { + const LOGINS = [{ + guid: "123456789", + origin: "https://example.com", + username: "user1", + password: "pass1", + }, { + guid: "987654321", + origin: "https://example.com", + username: "user2", + password: "pass2", + }]; + gLoginList.setLogins(LOGINS); + + let tests = [ + ["", 2], + [LOGINS[0].username, 1], + [LOGINS[0].username + "-notfound", 0], + [LOGINS[0].username.substr(2, 3), 1], + ["", 2], + // The password is also used for search when MP is disabled. + [LOGINS[0].password, 1], + [LOGINS[0].password + "-notfound", 0], + [LOGINS[0].password.substr(2, 3), 1], + ["", 2], + [LOGINS[0].origin, 2], + [LOGINS[0].origin + "-notfound", 0], + [LOGINS[0].origin.substr(2, 3), 2], + ["", 2], + // The guid is not used for search. + [LOGINS[0].guid, 0], + [LOGINS[0].guid + "-notfound", 0], + [LOGINS[0].guid.substr(0, 2), 0], + ["", 2], + ]; + + let loginFilterInput = gLoginFilter.shadowRoot.querySelector("input"); + loginFilterInput.focus(); + + for (let i = 0; i < tests.length; i++) { + info("Testcase: " + i); + + let testObj = { + testCase: i, + query: tests[i][0], + resultExpectedCount: tests[i][1], + }; + + let filterLength = loginFilterInput.value.length; + while (filterLength-- > 0) { + sendKey("BACK_SPACE"); + } + sendString(testObj.query); + + await SimpleTest.promiseWaitForCondition(() => { + let countElement = gLoginList.shadowRoot.querySelector(".count"); + return countElement.hasAttribute("data-l10n-args") && + JSON.parse(countElement.getAttribute("data-l10n-args")).count == testObj.resultExpectedCount; + }, `Waiting for the search result count to update to ${testObj.resultExpectedCount} (tc#${testObj.testCase})`); + } +}); + + add_task(async function test_keys_in_filter() { + const LOGINS = [{ + guid: "123456789", + origin: "https://example.com", + username: "user1", + password: "pass1", + }, { + guid: "987654321", + origin: "https://example.com", + username: "user2", + password: "pass2", + }, { + guid: "333333333", + origin: "https://example.com", + username: "user3", + password: "pass3", + }]; + gLoginList.setLogins(LOGINS); + + const ol = gLoginList.shadowRoot.querySelector("ol"); + const loginFilterInput = gLoginFilter.shadowRoot.querySelector("input"); + loginFilterInput.focus(); + + // Up/down keys must select previous/next item in the list + function pressKeyAndExpectSelection(key, selectedIndex) { + sendKey(key); + ok(ol.querySelectorAll(".login-list-item[data-guid]:not([hidden])")[selectedIndex]?.classList?.contains("keyboard-selected"), + `item ${selectedIndex} should be marked as keyboard-selected`); + is(ol.querySelector(".selected").dataset.guid, LOGINS[selectedIndex].guid, `item ${selectedIndex} must be selected`); + } + + pressKeyAndExpectSelection("DOWN", 1); + pressKeyAndExpectSelection("DOWN", 2); + + // ENTER key in search box must click on selected item in the list + sendKey("RETURN"); + is(ol.querySelector(".selected").dataset.guid, LOGINS[2].guid, "item 2 must still be selected"); + + pressKeyAndExpectSelection("DOWN", 2); + pressKeyAndExpectSelection("UP", 1); + pressKeyAndExpectSelection("UP", 0); + pressKeyAndExpectSelection("UP", 0); + + // ESC must clear search box + async function expectItemCount(count) { + await SimpleTest.promiseWaitForCondition(() => + JSON.parse(gLoginList.shadowRoot.querySelector(".count").getAttribute("data-l10n-args"))?.count == count, + `Waiting for the search result count to update to ${count}`); + } + + sendString("unique string"); + await expectItemCount(0); + sendKey("Escape"); + ok(!loginFilterInput.value, "ESC must clear filter input"); + await expectItemCount(LOGINS.length); + }); +</script> + +</body> +</html> diff --git a/browser/components/aboutlogins/tests/chrome/test_login_item.html b/browser/components/aboutlogins/tests/chrome/test_login_item.html new file mode 100644 index 0000000000..a7946a0618 --- /dev/null +++ b/browser/components/aboutlogins/tests/chrome/test_login_item.html @@ -0,0 +1,481 @@ +<!DOCTYPE HTML> +<html> +<!-- +Test the login-item component +--> +<head> + <meta charset="utf-8"> + <title>Test the login-item component</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script> + <script type="module" src="chrome://browser/content/aboutlogins/components/login-item.mjs"></script> + <script type="module" src="chrome://browser/content/aboutlogins/components/confirmation-dialog.mjs"></script> + <script type="module" src="chrome://browser/content/aboutlogins/components/login-timeline.mjs"></script> + <script src="aboutlogins_common.js"></script> + + <link rel="stylesheet" href="/tests/SimpleTest/test.css"/> +</head> +<body> + <p id="display"> + </p> +<div id="content" style="display: none"> + <iframe id="templateFrame" src="chrome://browser/content/aboutlogins/aboutLogins.html" + sandbox="allow-same-origin"></iframe> +</div> +<pre id="test"> +</pre> +<script type="module"> + +import { CONCEALED_PASSWORD_TEXT } from "chrome://browser/content/aboutlogins/aboutLoginsUtils.mjs"; + +/** Test the login-item component **/ + +let gLoginItem, gConfirmationDialog; +const TEST_LOGIN_1 = { + guid: "123456789", + origin: "https://example.com", + username: "user1", + password: "pass1", + timeCreated: "1000", + timePasswordChanged: "2000", + timeLastUsed: "4000", +}; + +const TEST_LOGIN_2 = { + guid: "987654321", + origin: "https://example.com", + username: "user2", + password: "pass2", + timeCreated: "2000", + timePasswordChanged: "4000", + timeLastUsed: "8000", +}; + +const TEST_BREACH = { + Name: "Test-Breach", + breachAlertURL: "https://monitor.firefox.com/breach-details/Test-Breach", +}; + +const TEST_BREACHES_MAP = new Map(); +TEST_BREACHES_MAP.set(TEST_LOGIN_1.guid, TEST_BREACH); + +const TEST_VULNERABLE_MAP = new Map(); +TEST_VULNERABLE_MAP.set(TEST_LOGIN_2.guid, true); + +const getLoginTimeline = loginItem => + loginItem.shadowRoot.querySelector("login-timeline"); + +const verifyTimelineActions = (actions, expectedActions) => { + is( + actions.length, + expectedActions.length, + `Number timeline actions length is correct. Actual: ${actions.length}. Expected: ${expectedActions.length}` + ); + + actions.forEach((point, index) => { + let actionId = document.l10n.getAttributes(point).id; + let expectedAction = expectedActions[index]; + + is( + actionId, + expectedAction, + `Rendered action is correct. Actual: ${actionId}. Expected: ${expectedAction}` + ); + }); +}; + +add_setup(async () => { + let templateFrame = document.getElementById("templateFrame"); + let displayEl = document.getElementById("display"); + await importDependencies(templateFrame, displayEl); + + gLoginItem = document.createElement("login-item"); + displayEl.appendChild(gLoginItem); + + gConfirmationDialog = document.createElement("confirmation-dialog"); + gConfirmationDialog.hidden = true; + displayEl.appendChild(gConfirmationDialog); +}); + +add_task(async function test_empty_item() { + ok(gLoginItem, "loginItem exists"); + is(gLoginItem.shadowRoot.querySelector("a[name='origin']").getAttribute("href"), "", "origin should be blank"); + is(gLoginItem.shadowRoot.querySelector("input[name='username']").value, "", "username should be blank"); + is(gLoginItem._passwordInput.value, "", "password should be blank"); + ok(!gLoginItem._passwordInput.isConnected, "Real password input should be disconnected"); + is(gLoginItem._passwordDisplayInput.value, "", "password display should be blank"); + ok(!isHidden(gLoginItem._passwordDisplayInput), "Password display input should be visible") + ok(isHidden(getLoginTimeline(gLoginItem)), "Timeline should be hidden"); +}); + +add_task(async function test_set_login() { + gLoginItem.setLogin(TEST_LOGIN_1); + await asyncElementRendered(); + + ok(!gLoginItem.dataset.editing, "loginItem should not be in 'edit' mode"); + ok(!gLoginItem.dataset.isNewLogin, "loginItem should not be in 'isNewLogin' mode"); + ok(isHidden(gLoginItem._originInput), "Origin input should be hidden when not in edit mode"); + ok(!isHidden(gLoginItem._originDisplayInput), "Origin display link should be visible when not in edit mode"); + let originLink = gLoginItem.shadowRoot.querySelector("a[name='origin']"); + is(originLink.getAttribute("href"), TEST_LOGIN_1.origin, "origin should be populated"); + let usernameInput = gLoginItem.shadowRoot.querySelector("input[name='username']"); + is(usernameInput.value, TEST_LOGIN_1.username, "username should be populated"); + is(document.l10n.getAttributes(usernameInput).id, "about-logins-login-item-username", "username field should have default placeholder when not editing"); + + let passwordInput = gLoginItem._passwordInput; + is(passwordInput.value, TEST_LOGIN_1.password, "password should be populated"); + ok(!passwordInput.hasAttribute("value"), "Password shouldn't be exposed in @value"); + ok(!gLoginItem._passwordInput.isConnected, "Real password input should be disconnected"); + let passwordDisplayInput = gLoginItem._passwordDisplayInput; + is(passwordDisplayInput.value, CONCEALED_PASSWORD_TEXT, "password display should be populated"); + ok(!isHidden(passwordDisplayInput), "Password display input should be visible"); + + let timeline = getLoginTimeline(gLoginItem); + ok(!isHidden(timeline), "Timeline should be visible"); + let actions = timeline.shadowRoot.querySelectorAll(".action"); + verifyTimelineActions(actions, [ + "login-item-timeline-action-created", + "login-item-timeline-action-updated", + "login-item-timeline-action-used", + ]); + + let copyButtons = [...gLoginItem.shadowRoot.querySelectorAll(".copy-button")]; + ok(copyButtons.every(button => !isHidden(button)), "The copy buttons should be visible when viewing a login"); + + let loginNoUsername = Object.assign({}, TEST_LOGIN_1, {username: ""}); + gLoginItem.setLogin(loginNoUsername); + ok(!gLoginItem.dataset.editing, "loginItem should not be in 'edit' mode"); + is(document.l10n.getAttributes(usernameInput).id, "about-logins-login-item-username", "username field should have default placeholder when username is not present and not editing"); + let copyUsernameButton = gLoginItem.shadowRoot.querySelector(".copy-username-button"); + ok(copyUsernameButton.disabled, "The copy-username-button should be disabled if there is no username"); + + usernameInput.placeholder = "dummy placeholder"; + gLoginItem.shadowRoot.querySelector(".edit-button").click(); + await asyncElementRendered(); + is( + document.l10n.getAttributes(usernameInput).id, + null, + "there should be no placeholder id on the username input in edit mode" + ); + is(usernameInput.placeholder, "", "there should be no placeholder on the username input in edit mode"); +}); + +add_task(async function test_update_breaches() { + gLoginItem.setLogin(TEST_LOGIN_1); + gLoginItem.setBreaches(TEST_BREACHES_MAP); + await asyncElementRendered(); + + let breachAlert = gLoginItem.shadowRoot.querySelector(".breach-alert"); + ok(!isHidden(breachAlert), "Breach alert should be visible"); + is(breachAlert.querySelector(".alert-link").href, TEST_LOGIN_1.origin + "/", "Link in the text should point to the login origin"); + let vulernableAlert = gLoginItem.shadowRoot.querySelector(".vulnerable-alert"); + ok(isHidden(vulernableAlert), "Vulnerable alert should not be visible on a non-vulnerable login."); +}); + +add_task(async function test_breach_alert_is_correctly_hidden() { + gLoginItem.setLogin(TEST_LOGIN_2); + gLoginItem.setBreaches(TEST_BREACHES_MAP); + await asyncElementRendered(); + + let breachAlert = gLoginItem.shadowRoot.querySelector(".breach-alert"); + ok(isHidden(breachAlert), "Breach alert should not be visible on login without an associated breach."); + let vulernableAlert = gLoginItem.shadowRoot.querySelector(".vulnerable-alert"); + ok(isHidden(vulernableAlert), "Vulnerable alert should not be visible on a non-vulnerable login."); +}); + +add_task(async function test_update_vulnerable() { + gLoginItem.setLogin(TEST_LOGIN_2); + gLoginItem.setVulnerableLogins(TEST_VULNERABLE_MAP); + await asyncElementRendered(); + + let breachAlert = gLoginItem.shadowRoot.querySelector(".breach-alert"); + ok(isHidden(breachAlert), "Breach alert should not be visible on login without an associated breach."); + let vulernableAlert = gLoginItem.shadowRoot.querySelector(".vulnerable-alert"); + ok(!isHidden(vulernableAlert), "Vulnerable alert should be visible"); + is(vulernableAlert.querySelector(".alert-link").href, TEST_LOGIN_2.origin + "/", "Link in the text should point to the login origin"); +}); + +add_task(async function test_vulnerable_alert_is_correctly_hidden() { + gLoginItem.setLogin(TEST_LOGIN_1); + gLoginItem.setVulnerableLogins(TEST_VULNERABLE_MAP); + gLoginItem.setBreaches(new Map()); + await asyncElementRendered(); + + let breachAlert = gLoginItem.shadowRoot.querySelector(".breach-alert"); + ok(isHidden(breachAlert), "Breach alert should not be visible on login without an associated breach."); + let vulernableAlert = gLoginItem.shadowRoot.querySelector(".vulnerable-alert"); + ok(isHidden(vulernableAlert), "Vulnerable alert should not be visible on a non-vulnerable login."); +}); + +add_task(async function test_edit_login() { + gLoginItem.setLogin(TEST_LOGIN_1); + let usernameInput = gLoginItem.shadowRoot.querySelector("input[name='username']"); + usernameInput.placeholder = "dummy placeholder"; + gLoginItem.shadowRoot.querySelector(".edit-button").click(); + await asyncElementRendered(); + await asyncElementRendered(); + + ok(gLoginItem.dataset.editing, "loginItem should be in 'edit' mode"); + ok(isHidden(gLoginItem.shadowRoot.querySelector(".edit-button")), "edit button should be hidden in 'edit' mode"); + ok(!gLoginItem.dataset.isNewLogin, "loginItem should not be in 'isNewLogin' mode"); + let deleteButton = gLoginItem.shadowRoot.querySelector(".delete-button"); + ok(!deleteButton.disabled, "Delete button should be enabled when editing a login"); + ok(isHidden(gLoginItem._originInput), "Origin input should be hidden in edit mode"); + ok(!isHidden(gLoginItem._originDisplayInput), "Origin display link should be visible in edit mode"); + is(gLoginItem.shadowRoot.querySelector("a[name='origin']").getAttribute("href"), TEST_LOGIN_1.origin, "origin should be populated"); + is(usernameInput.value, TEST_LOGIN_1.username, "username should be populated"); + is(usernameInput, document.activeElement?.shadowRoot?.activeElement, "username is focused"); + is(usernameInput.selectionStart, 0, "username value is selected from start"); + is(usernameInput.selectionEnd, usernameInput.value.length, "username value is selected to the end"); + is( + document.l10n.getAttributes(usernameInput).id, + null, + "there should be no placeholder id on the username input in edit mode" + ); + is(usernameInput.placeholder, "", "there should be no placeholder on the username input in edit mode"); + is(gLoginItem._passwordInput.value, TEST_LOGIN_1.password, "password should be populated"); + is(gLoginItem._passwordDisplayInput.value, CONCEALED_PASSWORD_TEXT, "password display should be populated"); + + let timeline = getLoginTimeline(gLoginItem); + ok(!isHidden(timeline), "Timeline should be visible"); + + let copyButtons = [...gLoginItem.shadowRoot.querySelectorAll(".copy-button")]; + ok(copyButtons.every(button => isHidden(button)), "The copy buttons should be hidden when editing a login"); + + usernameInput.value = "newUsername"; + gLoginItem._passwordInput.value = "newPassword"; + + let updateEventDispatched = false; + document.addEventListener("AboutLoginsUpdateLogin", event => { + is(event.detail.guid, TEST_LOGIN_1.guid, "event should include guid"); + is(event.detail.origin, TEST_LOGIN_1.origin, "event should include origin"); + is(event.detail.username, "newUsername", "event should include new username"); + is(event.detail.password, "newPassword", "event should include new password"); + updateEventDispatched = true; + }, {once: true}); + gLoginItem.shadowRoot.querySelector(".save-changes-button").click(); + ok(updateEventDispatched, "Clicking the .save-changes-button should dispatch the AboutLoginsUpdateLogin event"); +}); + +add_task(async function test_edit_login_cancel() { + gLoginItem.setLogin(TEST_LOGIN_1); + gLoginItem.shadowRoot.querySelector(".edit-button").click(); + await asyncElementRendered(); + + ok(gLoginItem.dataset.editing, "loginItem should be in 'edit' mode"); + is(!!gLoginItem.dataset.isNewLogin, false, + "loginItem should not be in 'isNewLogin' mode"); + + gLoginItem.shadowRoot.querySelector(".cancel-button").click(); + gConfirmationDialog.shadowRoot.querySelector(".confirm-button").click(); + + await SimpleTest.promiseWaitForCondition( + () => gConfirmationDialog.hidden, + "waiting for confirmation dialog to hide" + ); + + ok(!gLoginItem.dataset.editing, "loginItem should not be in 'edit' mode"); + ok(!gLoginItem.dataset.isNewLogin, "loginItem should not be in 'isNewLogin' mode"); +}); + +add_task(async function test_reveal_password_change_selected_login() { + gLoginItem.setLogin(TEST_LOGIN_1); + let revealCheckbox = gLoginItem.shadowRoot.querySelector(".reveal-password-checkbox"); + let passwordInput = gLoginItem._passwordInput; + + ok(!revealCheckbox.checked, "reveal-checkbox should not be checked by default"); + is(passwordInput.type, "password", "Password should be masked by default"); + revealCheckbox.click(); + ok(revealCheckbox.checked, "reveal-checkbox should be checked after clicking"); + await SimpleTest.promiseWaitForCondition(() => passwordInput.type == "text", + "waiting for password input type to change after checking for primary password"); + is(passwordInput.type, "text", "Password should be unmasked when checkbox is clicked"); + ok(!isHidden(passwordInput), "Password input should be visible"); + + let editButton = gLoginItem.shadowRoot.querySelector(".edit-button"); + editButton.click(); + await asyncElementRendered(); + ok(!isHidden(passwordInput), "Password input should still be visible"); + ok(revealCheckbox.checked, "reveal-checkbox should remain checked when entering 'edit' mode"); + gLoginItem.shadowRoot.querySelector(".cancel-button").click(); + ok(!revealCheckbox.checked, "reveal-checkbox should be unchecked after canceling 'edit' mode"); + revealCheckbox.click(); + ok(isHidden(passwordInput), "Password input should be hidden"); + ok(!isHidden(gLoginItem._passwordDisplayInput), "Password display should be visible"); + gLoginItem.setLogin(TEST_LOGIN_2); + ok(!revealCheckbox.checked, "reveal-checkbox should be unchecked when changing logins"); + is(passwordInput.type, "password", "Password should be masked by default when switching logins"); + ok(isHidden(passwordInput), "Password input should be hidden"); + ok(!isHidden(gLoginItem._passwordDisplayInput), "Password display should be visible"); +}); + +add_task(async function test_set_login_empty() { + gLoginItem.setLogin({}); + await asyncElementRendered(); + + ok(gLoginItem.dataset.editing, "loginItem should be in 'edit' mode"); + ok(isHidden(gLoginItem.shadowRoot.querySelector(".edit-button")), "edit button should be hidden in 'edit' mode"); + ok(gLoginItem.dataset.isNewLogin, "loginItem should be in 'isNewLogin' mode"); + let deleteButton = gLoginItem.shadowRoot.querySelector(".delete-button"); + ok(deleteButton.disabled, "Delete button should be disabled when creating a login"); + ok(!isHidden(gLoginItem._originInput), "Origin input should be visible in new login edit mode"); + ok(isHidden(gLoginItem._originDisplayInput), "Origin display should be hidden in new login edit mode"); + is(gLoginItem.shadowRoot.querySelector("input[name='origin']").value, "", "origin should be empty"); + is(gLoginItem.shadowRoot.querySelector("input[name='username']").value, "", "username should be empty"); + is(gLoginItem._passwordInput.value, "", "password should be empty"); + ok(!isHidden(gLoginItem._passwordInput), "Real password input should be visible in edit mode"); + ok(isHidden(gLoginItem._passwordDisplayInput), "Password display should be hidden in edit mode"); + ok(!gLoginItem._passwordInput.hasAttribute("value"), "Password shouldn't be exposed in @value"); + + let timeline = getLoginTimeline(gLoginItem); + ok(isHidden(timeline), "Timeline should be visible"); + + let copyButtons = [...gLoginItem.shadowRoot.querySelectorAll(".copy-button")]; + ok(copyButtons.every(button => isHidden(button)), "The copy buttons should be hidden when creating a login"); + + let createEventDispatched = false; + document.addEventListener("AboutLoginsCreateLogin", event => { + createEventDispatched = true; + }, {once: true}); + gLoginItem.shadowRoot.querySelector(".save-changes-button").click(); + ok(!createEventDispatched, "Clicking the .save-changes-button shouldn't dispatch the event when fields are invalid"); + let originInput = gLoginItem.shadowRoot.querySelector("input[name='origin']"); + ok(originInput.matches(":invalid"), "origin value is required"); + is(originInput.value, "", "origin input should be blank at start"); + + for (let originTuple of [ + ["ftp://ftp.example.com/", "ftp://ftp.example.com/"], + ["https://example.com/", "https://example.com/"], + ["http://example.com/", "http://example.com/"], + ["www.example.com/bar", "https://www.example.com/bar"], + ["example.com/foo", "https://example.com/foo"], + ]) { + originInput.value = originTuple[0]; + sendKey("TAB"); + is(originInput.value, originTuple[1], + "origin input should have https:// prefix when not provided by user"); + // Return focus back to the origin input + synthesizeKey("VK_TAB", { shiftKey: true }); + } + + gLoginItem.shadowRoot.querySelector("input[name='username']").value = "user1"; + gLoginItem._passwordInput.value = "pass1"; + + document.addEventListener("AboutLoginsCreateLogin", event => { + is(event.detail.guid, undefined, "event should not include guid"); + is(event.detail.origin, "https://example.com/foo", "event should include origin"); + is(event.detail.username, "user1", "event should include new username"); + is(event.detail.password, "pass1", "event should include new password"); + createEventDispatched = true; + }, {once: true}); + gLoginItem.shadowRoot.querySelector(".save-changes-button").click(); + ok(createEventDispatched, "Clicking the .save-changes-button should dispatch the AboutLoginsCreateLogin event"); +}); + +add_task(async function test_different_login_modified() { + gLoginItem.setLogin(TEST_LOGIN_1); + let otherLogin = Object.assign({}, TEST_LOGIN_1, {username: "fakeuser", guid: "fakeguid"}); + gLoginItem.loginModified(otherLogin); + await asyncElementRendered(); + + is(gLoginItem.shadowRoot.querySelector("a[name='origin']").getAttribute("href"), TEST_LOGIN_1.origin, "origin should be unchanged"); + is(gLoginItem.shadowRoot.querySelector("input[name='username']").value, TEST_LOGIN_1.username, "username should be unchanged"); + is(gLoginItem._passwordInput.value, TEST_LOGIN_1.password, "password should be unchanged"); + ok(!gLoginItem._passwordInput.hasAttribute("value"), "Password shouldn't be exposed in @value"); + ok(!gLoginItem._passwordInput.isConnected, "Real password input should be disconnected in masked non-edit mode"); + ok(!isHidden(gLoginItem._passwordDisplayInput), "Password display should be visible in masked non-edit mode"); +}); + +add_task(async function test_different_login_removed() { + gLoginItem.setLogin(TEST_LOGIN_1); + let otherLogin = Object.assign({}, TEST_LOGIN_1, {username: "fakeuser", guid: "fakeguid"}); + gLoginItem.loginRemoved(otherLogin); + await asyncElementRendered(); + + is(gLoginItem.shadowRoot.querySelector("a[name='origin']").getAttribute("href"), TEST_LOGIN_1.origin, "origin should be unchanged"); + is(gLoginItem.shadowRoot.querySelector("input[name='username']").value, TEST_LOGIN_1.username, "username should be unchanged"); + is(gLoginItem._passwordInput.value, TEST_LOGIN_1.password, "password should be unchanged"); + ok(!gLoginItem._passwordInput.hasAttribute("value"), "Password shouldn't be exposed in @value"); + ok(!gLoginItem._passwordInput.isConnected, "Real password input should be disconnected in masked non-edit mode"); + ok(!isHidden(gLoginItem._passwordDisplayInput), "Password display should be visible in masked non-edit mode"); +}); + +add_task(async function test_login_modified() { + gLoginItem.setLogin(TEST_LOGIN_1); + let modifiedLogin = Object.assign({}, TEST_LOGIN_1, {username: "updateduser"}); + gLoginItem.loginModified(modifiedLogin); + await asyncElementRendered(); + + is(gLoginItem.shadowRoot.querySelector("a[name='origin']").getAttribute("href"), modifiedLogin.origin, "origin should be updated"); + is(gLoginItem.shadowRoot.querySelector("input[name='username']").value, modifiedLogin.username, "username should be updated"); + is(gLoginItem._passwordInput.value, modifiedLogin.password, "password should be updated"); + ok(!gLoginItem._passwordInput.hasAttribute("value"), "Password shouldn't be exposed in @value"); + ok(!gLoginItem._passwordInput.isConnected, "Real password input should be disconnected in masked non-edit mode"); + ok(!isHidden(gLoginItem._passwordDisplayInput), "Password display should be visible in masked non-edit mode"); +}); + +add_task(async function test_login_removed() { + gLoginItem.setLogin(TEST_LOGIN_1); + gLoginItem.loginRemoved(TEST_LOGIN_1); + await asyncElementRendered(); + + is(gLoginItem.shadowRoot.querySelector("input[name='origin']").value, "", "origin should be cleared"); + is(gLoginItem.shadowRoot.querySelector("input[name='username']").value, "", "username should be cleared"); + is(gLoginItem._passwordInput.value, "", "password should be cleared"); + ok(!gLoginItem._passwordInput.hasAttribute("value"), "Password shouldn't be exposed in @value"); + + let timeline = getLoginTimeline(gLoginItem); + ok(isHidden(timeline), "Timeline should be visible"); +}); + +add_task(async function test_login_long_username_scrollLeft_reset() { + let loginLongUsername = Object.assign({}, TEST_LOGIN_1, {username: "user2longnamelongnamelongnamelongnamelongname"}); + gLoginItem.setLogin(loginLongUsername); + gLoginItem.shadowRoot.querySelector(".edit-button").click(); + await asyncElementRendered(); + await asyncElementRendered(); + let usernameInput = gLoginItem.shadowRoot.querySelector("input[name='username']"); + usernameInput.scrollLeft = usernameInput.scrollLeftMax; + gLoginItem.shadowRoot.querySelector(".cancel-button").click(); + is(usernameInput.scrollLeft, 0, "username input should be scrolled horizontally to the beginning"); +}); + +add_task(async function test_copy_button_state() { + gLoginItem.setLogin(TEST_LOGIN_1); + await asyncElementRendered(); + + let copyUsernameButton = gLoginItem.shadowRoot.querySelector(".copy-username-button"); + ok(!copyUsernameButton.disabled, "The copy-username-button should be enabled"); + + let copyPasswordButton = gLoginItem.shadowRoot.querySelector(".copy-password-button"); + ok(!copyPasswordButton.disabled, "The copy-passwoed-button should be enabled"); + + copyUsernameButton.click(); + ok(copyUsernameButton.disabled, "The copy-username-button should be disabled when it is clicked"); + ok(!copyPasswordButton.disabled, "The copy-passwoed-button should be enabled when the copy-username-button is clicked"); + + copyPasswordButton.click(); + await SimpleTest.promiseWaitForCondition(() => copyPasswordButton.disabled, + "waiting for copy-password-button to become disabled after checking for primary password"); + + ok(copyPasswordButton.disabled, "The copy-passwoed-button should be disabled when it is clicked"); + ok(!copyUsernameButton.disabled, "The copy-username-button should be enabled when the copy-password-button is clicked"); + + let loginNoUsername = Object.assign({}, TEST_LOGIN_2, {username: ""}); + gLoginItem.setLogin(loginNoUsername); + + ok(copyUsernameButton.disabled, "The copy-username-button should be disabled when the username is empty"); + ok(!copyPasswordButton.disabled, "The copy-passwoed-button should be enabled"); + + copyPasswordButton.click(); + await SimpleTest.promiseWaitForCondition(() => copyPasswordButton.disabled, + "waiting for copy-password-button to become disabled after checking for primary password"); + + ok(copyPasswordButton.disabled, "The copy-passwoed-button should be disabled when it is clicked"); + ok(copyUsernameButton.disabled, "The copy-username-button should still be disabled after clicking the password button when the username is empty"); +}); + +</script> + +</body> +</html> diff --git a/browser/components/aboutlogins/tests/chrome/test_login_list.html b/browser/components/aboutlogins/tests/chrome/test_login_list.html new file mode 100644 index 0000000000..98342978fb --- /dev/null +++ b/browser/components/aboutlogins/tests/chrome/test_login_list.html @@ -0,0 +1,697 @@ +<!DOCTYPE HTML> +<html> +<!-- +Test the login-list component +--> +<head> + <meta charset="utf-8"> + <title>Test the login-list component</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script> + <script type="module" src="chrome://browser/content/aboutlogins/components/login-list.mjs"></script> + <script src="aboutlogins_common.js"></script> + + <link rel="stylesheet" href="/tests/SimpleTest/test.css"/> +</head> +<body> + <p id="display"> + </p> +<div id="content" style="display: none"> + <iframe id="templateFrame" src="chrome://browser/content/aboutlogins/aboutLogins.html" + sandbox="allow-same-origin"></iframe> +</div> +<pre id="test"> +</pre> +<script> +/** Test the login-list component **/ + +let gLoginList; +const TEST_LOGIN_1 = { + guid: "123456789", + origin: "https://abc.example.com", + httpRealm: null, + username: "user1", + password: "pass1", + title: "abc.example.com", + // new Date("December 13, 2018").getTime() + timeLastUsed: 1544677200000, + timePasswordChanged: 1544677200000, +}; +const TEST_LOGIN_2 = { + guid: "987654321", + origin: "https://example.com", + httpRealm: null, + username: "user2", + password: "pass2", + title: "example.com", + // new Date("June 1, 2019").getTime() + timeLastUsed: 1559361600000, + timePasswordChanged: 1559361600000, +}; +const TEST_LOGIN_3 = { + guid: "1111122222", + origin: "https://def.example.com", + httpRealm: null, + username: "", + password: "pass3", + title: "def.example.com", + // new Date("June 1, 2019").getTime() + timeLastUsed: 1559361600000, + timePasswordChanged: 1559361600000, +}; +const TEST_HTTP_AUTH_LOGIN_1 = { + guid: "8675309", + origin: "https://httpauth.example.com", + httpRealm: "My Realm", + username: "http_auth_user", + password: "pass4", + title: "httpauth.example.com (My Realm)", + // new Date("June 1, 2019").getTime() + timeLastUsed: 1559361600000, + timePasswordChanged: 1559361600000, +}; + +const TEST_BREACH = { + AddedDate: "2018-12-20T23:56:26Z", + BreachDate: "2018-12-11", + Domain: "abc.example.com", + Name: "ABC Example", + PwnCount: 1643100, + DataClasses: ["Usernames", "Passwords"], + _status: "synced", + id: "047940fe-d2fd-4314-b636-b4a952ee0043", + last_modified: "1541615610052", + schema: "1541615609018", + breachAlertURL: "https://monitor.firefox.com/breach-details/ABC-Example", +}; + + +const TEST_BREACHES_MAP = new Map(); +TEST_BREACHES_MAP.set(TEST_LOGIN_1.guid, TEST_BREACH); + +add_setup(async () => { + let templateFrame = document.getElementById("templateFrame"); + let displayEl = document.getElementById("display"); + await importDependencies(templateFrame, displayEl); + + gLoginList = document.createElement("login-list"); + displayEl.appendChild(gLoginList); +}); + +add_task(async function test_empty_list() { + ok(gLoginList, "loginList exists"); + is(gLoginList.textContent, "", "Initially empty"); + gLoginList.classList.add("no-logins"); + let loginListBox = gLoginList.shadowRoot.querySelector("ol"); + let introText = gLoginList.shadowRoot.querySelector(".intro"); + let emptySearchText = gLoginList.shadowRoot.querySelector(".empty-search-message"); + ok(isHidden(loginListBox), "The login-list ol should be hidden when there are no logins"); + ok(!isHidden(introText), "The intro text should be visible when the list is empty"); + ok(isHidden(emptySearchText), "The empty-search text should be hidden when there are no logins"); + + gLoginList.classList.add("create-login-selected"); + ok(!isHidden(loginListBox), "The login-list ol should be visible when the create-login mode is active"); + ok(isHidden(introText), "The intro text should be hidden when the create-login mode is active"); + ok(isHidden(emptySearchText), "The empty-search text should be hidden when the create-login mode is active"); + gLoginList.classList.remove("create-login-selected"); + + window.dispatchEvent(new CustomEvent("AboutLoginsFilterLogins", { + bubbles: true, + detail: "foo", + })); + ok(isHidden(loginListBox), "The login-list ol should be hidden when there are no logins"); + ok(!isHidden(introText), "The intro text should be visible when the list is empty"); + ok(isHidden(emptySearchText), "The empty-search text should be hidden when there are no logins even if a filter is applied"); + + // Clean up state for next test + gLoginList.classList.remove("no-logins"); + window.dispatchEvent(new CustomEvent("AboutLoginsFilterLogins", { + bubbles: true, + detail: "", + })); +}); + +add_task(async function test_keyboard_navigation() { + let logins = []; + for (let i = 0; i < 20; i++) { + let suffix = i % 2 ? "odd" : "even"; + logins.push(Object.assign({}, TEST_LOGIN_1, { + guid: "" + i, + username: `testuser-${suffix}-${i}`, + password: `testpass-${suffix}-${i}`, + })); + } + gLoginList.setLogins(logins); + let ol = gLoginList.shadowRoot.querySelector("ol"); + is(ol.querySelectorAll(".login-list-item[data-guid]").length, 20, "there should be 20 logins in the list"); + is(ol.querySelectorAll(".login-list-item[data-guid]:not([hidden])").length, 20, "all logins should be visible"); + window.dispatchEvent(new CustomEvent("AboutLoginsFilterLogins", { + bubbles: true, + detail: "odd", + })); + is(ol.querySelectorAll(".login-list-item[data-guid]:not([hidden])").length, 10, "half of the logins in the list"); + + while (document.activeElement != gLoginList && + gLoginList.shadowRoot.querySelector("#login-sort") != gLoginList.shadowRoot.activeElement) { + sendKey("TAB"); + await new Promise(resolve => requestAnimationFrame(resolve)); + } + sendKey("TAB"); + let loginSort = gLoginList.shadowRoot.querySelector("#login-sort"); + await SimpleTest.promiseWaitForCondition(() => loginSort == gLoginList.shadowRoot.activeElement, + "waiting for login-sort to get focus"); + ok(loginSort == gLoginList.shadowRoot.activeElement, "#login-sort should be focused after tabbing to it"); + + sendKey("TAB"); + await SimpleTest.promiseWaitForCondition(() => ol.matches(":focus"), + "waiting for 'ol' to get focus"); + ok(ol.matches(":focus"), "'ol' should be focused after tabbing to it"); + + let selectedGuid = ol.querySelectorAll(".login-list-item[data-guid]:not([hidden])")[0].dataset.guid; + let loginSelectedEvent = null; + gLoginList.addEventListener("AboutLoginsLoginSelected", event => loginSelectedEvent = event, {once: true}); + sendKey("RETURN"); + is(ol.querySelector(".selected").dataset.guid, selectedGuid, "item should be marked as selected"); + ok(loginSelectedEvent, "AboutLoginsLoginSelected event should be dispatched on pressing Enter"); + is(loginSelectedEvent.detail.guid, selectedGuid, "event should have expected login attached"); + + for (let [keyFwd, keyRev] of [["LEFT", "RIGHT"], ["DOWN", "UP"]]) { + sendKey(keyFwd); + await SimpleTest.promiseWaitForCondition(() => ol.getAttribute("aria-activedescendant") == ol.querySelectorAll(".login-list-item[data-guid]:not([hidden])")[1].id, + `waiting for second item in list to get focused (${keyFwd})`); + ok(ol.querySelectorAll(".login-list-item[data-guid]:not([hidden])")[1].classList.contains("keyboard-selected"), `second item should be marked as keyboard-selected (${keyFwd})`); + + sendKey(keyRev); + await SimpleTest.promiseWaitForCondition(() => ol.getAttribute("aria-activedescendant") == ol.querySelectorAll(".login-list-item[data-guid]:not([hidden])")[0].id, + `waiting for first item in list to get focused (${keyRev})`); + ok(ol.querySelectorAll(".login-list-item[data-guid]:not([hidden])")[0].classList.contains("keyboard-selected"), `first item should be marked as keyboard-selected (${keyRev})`); + } + + sendKey("DOWN"); + await SimpleTest.promiseWaitForCondition(() => ol.getAttribute("aria-activedescendant") == ol.querySelectorAll(".login-list-item[data-guid]:not([hidden])")[1].id, + `waiting for second item in list to get focused (DOWN)`); + ok(ol.querySelectorAll(".login-list-item[data-guid]:not([hidden])")[1].classList.contains("keyboard-selected"), `second item should be marked as keyboard-selected (DOWN)`); + selectedGuid = ol.querySelectorAll(".login-list-item[data-guid]:not([hidden])")[1].dataset.guid; + + synthesizeKey("VK_DOWN", { repeat: 5 }); + ok(ol.querySelectorAll(".login-list-item[data-guid]:not([hidden])")[6].classList.contains("keyboard-selected"), `sixth item should be marked as keyboard-selected after 5 DOWN repeats`); + synthesizeKey("VK_UP", { repeat: 5 }); + ok(ol.querySelectorAll(".login-list-item[data-guid]:not([hidden])")[1].classList.contains("keyboard-selected"), `second item should be marked as keyboard-selected again after 5 UP repeats`); + + loginSelectedEvent = null; + gLoginList.addEventListener("AboutLoginsLoginSelected", event => loginSelectedEvent = event, {once: true}); + sendKey("RETURN"); + is(ol.querySelector(".selected").dataset.guid, selectedGuid, "item should be marked as selected"); + ok(loginSelectedEvent, "AboutLoginsLoginSelected event should be dispatched on pressing Enter"); + is(loginSelectedEvent.detail.guid, selectedGuid, "event should have expected login attached"); + + // Clean up state for next test + gLoginList.classList.remove("no-logins"); + window.dispatchEvent(new CustomEvent("AboutLoginsFilterLogins", { + bubbles: true, + detail: "", + })); +}); + +add_task(async function test_empty_login_username_in_list() { + // Clear the selection so the 'new' login will be in the list too. + window.dispatchEvent(new CustomEvent("AboutLoginsLoginSelected", { + detail: {}, + })); + + gLoginList.setLogins([TEST_LOGIN_3]); + let loginListItems = gLoginList.shadowRoot.querySelectorAll(".login-list-item:not(#new-login-list-item, [hidden])"); + is(loginListItems.length, 1, "The one stored login should be displayed"); + is(loginListItems[0].dataset.guid, TEST_LOGIN_3.guid, "login-list-item should have correct guid attribute"); + let loginUsername = loginListItems[0].querySelector(".username"); + is(loginUsername.getAttribute("data-l10n-id"), "login-list-item-subtitle-missing-username", "login should show missing username text"); +}); + +add_task(async function test_populated_list() { + gLoginList.setLogins([TEST_LOGIN_1, TEST_LOGIN_2]); + let loginListItems = gLoginList.shadowRoot.querySelectorAll(".login-list-item:not(#new-login-list-item, [hidden])"); + is(loginListItems.length, 2, "The two stored logins should be displayed"); + is(loginListItems[0].getAttribute("role"), "option", "Each login-list-item should have role='option'"); + is(loginListItems[0].dataset.guid, TEST_LOGIN_1.guid, "login-list-item should have correct guid attribute"); + is(loginListItems[0].querySelector(".title").textContent, TEST_LOGIN_1.title, + "login-list-item origin should match"); + is(loginListItems[0].querySelector(".username").textContent, TEST_LOGIN_1.username, + "login-list-item username should match"); + ok(loginListItems[0].classList.contains("selected"), "The first item should be selected by default"); + ok(!loginListItems[1].classList.contains("selected"), "The second item should not be selected by default"); + loginListItems[0].click(); + loginListItems = gLoginList.shadowRoot.querySelectorAll(".login-list-item:not(#new-login-list-item, [hidden])"); + is(loginListItems.length, 2, "After selecting one, only the two stored logins should be displayed"); + ok(loginListItems[0].classList.contains("selected"), "The first item should be selected"); + ok(!loginListItems[1].classList.contains("selected"), "The second item should still not be selected"); +}); + +add_task(async function test_breach_indicator() { + gLoginList.setLogins([TEST_LOGIN_1, TEST_LOGIN_2, Object.assign({}, TEST_LOGIN_3, {password: TEST_LOGIN_1.password})]); + gLoginList.setBreaches(TEST_BREACHES_MAP); + let vulnerableLogins = new Map(); + vulnerableLogins.set(TEST_LOGIN_1.guid, true); + vulnerableLogins.set(TEST_LOGIN_3.guid, true); + gLoginList.setVulnerableLogins(vulnerableLogins); + let loginListItems = gLoginList.shadowRoot.querySelectorAll(".login-list-item:not(#new-login-list-item, [hidden])"); + let alertIcon = loginListItems[0].querySelector(".alert-icon"); + is(loginListItems[0].dataset.guid, TEST_LOGIN_1.guid, "The first login should be TEST_LOGIN_1"); + ok(!loginListItems[0].classList.contains("vulnerable"), "The first login should not have the .vulnerable class"); + ok(loginListItems[0].classList.contains("breached"), "The first login should have the .breached class."); + is(alertIcon.src, "chrome://browser/content/aboutlogins/icons/breached-website.svg", "The alert icon should be the breach warning icon"); + is(loginListItems[1].dataset.guid, TEST_LOGIN_3.guid, "The second login should be TEST_LOGIN_3"); + ok(loginListItems[1].classList.contains("vulnerable"), "The second login should have the .vulnerable class"); + ok(!loginListItems[1].classList.contains("breached"), "The second login should not have the .breached class"); + alertIcon = loginListItems[1].querySelector(".alert-icon"); + is(alertIcon.src, "chrome://browser/content/aboutlogins/icons/vulnerable-password.svg", "The alert icon should be the vulnerable password icon"); + is(loginListItems[2].dataset.guid, TEST_LOGIN_2.guid, "The third login should be TEST_LOGIN_2"); + alertIcon = loginListItems[2].querySelector(".alert-icon"); + ok(!loginListItems[2].classList.contains("vulnerable"), "The third login should not have the .vulnerable class"); + ok(!loginListItems[2].classList.contains("breached"), "The third login should not have the .breached class"); + is(alertIcon.src, "chrome://mochitests/content/chrome/browser/components/aboutlogins/tests/chrome/test_login_list.html", "The alert icon src should be empty"); +}); + +function assertCount({ count, total }) { + const countSpan = gLoginList.shadowRoot.querySelector(".count"); + const actual = JSON.parse(countSpan.getAttribute("data-l10n-args")); + isDeeply(actual, { count, total }, "Login count updated"); +} + +add_task(async function test_filtered_list() { + function findItemFromUsername(list, username) { + for (let item of list) { + if ((item._cachedUsername || (item._cachedUsername = item.querySelector('.username').textContent)) == username) { + return item; + } + } + ok(false, `The ${username} wasn't in the list of logins.`) + return list[0]; + } + gLoginList.setLogins([TEST_LOGIN_1, TEST_LOGIN_2]); + let emptySearchText = gLoginList.shadowRoot.querySelector(".empty-search-message"); + ok(isHidden(emptySearchText), "The empty search text should be hidden when there are results in the list"); + is(gLoginList.shadowRoot.querySelectorAll(".login-list-item:not(#new-login-list-item, [hidden])").length, 2, "Both logins should be visible"); + + assertCount({ count: 2, total: 2 }); + window.dispatchEvent(new CustomEvent("AboutLoginsFilterLogins", { + bubbles: true, + detail: "user1", + })); + assertCount({ count: 1, total: 2 }); + ok(isHidden(emptySearchText), "The empty search text should be hidden when there are results in the list"); + let loginListItems = gLoginList.shadowRoot.querySelectorAll(".login-list-item[data-guid]"); + is(loginListItems[0].querySelector(".username").textContent, "user1", "user1 is expected first"); + ok(!loginListItems[0].hidden, "user1 should remain visible"); + ok(loginListItems[1].hidden, "user2 should be hidden"); + window.dispatchEvent(new CustomEvent("AboutLoginsFilterLogins", { + bubbles: true, + detail: "user2", + })); + assertCount({ count: 1, total: 2 }); + ok(isHidden(emptySearchText), "The empty search text should be hidden when there are results in the list"); + loginListItems = gLoginList.shadowRoot.querySelectorAll(".login-list-item[data-guid]"); + ok(findItemFromUsername(loginListItems, 'user1').hidden, "user1 should be hidden"); + ok(!findItemFromUsername(loginListItems, 'user2').hidden, "user2 should be visible"); + window.dispatchEvent(new CustomEvent("AboutLoginsFilterLogins", { + bubbles: true, + detail: "user", + })); + assertCount({ count: 2, total: 2 }); + ok(!gLoginList._sortSelect.disabled, "The sort should be enabled when there are visible logins in the list"); + ok(isHidden(emptySearchText), "The empty search text should be hidden when there are results in the list"); + loginListItems = gLoginList.shadowRoot.querySelectorAll(".login-list-item[data-guid]"); + ok(!findItemFromUsername(loginListItems, 'user1').hidden, "user1 should be visible"); + ok(!findItemFromUsername(loginListItems, 'user2').hidden, "user2 should be visible"); + window.dispatchEvent(new CustomEvent("AboutLoginsFilterLogins", { + bubbles: true, + detail: "foo", + })); + assertCount({ count: 0, total: 2 }); + ok(gLoginList._sortSelect.disabled, "The sort should be disabled when there are no visible logins in the list"); + ok(!isHidden(emptySearchText), "The empty search text should be visible when there are no results in the list"); + isnot(gLoginList.shadowRoot.querySelector(".container > ol").getAttribute("aria-activedescendant"), "new-login-list-item", "new-login-list-item shouldn't be the active descendant"); + loginListItems = gLoginList.shadowRoot.querySelectorAll(".login-list-item[data-guid]"); + ok(findItemFromUsername(loginListItems, 'user1').hidden, "user1 should be hidden"); + ok(findItemFromUsername(loginListItems, 'user2').hidden, "user2 should be hidden"); + window.dispatchEvent(new CustomEvent("AboutLoginsFilterLogins", { + bubbles: true, + detail: "", + })); + ok(!gLoginList._sortSelect.disabled, "The sort should be re-enabled when there are visible logins in the list"); + ok(isHidden(emptySearchText), "The empty search text should be hidden when there are results in the list"); + assertCount({ count: 2, total: 2 }); + loginListItems = gLoginList.shadowRoot.querySelectorAll(".login-list-item[data-guid]"); + ok(!findItemFromUsername(loginListItems, 'user1').hidden, "user1 should be visible"); + ok(!findItemFromUsername(loginListItems, 'user2').hidden, "user2 should be visible"); + + info("Add an HTTP Auth login"); + gLoginList.setLogins([TEST_LOGIN_1, TEST_LOGIN_2, TEST_HTTP_AUTH_LOGIN_1]); + await asyncElementRendered(); + assertCount({ count: 3, total: 3 }); + info("Filter by httpRealm"); + window.dispatchEvent(new CustomEvent("AboutLoginsFilterLogins", { + bubbles: true, + detail: "realm", + })); + assertCount({ count: 1, total: 3 }); + loginListItems = gLoginList.shadowRoot.querySelectorAll(".login-list-item[data-guid]"); + ok(findItemFromUsername(loginListItems, 'user1').hidden, "user1 should be hidden"); + ok(findItemFromUsername(loginListItems, 'user2').hidden, "user2 should be hidden"); + ok(!findItemFromUsername(loginListItems, 'http_auth_user').hidden, "http_auth_user should be visible"); + + gLoginList.setLogins([TEST_LOGIN_1, TEST_LOGIN_2]); + window.dispatchEvent(new CustomEvent("AboutLoginsFilterLogins", { + bubbles: true, + detail: "", + })); + await asyncElementRendered(); +}); + +add_task(async function test_initial_empty_results() { + // Create a new instance to reset state + gLoginList.remove(); + gLoginList = document.createElement("login-list"); + document.getElementById("display").appendChild(gLoginList); + await asyncElementRendered(); + + let emptySearchText = gLoginList.shadowRoot.querySelector(".empty-search-message"); + + window.dispatchEvent(new CustomEvent("AboutLoginsFilterLogins", { + bubbles: true, + detail: "foo", + })); + assertCount({ count: 0, total: 0 }); + ok(gLoginList._sortSelect.disabled, "The sort should be disabled when there are no visible logins in the list"); + ok(!isHidden(emptySearchText), "The empty search text should be visible when there are no results in the list"); + isnot(gLoginList.shadowRoot.querySelector(".container > ol").getAttribute("aria-activedescendant"), "new-login-list-item", "new-login-list-item shouldn't be the active descendant"); + ok(gLoginList.shadowRoot.querySelector("#new-login-list-item").hidden, "new-login-list-item should be @hidden"); + + gLoginList.setLogins([TEST_LOGIN_1, TEST_LOGIN_2]); + window.dispatchEvent(new CustomEvent("AboutLoginsFilterLogins", { + bubbles: true, + detail: "", + })); + await asyncElementRendered(); +}); + +add_task(async function test_login_modified() { + let modifiedLogin = Object.assign(TEST_LOGIN_1, {username: "user11"}); + gLoginList.loginModified(modifiedLogin); + await asyncElementRendered(); + let loginListItems = gLoginList.shadowRoot.querySelectorAll(".login-list-item[data-guid]:not([hidden])"); + is(loginListItems.length, 2, "Both logins should be displayed"); + is(loginListItems[0].dataset.guid, TEST_LOGIN_1.guid, "login-list-item should have correct guid attribute"); + is(loginListItems[0].querySelector(".title").textContent, TEST_LOGIN_1.title, + "login-list-item origin should match"); + is(loginListItems[0].querySelector(".username").textContent, modifiedLogin.username, + "login-list-item username should have been updated"); + is(loginListItems[1].querySelector(".username").textContent, TEST_LOGIN_2.username, + "login-list-item2 username should remain unchanged"); +}); + +add_task(async function test_login_added() { + info("selected sort: " + gLoginList.shadowRoot.getElementById("login-sort").selectedIndex); + + let loginListItems = gLoginList.shadowRoot.querySelectorAll(".login-list-item:not(#new-login-list-item, [hidden])"); + is(loginListItems.length, 2, "Should have two logins at start of test"); + let newLogin = Object.assign({}, TEST_LOGIN_1, {title: "example2.example.com", guid: "111222"}); + gLoginList.loginAdded(newLogin); + await asyncElementRendered(); + loginListItems = gLoginList.shadowRoot.querySelectorAll(".login-list-item:not(#new-login-list-item, [hidden])"); + is(loginListItems.length, 3, "New login should be added to the list"); + is(loginListItems[0].dataset.guid, TEST_LOGIN_1.guid, "login-list-item1 should have correct guid attribute"); + is(loginListItems[1].dataset.guid, TEST_LOGIN_2.guid, "login-list-item2 should have correct guid attribute"); + is(loginListItems[2].dataset.guid, newLogin.guid, "login-list-item3 should have correct guid attribute"); + is(loginListItems[2].querySelector(".title").textContent, newLogin.title, + "login-list-item origin should match"); + is(loginListItems[2].querySelector(".username").textContent, newLogin.username, + "login-list-item username should have been updated"); +}); + +add_task(async function test_login_removed() { + gLoginList.loginRemoved({guid: "111222"}); + await asyncElementRendered(); + let loginListItems = gLoginList.shadowRoot.querySelectorAll(".login-list-item:not(#new-login-list-item, [hidden])"); + is(loginListItems.length, 2, "New login should be removed from the list"); + is(loginListItems[0].dataset.guid, TEST_LOGIN_1.guid, "login-list-item1 should have correct guid attribute"); + is(loginListItems[1].dataset.guid, TEST_LOGIN_2.guid, "login-list-item2 should have correct guid attribute"); +}); + +add_task(async function test_login_added_filtered() { + assertCount({ count: 2, total: 2 }); + window.dispatchEvent(new CustomEvent("AboutLoginsFilterLogins", { + detail: "user1", + })); + assertCount({ count: 1, total: 2 }); + + let newLogin = Object.assign({}, TEST_LOGIN_1, {title: "example2.example.com", username: "user22", guid: "111222"}); + gLoginList.loginAdded(newLogin); + await asyncElementRendered(); + let loginListItems = gLoginList.shadowRoot.querySelectorAll(".login-list-item[data-guid]"); + is(loginListItems.length, 3, "New login should be added to the list"); + is(loginListItems[0].dataset.guid, TEST_LOGIN_1.guid, "login-list-item1 should have correct guid attribute"); + is(loginListItems[1].dataset.guid, TEST_LOGIN_2.guid, "login-list-item2 should have correct guid attribute"); + is(loginListItems[2].dataset.guid, newLogin.guid, "login-list-item3 should have correct guid attribute"); + ok(!loginListItems[0].hidden, "login-list-item1 should be visible"); + ok(loginListItems[1].hidden, "login-list-item2 should be hidden"); + ok(loginListItems[2].hidden, "login-list-item3 should be hidden"); + assertCount({ count: 1, total: 3 }); +}); + +add_task(async function test_sorted_list() { + function dispatchChangeEvent(target) { + let event = document.createEvent("UIEvent"); + event.initEvent("change", true, true); + target.dispatchEvent(event); + } + + // Clear the filter + window.dispatchEvent(new CustomEvent("AboutLoginsFilterLogins", { + detail: "", + })); + + // Clear the selection so the 'new' login will be in the list too. + window.dispatchEvent(new CustomEvent("AboutLoginsLoginSelected", { + detail: {}, + })); + + // make sure that the logins have distinct orderings based on sort order + let [guid1, guid2, guid3] = gLoginList._loginGuidsSortedOrder; + gLoginList._logins[guid1].login.timeLastUsed = 0; + gLoginList._logins[guid2].login.timeLastUsed = 1; + gLoginList._logins[guid3].login.timeLastUsed = 2; + gLoginList._logins[guid1].login.title = "a"; + gLoginList._logins[guid2].login.title = "b"; + gLoginList._logins[guid3].login.title = "c"; + gLoginList._logins[guid1].login.username = "a"; + gLoginList._logins[guid2].login.username = "b"; + gLoginList._logins[guid3].login.username = "c"; + gLoginList._logins[guid1].login.timePasswordChanged = 1; + gLoginList._logins[guid2].login.timePasswordChanged = 2; + gLoginList._logins[guid3].login.timePasswordChanged = 0; + + // sort by last used + let loginSort = gLoginList.shadowRoot.getElementById("login-sort"); + loginSort.value = "last-used"; + dispatchChangeEvent(loginSort); + let loginListItems = gLoginList.shadowRoot.querySelectorAll(".login-list-item:not(#new-login-list-item, [hidden])"); + is(loginListItems.length, 3, "The list should contain the three stored logins"); + let timeUsed1 = gLoginList._logins[loginListItems[0].dataset.guid].login.timeLastUsed; + let timeUsed2 = gLoginList._logins[loginListItems[1].dataset.guid].login.timeLastUsed; + let timeUsed3 = gLoginList._logins[loginListItems[2].dataset.guid].login.timeLastUsed; + is(timeUsed1 > timeUsed2, true, "Logins sorted by timeLastUsed. First: " + timeUsed1 + "; Second: " + timeUsed2); + is(timeUsed2 > timeUsed3, true, "Logins sorted by timeLastUsed. Second: " + timeUsed2 + "; Third: " + timeUsed3); + + // sort by title + loginSort.value = "name"; + dispatchChangeEvent(loginSort); + loginListItems = gLoginList.shadowRoot.querySelectorAll(".login-list-item:not(#new-login-list-item, [hidden])"); + let title1 = gLoginList._logins[loginListItems[0].dataset.guid].login.title; + let title2 = gLoginList._logins[loginListItems[1].dataset.guid].login.title; + let title3 = gLoginList._logins[loginListItems[2].dataset.guid].login.title; + is(title1.localeCompare(title2), -1, "Logins sorted by title. First: " + title1 + "; Second: " + title2); + is(title2.localeCompare(title3), -1, "Logins sorted by title. Second: " + title2 + "; Third: " + title3); + + // sort by title in reverse alphabetical order + loginSort.value = "name-reverse"; + dispatchChangeEvent(loginSort); + loginListItems = gLoginList.shadowRoot.querySelectorAll(".login-list-item:not(#new-login-list-item, [hidden])"); + title1 = gLoginList._logins[loginListItems[0].dataset.guid].login.title; + title2 = gLoginList._logins[loginListItems[1].dataset.guid].login.title; + title3 = gLoginList._logins[loginListItems[2].dataset.guid].login.title; + let testDescription = "Logins sorted by title in reverse alphabetical order." + is(title1.localeCompare(title2), 1, `${testDescription} First: ${title2}; Second: ${title1}`); + is(title2.localeCompare(title3), 1, `${testDescription} Second: ${title3}; Third: ${title2}`); + + // sort by last changed + loginSort.value = "last-changed"; + dispatchChangeEvent(loginSort); + loginListItems = gLoginList.shadowRoot.querySelectorAll(".login-list-item:not(#new-login-list-item, [hidden])"); + let pwChanged1 = gLoginList._logins[loginListItems[0].dataset.guid].login.timePasswordChanged; + let pwChanged2 = gLoginList._logins[loginListItems[1].dataset.guid].login.timePasswordChanged; + let pwChanged3 = gLoginList._logins[loginListItems[2].dataset.guid].login.timePasswordChanged; + is(pwChanged1 > pwChanged2, true, "Logins sorted by timePasswordChanged. First: " + pwChanged1 + "; Second: " + pwChanged2); + is(pwChanged2 > pwChanged3, true, "Logins sorted by timePasswordChanged. Second: " + pwChanged2 + "; Third: " + pwChanged3); + + // sort by breached when there are breached logins + gLoginList.setBreaches(TEST_BREACHES_MAP); + loginSort.value = "alerts"; + let vulnerableLogins = new Map(); + gLoginList.setVulnerableLogins(vulnerableLogins); + dispatchChangeEvent(loginSort); + loginListItems = gLoginList.shadowRoot.querySelectorAll(".login-list-item:not(#new-login-list-item, [hidden])"); + is(loginListItems[0].classList.contains("breached"), true, "Breached login should be displayed at top of list"); + is(!loginListItems[1].classList.contains("breached"), true, "Non-breached login should be displayed below breached"); + + // sort by username + loginSort.value = "username"; + dispatchChangeEvent(loginSort); + loginListItems = gLoginList.shadowRoot.querySelectorAll(".login-list-item:not(#new-login-list-item, [hidden])"); + let username1 = gLoginList._logins[loginListItems[0].dataset.guid].login.username; + let username2 = gLoginList._logins[loginListItems[1].dataset.guid].login.username; + let username3 = gLoginList._logins[loginListItems[2].dataset.guid].login.username; + is(username1.localeCompare(username2), -1, "Logins sorted by username. First: " + username1 + "; Second: " + username2); + is(username2.localeCompare(username3), -1, "Logins sorted by username. Second: " + username2 + "; Third: " + username3); + + // sort by username in reverse alphabetical order + loginSort.value = "username-reverse"; + dispatchChangeEvent(loginSort); + loginListItems = gLoginList.shadowRoot.querySelectorAll(".login-list-item:not(#new-login-list-item, [hidden])"); + username1 = gLoginList._logins[loginListItems[0].dataset.guid].login.username; + username2 = gLoginList._logins[loginListItems[1].dataset.guid].login.username; + username3 = gLoginList._logins[loginListItems[2].dataset.guid].login.username; + testDescription = "Logins sorted by username in reverse alphabetical order."; + is(username3.localeCompare(username2), -1, `${testDescription} First: ${username3} Second: ${username2}`); + is(username2.localeCompare(username1), -1, `${testDescription} Second: ${username2} Third: ${username1}`); + + // sort by name when there are no breached logins + gLoginList.setBreaches(new Map()); + loginSort.value = "alerts"; + dispatchChangeEvent(loginSort); + loginListItems = gLoginList.shadowRoot.querySelectorAll(".login-list-item:not(#new-login-list-item, [hidden])"); + title1 = gLoginList._logins[loginListItems[0].dataset.guid].login.title; + title2 = gLoginList._logins[loginListItems[1].dataset.guid].login.title; + is(title1.localeCompare(title2), -1, "Logins should be sorted alphabetically by hostname"); +}); + +add_task(async function test_login_list_item_removed_next_selected() { + let logins = []; + for (let i = 0; i < 12; i++) { + let group = i % 2 ? "BB" : "AA"; + // Create logins of the form `jared0AAa@example.com`, + // `jared1BBb@example.com`, `jared2AAc@example.com`, etc. + logins.push({ + guid: `${i}`, + username: `jared${i}${group}${String.fromCharCode(97 + i)}@example.com`, + password: "omgsecret!!1", + origin: "https://www.example.com", + }); + } + + gLoginList.setLogins(logins); + let visibleLogins = gLoginList.shadowRoot.querySelectorAll(".login-list-item[data-guid]:not([hidden])"); + await SimpleTest.promiseWaitForCondition(() => { + return visibleLogins.length == 12; + }, "Waiting for all logins to be visible"); + is(gLoginList._selectedGuid, logins[0].guid, "login0 should be selected by default"); + + window.dispatchEvent( + new CustomEvent("AboutLoginsFilterLogins", { + bubbles: true, + detail: "BB", + }) + ); + + await SimpleTest.promiseWaitForCondition(() => { + visibleLogins = gLoginList.shadowRoot.querySelectorAll(".login-list-item[data-guid]:not([hidden])"); + return visibleLogins.length == 6; + }, "Only logins with BB in the username should be visible, visible count: " + visibleLogins.length); + + is(gLoginList._selectedGuid, logins[0].guid, "login0 should still be selected after filtering"); + + gLoginList.loginRemoved({guid: logins[0].guid}); + + await SimpleTest.promiseWaitForCondition(() => { + return gLoginList._loginGuidsSortedOrder.length == 11; + }, "Waiting for login to get removed"); + + await SimpleTest.promiseWaitForCondition(() => { + visibleLogins = gLoginList.shadowRoot.querySelectorAll(".login-list-item[data-guid]:not([hidden])"); + return visibleLogins.length == 6; + }, "the number of visible logins should not change, got " + visibleLogins.length); + is(gLoginList._selectedGuid, logins[1].guid, + "login1 should be selected after delete since the deleted login was not visible and login1 was the first in the list"); + + let loginToSwitchTo = gLoginList._logins[visibleLogins[1].dataset.guid].login; + window.dispatchEvent( + new CustomEvent("AboutLoginsLoginSelected", { + bubbles: true, + detail: loginToSwitchTo, + }) + ); + is(gLoginList._selectedGuid, loginToSwitchTo.guid, "login3 should be selected"); + + gLoginList.loginRemoved({guid: logins[3].guid}); + + await SimpleTest.promiseWaitForCondition(() => { + return gLoginList._loginGuidsSortedOrder.length == 10; + }, "Waiting for login to get removed"); + + await SimpleTest.promiseWaitForCondition(() => { + visibleLogins = gLoginList.shadowRoot.querySelectorAll( + ".login-list-item[data-guid]:not([hidden])" + ); + return visibleLogins.length == 5; + }, "the number of filtered logins should decrease by 1"); + is(gLoginList._selectedGuid, visibleLogins[0].dataset.guid, "the first login should now be selected"); + + gLoginList.loginRemoved({guid: logins[1].guid}); + + await SimpleTest.promiseWaitForCondition(() => { + return gLoginList._loginGuidsSortedOrder.length == 9; + }, "Waiting for login to get removed"); + + await SimpleTest.promiseWaitForCondition(() => { + visibleLogins = gLoginList.shadowRoot.querySelectorAll( + ".login-list-item[data-guid]:not([hidden])" + ); + return visibleLogins.length == 4; + }, "the number of filtered logins should decrease by 1"); + is(gLoginList._selectedGuid, visibleLogins[0].dataset.guid, "the first login should now still be selected"); + + loginToSwitchTo = gLoginList._logins[visibleLogins[3].dataset.guid].login; + window.dispatchEvent( + new CustomEvent("AboutLoginsLoginSelected", { + bubbles: true, + detail: loginToSwitchTo, + }) + ); + is(gLoginList._selectedGuid, visibleLogins[3].dataset.guid, "the last login should now still be selected"); + + gLoginList.loginRemoved({guid: logins[10].guid}); + + await SimpleTest.promiseWaitForCondition(() => { + return gLoginList._loginGuidsSortedOrder.length == 8; + }, "Waiting for login to get removed"); + + await SimpleTest.promiseWaitForCondition(() => { + visibleLogins = gLoginList.shadowRoot.querySelectorAll( + ".login-list-item[data-guid]:not([hidden])" + ); + return visibleLogins.length == 4; + }, "the number of filtered logins should decrease by 1"); + is(gLoginList._selectedGuid, visibleLogins[3].dataset.guid, "the last login should now be selected"); + + loginToSwitchTo = gLoginList._logins[visibleLogins[2].dataset.guid].login; + window.dispatchEvent( + new CustomEvent("AboutLoginsLoginSelected", { + bubbles: true, + detail: loginToSwitchTo, + }) + ); + is(gLoginList._selectedGuid, visibleLogins[2].dataset.guid, "the last login should now still be selected"); +}); +</script> + +</body> +</html> diff --git a/browser/components/aboutlogins/tests/chrome/test_menu_button.html b/browser/components/aboutlogins/tests/chrome/test_menu_button.html new file mode 100644 index 0000000000..2beede09f1 --- /dev/null +++ b/browser/components/aboutlogins/tests/chrome/test_menu_button.html @@ -0,0 +1,260 @@ +<!DOCTYPE HTML> +<html> +<!-- +Test the menu-button component +--> +<head> + <meta charset="utf-8"> + <title>Test the menu-button component</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script> + <script type="module" src="chrome://browser/content/aboutlogins/components/menu-button.mjs"></script> + <script src="aboutlogins_common.js"></script> + + <link rel="stylesheet" href="/tests/SimpleTest/test.css"/> +</head> +<body> + <p id="display"> + </p> +<div id="content" style="display: none"> + <iframe id="templateFrame" src="chrome://browser/content/aboutlogins/aboutLogins.html" + sandbox="allow-same-origin"></iframe> +</div> +<pre id="test"> +</pre> +<script> +/** Test the menu-button component **/ + +let gMenuButton; +add_setup(async () => { + let templateFrame = document.getElementById("templateFrame"); + let displayEl = document.getElementById("display"); + await importDependencies(templateFrame, displayEl); + + gMenuButton = document.createElement("menu-button"); + displayEl.appendChild(gMenuButton); + gMenuButton.style.marginInlineStart = "100px"; + + isnot(document.activeElement, gMenuButton, "menu-button should not be focused by default"); + while (document.activeElement != gMenuButton) { + sendKey("TAB"); + await new Promise(resolve => requestAnimationFrame(resolve)); + } +}); + +add_task(async function test_menu_click_button () { + let menu = gMenuButton.shadowRoot.querySelector(".menu"); + let menuButton = gMenuButton.shadowRoot.querySelector(".menu-button"); + ok(menu.hidden, "menu should be hidden before being clicked"); + await synthesizeMouseAtCenter(menuButton, {}); + await new Promise(resolve => requestAnimationFrame(resolve)); + ok(!menu.hidden, "menu should be visible after clicked"); + + let menuListSeparators = gMenuButton.shadowRoot.querySelectorAll(".menuitem-separator"); + await synthesizeMouseAtCenter(menuListSeparators[0], {}); + await new Promise(resolve => requestAnimationFrame(resolve)); + ok(!menu.hidden, "menu should still be visible after menu separator has been clicked"); + + let menuListButtons = gMenuButton.shadowRoot.querySelectorAll(".menuitem-button"); + await synthesizeMouseAtCenter(menuListButtons[0], {}); + await new Promise(resolve => requestAnimationFrame(resolve)); + ok(menu.hidden, "menu should be hidden after a button has been clicked"); +}); + +add_task(async function test_menu_click_outside () { + let menu = gMenuButton.shadowRoot.querySelector(".menu"); + let menuButton = gMenuButton.shadowRoot.querySelector(".menu-button"); + ok(menu.hidden, "menu should be hidden before being clicked"); + await synthesizeMouseAtCenter(menuButton, {}); + await new Promise(resolve => requestAnimationFrame(resolve)); + ok(!menu.hidden, "menu should be visible after clicked"); + + let menuListSeparators = gMenuButton.shadowRoot.querySelectorAll(".menuitem-separator"); + await synthesizeMouseAtCenter(menuListSeparators[0], {}); + await new Promise(resolve => requestAnimationFrame(resolve)); + ok(!menu.hidden, "menu should still be visible after menu separator has been clicked"); + + let outsideEl = document.getElementById("test"); + await synthesizeMouseAtCenter(outsideEl, {}); + await new Promise(resolve => requestAnimationFrame(resolve)); + ok(menu.hidden, "menu should be hidden after a click outside of the menu has been clicked"); + + for (let key of ["KEY_ArrowDown", "KEY_ArrowUp"]) { + synthesizeKey(key); + await new Promise(resolve => requestAnimationFrame(resolve)); + ok(menu.hidden, `menu should still be hidden when ${key} is entered`); + } +}); + +add_task(async function test_menu_esc_after_click_disabled_item () { + let menu = gMenuButton.shadowRoot.querySelector(".menu"); + let menuButton = gMenuButton.shadowRoot.querySelector(".menu-button"); + ok(menu.hidden, "menu should be hidden before being clicked"); + await synthesizeMouseAtCenter(menuButton, {}); + await new Promise(resolve => requestAnimationFrame(resolve)); + ok(!menu.hidden, "menu should be visible after clicked"); + + let menuListSeparators = gMenuButton.shadowRoot.querySelectorAll(".menuitem-separator"); + await synthesizeMouseAtCenter(menuListSeparators[0], {}); + await new Promise(resolve => requestAnimationFrame(resolve)); + ok(!menu.hidden, "menu should still be visible after menu separator has been clicked"); + + sendKey("ESCAPE"); + await new Promise(resolve => requestAnimationFrame(resolve)); + ok(menu.hidden, "menu should be hidden after pressing 'escape'"); +}); + +add_task(async function test_menu_open_close() { + is(document.activeElement, gMenuButton, "menu-button should be focused to start the test"); + + let menu = gMenuButton.shadowRoot.querySelector(".menu"); + is(true, menu.hidden, "menu should be hidden before pressing 'space'"); + sendKey("SPACE"); + await new Promise(resolve => requestAnimationFrame(resolve)); + ok(!menu.hidden, "menu should be visible after pressing 'space'"); + + sendKey("ESCAPE"); + await new Promise(resolve => requestAnimationFrame(resolve)); + ok(menu.hidden, "menu should be hidden after pressing 'escape'"); + is(gMenuButton.shadowRoot.activeElement, gMenuButton.shadowRoot.querySelector(".menu-button"), + "the .menu-button should be focused after closing the menu via keyboard"); + + sendKey("RETURN"); + let firstVisibleItem = gMenuButton.shadowRoot.querySelector(".menuitem-button:not([hidden])"); + await SimpleTest.promiseWaitForCondition(() => firstVisibleItem.matches(":focus"), + "waiting for firstVisibleItem to get focus"); + + ok(!menu.hidden, "menu should be visible after pressing 'return'"); + ok(firstVisibleItem.matches(":focus"), "firstVisibleItem should be focused after opening popup"); + + synthesizeKey("VK_TAB", { shiftKey: true }); + await SimpleTest.promiseWaitForCondition(() => !firstVisibleItem.matches(":focus"), + "waiting for firstVisibleItem to lose focus"); + ok(!firstVisibleItem.matches(":focus"), "firstVisibleItem should lose focus after tabbing away from it"); + sendKey("TAB"); + await SimpleTest.promiseWaitForCondition(() => firstVisibleItem.matches(":focus"), + "waiting for firstVisibleItem to get focus again"); + ok(firstVisibleItem.matches(":focus"), "firstVisibleItem should be focused after tabbing to it again"); + if (SpecialPowers.getBoolPref("signon.management.page.fileImport.enabled")) { + sendKey("TAB"); // Import from file + } + sendKey("TAB"); // Export + sendKey("TAB"); // Remove All Logins + + if (navigator.platform == "Win32" || navigator.platform == "MacIntel") { + // The Import menuitem is only visible on Windows/macOS, where we will need another Tab + // press to get to the Preferences item. + let preferencesItem = gMenuButton.shadowRoot.querySelector(".menuitem-preferences"); + sendKey("DOWN"); + await SimpleTest.promiseWaitForCondition(() => preferencesItem.matches(":focus"), + "waiting for preferencesItem to gain focus"); + ok(preferencesItem.matches(":focus"), `.menuitem-preferences should be now be focused (DOWN)`); + sendKey("UP"); + await SimpleTest.promiseWaitForCondition(() => !preferencesItem.matches(":focus"), + `waiting for preferencesItem to lose focus (UP)`); + ok(!preferencesItem.matches(":focus"), `.menuitem-preferences should lose focus after pressing up`); + + sendKey("TAB"); + await SimpleTest.promiseWaitForCondition(() => preferencesItem.matches(":focus"), + "waiting for preferencesItem to get focus"); + ok(preferencesItem.matches(":focus"), ".menuitem-preferences should be focused after tabbing to it"); + } + + let openPreferencesEvent = null; + ok(!menu.hidden, "menu should be visible before pressing 'space' on .menuitem-preferences"); + window.addEventListener( + "AboutLoginsOpenPreferences", + event => openPreferencesEvent = event, + {once: true} + ); + sendKey("SPACE"); + ok(openPreferencesEvent, "AboutLoginsOpenPreferences event should be dispatched after pressing 'space' on .menuitem-preferences"); + ok(menu.hidden, "menu should be hidden after pressing 'space' on .menuitem-preferences"); + + // Clean up task + sendKey("TAB"); + synthesizeKey("VK_TAB", { shiftKey: true }); +}); + +add_task(async function test_menu_keyboard_cycling() { + function waitForElementFocus(selector) { + return SimpleTest.promiseWaitForCondition( + () => gMenuButton.shadowRoot.querySelector(selector).matches(":focus"), + `waiting for ${selector} to be focused` + ); + } + + function getFocusedMenuItem() { + return gMenuButton.shadowRoot.querySelector(".menuitem-button:focus"); + } + + let allItems = [ + "menuitem-export", + "menuitem-remove-all-logins", + "menuitem-preferences", + "menuitem-help", + ]; + if (SpecialPowers.getBoolPref("signon.management.page.fileImport.enabled")) { + allItems = ["menuitem-import-file", ...allItems]; + } + if (navigator.platform == "Win32" || navigator.platform == "MacIntel") { + allItems = ["menuitem-import-browser", ...allItems]; + } + + let menu = gMenuButton.shadowRoot.querySelector(".menu"); + + is(document.activeElement, gMenuButton, "menu-button should be focused to start the test"); + is(true, menu.hidden, "menu should be hidden before pressing 'space'"); + + sendKey("RETURN"); + + await SimpleTest.promiseWaitForCondition(() => !menu.hidden, "waiting for menu to show"); + + ok(!menu.hidden, "menu should be visible after pressing 'enter'"); + + for (let item of allItems) { + await waitForElementFocus("." + item); + ok( + getFocusedMenuItem().classList.contains(item), + `.${item} should be selected after key is pressed` + ); + sendKey("DOWN"); + } + + + await waitForElementFocus("." + allItems[0]); + ok( + getFocusedMenuItem().classList.contains(allItems[0]), + "Focused item should not change if left arrow is pressed" + ) + sendKey("LEFT"); + + await waitForElementFocus("." + allItems[0]); + ok( + getFocusedMenuItem().classList.contains(allItems[0]), + "Focused item should not change if right arrow is pressed" + ) + sendKey("RIGHT"); + + await waitForElementFocus("." + allItems[0]); + ok( + getFocusedMenuItem().classList.contains(allItems[0]), + "Last item should cycle back to first item" + ); + + sendKey("UP"); + + let reversedItems = allItems.reverse(); + for (let item of reversedItems) { + await waitForElementFocus("." + item); + ok( + getFocusedMenuItem().classList.contains(item), + `.${item} should be selected after up key is pressed` + ); + sendKey("UP"); + } +}); +</script> + +</body> +</html> diff --git a/browser/components/aboutlogins/tests/unit/head.js b/browser/components/aboutlogins/tests/unit/head.js new file mode 100644 index 0000000000..938e06e3c0 --- /dev/null +++ b/browser/components/aboutlogins/tests/unit/head.js @@ -0,0 +1,22 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { LoginTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/LoginTestUtils.sys.mjs" +); +const { LoginHelper } = ChromeUtils.importESModule( + "resource://gre/modules/LoginHelper.sys.mjs" +); + +const TestData = LoginTestUtils.testData; +const newPropertyBag = LoginHelper.newPropertyBag; + +/** + * All the tests are implemented with add_task, this starts them automatically. + */ +function run_test() { + do_get_profile(); + run_next_test(); +} diff --git a/browser/components/aboutlogins/tests/unit/test_getPotentialBreachesByLoginGUID.js b/browser/components/aboutlogins/tests/unit/test_getPotentialBreachesByLoginGUID.js new file mode 100644 index 0000000000..a868572a6a --- /dev/null +++ b/browser/components/aboutlogins/tests/unit/test_getPotentialBreachesByLoginGUID.js @@ -0,0 +1,327 @@ +/** + * Test LoginBreaches.getPotentialBreachesByLoginGUID + */ + +"use strict"; + +const { RemoteSettings } = ChromeUtils.importESModule( + "resource://services-settings/remote-settings.sys.mjs" +); + +// Initializing BrowserGlue requires a profile on Windows. +do_get_profile(); + +const gBrowserGlue = Cc["@mozilla.org/browser/browserglue;1"].getService( + Ci.nsIObserver +); + +ChromeUtils.defineESModuleGetters(this, { + LoginBreaches: "resource:///modules/LoginBreaches.sys.mjs", +}); + +const TEST_BREACHES = [ + { + AddedDate: "2018-12-20T23:56:26Z", + BreachDate: "2018-12-16", + Domain: "breached.com", + Name: "Breached", + PwnCount: 1643100, + DataClasses: ["Email addresses", "Usernames", "Passwords", "IP addresses"], + _status: "synced", + id: "047940fe-d2fd-4314-b636-b4a952ee0043", + last_modified: "1541615610052", + schema: "1541615609018", + }, + { + AddedDate: "2018-12-20T23:56:26Z", + BreachDate: "2018-12-16", + Domain: "breached-subdomain.host.com", + Name: "Only a Sub-Domain was Breached", + PwnCount: 2754200, + DataClasses: ["Email addresses", "Usernames", "Passwords", "IP addresses"], + _status: "synced", + id: "047940fe-d2fd-4314-b636-b4a952ee0044", + last_modified: "1541615610052", + schema: "1541615609018", + }, + { + AddedDate: "2018-12-20T23:56:26Z", + BreachDate: "2018-12-16", + Domain: "breached-site-without-passwords.com", + Name: "Breached Site without passwords", + PwnCount: 987654, + DataClasses: ["Email addresses", "Usernames", "IP addresses"], + _status: "synced", + id: "047940fe-d2fd-4314-b636-b4a952ee0045", + last_modified: "1541615610052", + schema: "1541615609018", + }, +]; + +const CRASHING_URI_LOGIN = LoginTestUtils.testData.formLogin({ + origin: "chrome://grwatcher", + formActionOrigin: "https://www.example.com", + username: "username", + password: "password", + timePasswordChanged: new Date("2018-12-15").getTime(), +}); +const NOT_BREACHED_LOGIN = LoginTestUtils.testData.formLogin({ + origin: "https://www.example.com", + formActionOrigin: "https://www.example.com", + username: "username", + password: "password", + timePasswordChanged: new Date("2018-12-15").getTime(), +}); +const BREACHED_LOGIN = LoginTestUtils.testData.formLogin({ + origin: "https://www.breached.com", + formActionOrigin: "https://www.breached.com", + username: "username", + password: "password", + timePasswordChanged: new Date("2018-12-15").getTime(), +}); +const NOT_BREACHED_SUBDOMAIN_LOGIN = LoginTestUtils.testData.formLogin({ + origin: "https://not-breached-subdomain.host.com", + formActionOrigin: "https://not-breached-subdomain.host.com", + username: "username", + password: "password", +}); +const BREACHED_SUBDOMAIN_LOGIN = LoginTestUtils.testData.formLogin({ + origin: "https://breached-subdomain.host.com", + formActionOrigin: "https://breached-subdomain.host.com", + username: "username", + password: "password", + timePasswordChanged: new Date("2018-12-15").getTime(), +}); +const LOGIN_FOR_BREACHED_SITE_WITHOUT_PASSWORDS = + LoginTestUtils.testData.formLogin({ + origin: "https://breached-site-without-passwords.com", + formActionOrigin: "https://breached-site-without-passwords.com", + username: "username", + password: "password", + timePasswordChanged: new Date("2018-12-15").getTime(), + }); +const LOGIN_WITH_NON_STANDARD_URI = LoginTestUtils.testData.formLogin({ + origin: "someApp://random/path/to/login", + formActionOrigin: "someApp://random/path/to/login", + username: "username", + password: "password", + timePasswordChanged: new Date("2018-12-15").getTime(), +}); + +add_task(async function test_notBreachedLogin() { + await Services.logins.addLoginAsync(NOT_BREACHED_LOGIN); + const breachesByLoginGUID = + await LoginBreaches.getPotentialBreachesByLoginGUID( + [NOT_BREACHED_LOGIN], + TEST_BREACHES + ); + Assert.strictEqual( + breachesByLoginGUID.size, + 0, + "Should be 0 breached logins." + ); +}); + +add_task(async function test_breachedLogin() { + await Services.logins.addLoginAsync(BREACHED_LOGIN); + const breachesByLoginGUID = + await LoginBreaches.getPotentialBreachesByLoginGUID( + [NOT_BREACHED_LOGIN, BREACHED_LOGIN], + TEST_BREACHES + ); + Assert.strictEqual( + breachesByLoginGUID.size, + 1, + "Should be 1 breached login: " + BREACHED_LOGIN.origin + ); + Assert.strictEqual( + breachesByLoginGUID.get(BREACHED_LOGIN.guid).breachAlertURL, + "https://monitor.firefox.com/breach-details/Breached?utm_source=firefox-desktop&utm_medium=referral&utm_campaign=about-logins&utm_content=about-logins", + "Breach alert link should be equal to the breachAlertURL" + ); +}); + +add_task(async function test_breachedLoginAfterCrashingUriLogin() { + await Services.logins.addLoginAsync(CRASHING_URI_LOGIN); + + const breachesByLoginGUID = + await LoginBreaches.getPotentialBreachesByLoginGUID( + [CRASHING_URI_LOGIN, BREACHED_LOGIN], + TEST_BREACHES + ); + Assert.strictEqual( + breachesByLoginGUID.size, + 1, + "Should be 1 breached login: " + BREACHED_LOGIN.origin + ); + Assert.strictEqual( + breachesByLoginGUID.get(BREACHED_LOGIN.guid).breachAlertURL, + "https://monitor.firefox.com/breach-details/Breached?utm_source=firefox-desktop&utm_medium=referral&utm_campaign=about-logins&utm_content=about-logins", + "Breach alert link should be equal to the breachAlertURL" + ); +}); + +add_task(async function test_notBreachedSubdomain() { + await Services.logins.addLoginAsync(NOT_BREACHED_SUBDOMAIN_LOGIN); + + const breachesByLoginGUID = + await LoginBreaches.getPotentialBreachesByLoginGUID( + [NOT_BREACHED_LOGIN, NOT_BREACHED_SUBDOMAIN_LOGIN], + TEST_BREACHES + ); + Assert.strictEqual( + breachesByLoginGUID.size, + 0, + "Should be 0 breached logins." + ); +}); + +add_task(async function test_breachedSubdomain() { + await Services.logins.addLoginAsync(BREACHED_SUBDOMAIN_LOGIN); + + const breachesByLoginGUID = + await LoginBreaches.getPotentialBreachesByLoginGUID( + [NOT_BREACHED_SUBDOMAIN_LOGIN, BREACHED_SUBDOMAIN_LOGIN], + TEST_BREACHES + ); + Assert.strictEqual( + breachesByLoginGUID.size, + 1, + "Should be 1 breached login: " + BREACHED_SUBDOMAIN_LOGIN.origin + ); +}); + +add_task(async function test_breachedSiteWithoutPasswords() { + await Services.logins.addLoginAsync( + LOGIN_FOR_BREACHED_SITE_WITHOUT_PASSWORDS + ); + + const breachesByLoginGUID = + await LoginBreaches.getPotentialBreachesByLoginGUID( + [LOGIN_FOR_BREACHED_SITE_WITHOUT_PASSWORDS], + TEST_BREACHES + ); + Assert.strictEqual( + breachesByLoginGUID.size, + 0, + "Should be 0 breached login: " + + LOGIN_FOR_BREACHED_SITE_WITHOUT_PASSWORDS.origin + ); +}); + +add_task(async function test_breachAlertHiddenAfterDismissal() { + BREACHED_LOGIN.guid = "{d2de5ac1-4de6-e544-a7af-1f75abcba92b}"; + + await Services.logins.initializationPromise; + const storageJSON = Services.logins.wrappedJSObject._storage.wrappedJSObject; + + storageJSON.recordBreachAlertDismissal(BREACHED_LOGIN.guid); + + const breachesByLoginGUID = + await LoginBreaches.getPotentialBreachesByLoginGUID( + [BREACHED_LOGIN, NOT_BREACHED_LOGIN], + TEST_BREACHES + ); + Assert.strictEqual( + breachesByLoginGUID.size, + 0, + "Should be 0 breached logins after dismissal: " + BREACHED_LOGIN.origin + ); + + info("Clear login storage"); + Services.logins.removeAllUserFacingLogins(); + + const breachesByLoginGUID2 = + await LoginBreaches.getPotentialBreachesByLoginGUID( + [BREACHED_LOGIN, NOT_BREACHED_LOGIN], + TEST_BREACHES + ); + Assert.strictEqual( + breachesByLoginGUID2.size, + 1, + "Breached login should re-appear after clearing storage: " + + BREACHED_LOGIN.origin + ); +}); + +add_task(async function test_newBreachAfterDismissal() { + TEST_BREACHES[0].AddedDate = new Date().toISOString(); + + const breachesByLoginGUID = + await LoginBreaches.getPotentialBreachesByLoginGUID( + [BREACHED_LOGIN, NOT_BREACHED_LOGIN], + TEST_BREACHES + ); + + Assert.strictEqual( + breachesByLoginGUID.size, + 1, + "Should be 1 breached login after new breach following the dismissal of a previous breach: " + + BREACHED_LOGIN.origin + ); +}); + +add_task(async function test_ExceptionsThrownByNonStandardURIsAreCaught() { + await Services.logins.addLoginAsync(LOGIN_WITH_NON_STANDARD_URI); + + const breachesByLoginGUID = + await LoginBreaches.getPotentialBreachesByLoginGUID( + [LOGIN_WITH_NON_STANDARD_URI, BREACHED_LOGIN], + TEST_BREACHES + ); + + Assert.strictEqual( + breachesByLoginGUID.size, + 1, + "Exceptions thrown by logins with non-standard URIs should be caught." + ); +}); + +add_task(async function test_setBreachesFromRemoteSettingsSync() { + const login = NOT_BREACHED_SUBDOMAIN_LOGIN; + const nowExampleIsInBreachedRecords = [ + { + AddedDate: "2018-12-20T23:56:26Z", + BreachDate: "2018-12-16", + Domain: "not-breached-subdomain.host.com", + Name: "not-breached-subdomain.host.com is now breached!", + PwnCount: 1643100, + DataClasses: [ + "Email addresses", + "Usernames", + "Passwords", + "IP addresses", + ], + _status: "synced", + id: "047940fe-d2fd-4314-b636-b4a952ee0044", + last_modified: "1541615610052", + schema: "1541615609018", + }, + ]; + async function emitSync() { + await RemoteSettings(LoginBreaches.REMOTE_SETTINGS_COLLECTION).emit( + "sync", + { data: { current: nowExampleIsInBreachedRecords } } + ); + } + + const beforeSyncBreachesByLoginGUID = + await LoginBreaches.getPotentialBreachesByLoginGUID([login]); + Assert.strictEqual( + beforeSyncBreachesByLoginGUID.size, + 0, + "Should be 0 breached login before not-breached-subdomain.host.com is added to fxmonitor-breaches collection and synced: " + ); + gBrowserGlue.observe(null, "browser-glue-test", "add-breaches-sync-handler"); + const db = RemoteSettings(LoginBreaches.REMOTE_SETTINGS_COLLECTION).db; + await db.importChanges({}, Date.now(), [nowExampleIsInBreachedRecords[0]]); + await emitSync(); + + const breachesByLoginGUID = + await LoginBreaches.getPotentialBreachesByLoginGUID([login]); + Assert.strictEqual( + breachesByLoginGUID.size, + 1, + "Should be 1 breached login after not-breached-subdomain.host.com is added to fxmonitor-breaches collection and synced: " + ); +}); diff --git a/browser/components/aboutlogins/tests/unit/xpcshell.ini b/browser/components/aboutlogins/tests/unit/xpcshell.ini new file mode 100644 index 0000000000..e827d6d688 --- /dev/null +++ b/browser/components/aboutlogins/tests/unit/xpcshell.ini @@ -0,0 +1,7 @@ +[DEFAULT] +skip-if = toolkit == 'android' # bug 1730213 +head = head.js +firefox-appdir = browser + +[test_getPotentialBreachesByLoginGUID.js] +tags = remote-settings |