const DIRECTORY_PATH = "/browser/toolkit/components/passwordmgr/test/browser/"; ChromeUtils.import("resource://gre/modules/LoginHelper.jsm", this); const { LoginManagerParent } = ChromeUtils.import( "resource://gre/modules/LoginManagerParent.jsm" ); ChromeUtils.import("resource://testing-common/LoginTestUtils.jsm", this); ChromeUtils.import("resource://testing-common/ContentTaskUtils.jsm", this); ChromeUtils.import("resource://testing-common/TelemetryTestUtils.jsm", this); add_task(async function common_initialize() { await SpecialPowers.pushPrefEnv({ set: [ ["signon.rememberSignons", true], ["toolkit.telemetry.ipcBatchTimeout", 0], ], }); }); registerCleanupFunction( async function cleanup_removeAllLoginsAndResetRecipes() { await SpecialPowers.popPrefEnv(); LoginTestUtils.clearData(); LoginTestUtils.resetGeneratedPasswordsCache(); clearHttpAuths(); Services.telemetry.clearEvents(); let recipeParent = LoginTestUtils.recipes.getRecipeParent(); if (!recipeParent) { // No need to reset the recipes if the recipe module wasn't even loaded. return; } await recipeParent.then(recipeParentResult => recipeParentResult.reset()); await cleanupDoorhanger(); let notif; while ((notif = PopupNotifications.getNotification("password"))) { notif.remove(); } await Promise.resolve(); } ); /** * Compared logins in storage to expected values * * @param {array} expectedLogins * An array of expected login properties * @return {nsILoginInfo[]} - All saved logins sorted by timeCreated */ function verifyLogins(expectedLogins = []) { let allLogins = Services.logins.getAllLogins(); allLogins.sort((a, b) => a.timeCreated > b.timeCreated); is( allLogins.length, expectedLogins.length, "Check actual number of logins matches the number of provided expected property-sets" ); for (let i = 0; i < expectedLogins.length; i++) { // if the test doesn't care about comparing properties for this login, just pass false/null. let expected = expectedLogins[i]; if (expected) { let login = allLogins[i]; if (typeof expected.timesUsed !== "undefined") { is(login.timesUsed, expected.timesUsed, "Check timesUsed"); } if (typeof expected.passwordLength !== "undefined") { is( login.password.length, expected.passwordLength, "Check passwordLength" ); } if (typeof expected.username !== "undefined") { is(login.username, expected.username, "Check username"); } if (typeof expected.password !== "undefined") { is(login.password, expected.password, "Check password"); } if (typeof expected.usedSince !== "undefined") { ok(login.timeLastUsed > expected.usedSince, "Check timeLastUsed"); } if (typeof expected.passwordChangedSince !== "undefined") { ok( login.timePasswordChanged > expected.passwordChangedSince, "Check timePasswordChanged" ); } } } return allLogins; } /** * Submit the content form and return a promise resolving to the username and * password values echoed out in the response * * @param {Object} [browser] - browser with the form * @param {String = ""} formAction - Optional url to set the form's action to before submitting * @param {Object = null} selectorValues - Optional object with field values to set before form submission * @param {Object = null} responseSelectors - Optional object with selectors to find the username and password in the response */ async function submitFormAndGetResults( browser, formAction = "", selectorValues, responseSelectors ) { function contentSubmitForm([contentFormAction, contentSelectorValues]) { let doc = content.document; let form = doc.querySelector("form"); if (contentFormAction) { form.action = contentFormAction; } for (let [sel, value] of Object.entries(contentSelectorValues)) { try { doc.querySelector(sel).setUserInput(value); } catch (ex) { throw new Error(`submitForm: Couldn't set value of field at: ${sel}`); } } form.submit(); } await ContentTask.spawn( browser, [formAction, selectorValues], contentSubmitForm ); let result = await getFormSubmitResponseResult( browser, formAction, responseSelectors ); return result; } /** * Wait for a given result page to load and return a promise resolving to an object with the parsed-out * username/password values from the response * * @param {Object} [browser] - browser which is loading this page * @param {String} resultURL - the path or filename to look for in the content.location * @param {Object = null} - Optional object with selectors to find the username and password in the response */ async function getFormSubmitResponseResult( browser, resultURL = "/formsubmit.sjs", { username = "#user", password = "#pass" } = {} ) { // default selectors are for the response page produced by formsubmit.sjs let fieldValues = await ContentTask.spawn( browser, { resultURL, usernameSelector: username, passwordSelector: password, }, async function({ resultURL, usernameSelector, passwordSelector }) { await ContentTaskUtils.waitForCondition(() => { return ( content.location.pathname.endsWith(resultURL) && content.document.readyState == "complete" ); }, `Wait for form submission load (${resultURL})`); let username = content.document.querySelector(usernameSelector) .textContent; let password = content.document.querySelector(passwordSelector) .textContent; return { username, password, }; } ); return fieldValues; } /** * Loads a test page in `DIRECTORY_URL` which automatically submits to formsubmit.sjs and returns a * promise resolving with the field values when the optional `aTaskFn` is done. * * @param {String} aPageFile - test page file name which auto-submits to formsubmit.sjs * @param {Function} aTaskFn - task which can be run before the tab closes. * @param {String} [aOrigin="http://example.com"] - origin of the server to use * to load `aPageFile`. */ function testSubmittingLoginForm( aPageFile, aTaskFn, aOrigin = "http://example.com" ) { return BrowserTestUtils.withNewTab( { gBrowser, url: aOrigin + DIRECTORY_PATH + aPageFile, }, async function(browser) { ok(true, "loaded " + aPageFile); let fieldValues = await getFormSubmitResponseResult( browser, "/formsubmit.sjs" ); ok(true, "form submission loaded"); if (aTaskFn) { await aTaskFn(fieldValues); } return fieldValues; } ); } function checkOnlyLoginWasUsedTwice({ justChanged }) { // Check to make sure we updated the timestamps and use count on the // existing login that was submitted for the test. let logins = Services.logins.getAllLogins(); is(logins.length, 1, "Should only have 1 login"); ok(logins[0] instanceof Ci.nsILoginMetaInfo, "metainfo QI"); is(logins[0].timesUsed, 2, "check .timesUsed for existing login submission"); ok(logins[0].timeCreated < logins[0].timeLastUsed, "timeLastUsed bumped"); if (justChanged) { is( logins[0].timeLastUsed, logins[0].timePasswordChanged, "timeLastUsed == timePasswordChanged" ); } else { is( logins[0].timeCreated, logins[0].timePasswordChanged, "timeChanged not updated" ); } } function clearHttpAuths() { let authMgr = Cc["@mozilla.org/network/http-auth-manager;1"].getService( Ci.nsIHttpAuthManager ); authMgr.clearAll(); } // Begin popup notification (doorhanger) functions // const REMEMBER_BUTTON = "button"; const NEVER_MENUITEM = 0; const CHANGE_BUTTON = "button"; const DONT_CHANGE_BUTTON = "secondaryButton"; /** * Checks if we have a password capture popup notification * of the right type and with the right label. * * @param {String} aKind The desired `passwordNotificationType` * @param {Object} [popupNotifications = PopupNotifications] * @param {Object} [browser = null] Optional browser whose notifications should be searched. * @return the found password popup notification. */ function getCaptureDoorhanger( aKind, popupNotifications = PopupNotifications, browser = null ) { ok(true, "Looking for " + aKind + " popup notification"); let notification = popupNotifications.getNotification("password", browser); if (notification) { is( notification.options.passwordNotificationType, aKind, "Notification type matches." ); if (aKind == "password-change") { is( notification.mainAction.label, "Update", "Main action label matches update doorhanger." ); } else if (aKind == "password-save") { is( notification.mainAction.label, "Save", "Main action label matches save doorhanger." ); } } return notification; } async function getCaptureDoorhangerThatMayOpen( aKind, popupNotifications = PopupNotifications, browser = null ) { let notif = getCaptureDoorhanger(aKind, popupNotifications, browser); if (notif && !notif.dismissed) { if (popupNotifications.panel.state !== "open") { await BrowserTestUtils.waitForEvent( popupNotifications.panel, "popupshown" ); } } return notif; } function getDoorhangerButton(aPopup, aButtonIndex) { let notifications = aPopup.owner.panel.children; ok(!!notifications.length, "at least one notification displayed"); ok(true, notifications.length + " notification(s)"); let notification = notifications[0]; if (aButtonIndex == "button") { return notification.button; } else if (aButtonIndex == "secondaryButton") { return notification.secondaryButton; } return notification.menupopup.querySelectorAll("menuitem")[aButtonIndex]; } /** * Clicks the specified popup notification button. * * @param {Element} aPopup Popup Notification element * @param {Number} aButtonIndex Number indicating which button to click. * See the constants in this file. */ function clickDoorhangerButton(aPopup, aButtonIndex) { ok(true, "Looking for action at index " + aButtonIndex); let button = getDoorhangerButton(aPopup, aButtonIndex); if (aButtonIndex == "button") { ok(true, "Triggering main action"); } else if (aButtonIndex == "secondaryButton") { ok(true, "Triggering secondary action"); } else { ok(true, "Triggering menuitem # " + aButtonIndex); } button.doCommand(); } async function cleanupDoorhanger(notif) { let PN = notif ? notif.owner : PopupNotifications; if (notif) { notif.remove(); } let promiseHidden = PN.isPanelOpen ? BrowserTestUtils.waitForEvent(PN.panel, "popuphidden") : Promise.resolve(); PN.panel.hidePopup(); await promiseHidden; } /** * Checks the doorhanger's username and password. * * @param {String} username The username. * @param {String} password The password. */ async function checkDoorhangerUsernamePassword(username, password) { await BrowserTestUtils.waitForCondition(() => { return ( document.getElementById("password-notification-username").value == username ); }, "Wait for nsLoginManagerPrompter writeDataToUI()"); is( document.getElementById("password-notification-username").value, username, "Check doorhanger username" ); is( document.getElementById("password-notification-password").value, password, "Check doorhanger password" ); } /** * Change the doorhanger's username and password input values. * * @param {object} newValues * named values to update * @param {string} [newValues.password = undefined] * An optional string value to replace whatever is in the password field * @param {string} [newValues.username = undefined] * An optional string value to replace whatever is in the username field */ async function updateDoorhangerInputValues(newValues) { let { panel } = PopupNotifications; is(panel.state, "open", "Check the doorhanger is already open"); let notifElem = panel.childNodes[0]; // Note: setUserInput does not reliably dispatch input events from chrome elements? async function setInputValue(target, value) { info(`setInputValue: on target: ${target.id}, value: ${value}`); target.focus(); target.select(); await EventUtils.synthesizeKey("KEY_Backspace"); info( `setInputValue: target.value: ${target.value}, sending new value string` ); await EventUtils.sendString(value); await EventUtils.synthesizeKey("KEY_Tab"); return Promise.resolve(); } let passwordField = notifElem.querySelector( "#password-notification-password" ); let usernameField = notifElem.querySelector( "#password-notification-username" ); if (typeof newValues.password !== "undefined") { if (passwordField.value !== newValues.password) { await setInputValue(passwordField, newValues.password); } } if (typeof newValues.username !== "undefined") { if (usernameField.value !== newValues.username) { await setInputValue(usernameField, newValues.username); } } } // End popup notification (doorhanger) functions // async function waitForPasswordManagerDialog(openingFunc) { let win; await openingFunc(); await TestUtils.waitForCondition(() => { win = Services.wm.getMostRecentWindow("Toolkit:PasswordManager"); return win && win.document.getElementById("filter"); }, "Waiting for the password manager dialog to open"); return { filterValue: win.document.getElementById("filter").value, async close() { await BrowserTestUtils.closeWindow(win); }, }; } async function waitForPasswordManagerTab(openingFunc, waitForFilter) { info("waiting for new tab to open"); let tabPromise = BrowserTestUtils.waitForNewTab( gBrowser, url => url.includes("about:logins") && !url.includes("entryPoint=") ); await openingFunc(); let tab = await tabPromise; ok(tab, "got password management tab"); let filterValue; if (waitForFilter) { filterValue = await ContentTask.spawn(tab.linkedBrowser, null, async () => { let loginFilter = Cu.waiveXrays( content.document.querySelector("login-filter") ); await ContentTaskUtils.waitForCondition( () => !!loginFilter.value, "wait for login-filter to have a value" ); return loginFilter.value; }); } return { filterValue, close() { BrowserTestUtils.removeTab(tab); }, }; } function openPasswordManager(openingFunc, waitForFilter) { return Services.prefs.getCharPref("signon.management.overrideURI") ? waitForPasswordManagerTab(openingFunc, waitForFilter) : waitForPasswordManagerDialog(openingFunc); } // Autocomplete popup related functions // async function openACPopup(popup, browser, inputSelector) { let promiseShown = BrowserTestUtils.waitForEvent(popup, "popupshown"); await SimpleTest.promiseFocus(browser); info("content window focused"); // Focus the username field to open the popup. await ContentTask.spawn(browser, [inputSelector], function openAutocomplete( sel ) { content.document.querySelector(sel).focus(); }); let shown = await promiseShown; ok(shown, "autocomplete popup shown"); return shown; } // Contextmenu functions // /** * Synthesize mouse clicks to open the password manager context menu popup * for a target password input element. * * assertCallback should return true if we should continue or else false. */ async function openPasswordContextMenu( browser, passwordInput, assertCallback = null, browsingContext = null ) { const doc = browser.ownerDocument; const CONTEXT_MENU = doc.getElementById("contentAreaContextMenu"); const POPUP_HEADER = doc.getElementById("fill-login"); const LOGIN_POPUP = doc.getElementById("fill-login-popup"); if (!browsingContext) { browsingContext = browser.browsingContext; } let contextMenuShownPromise = BrowserTestUtils.waitForEvent( CONTEXT_MENU, "popupshown" ); // 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 BrowserTestUtils.synthesizeMouseAtCenter( passwordInput, eventDetails, browsingContext ); // Synthesize a contextmenu event to actually open the context menu. eventDetails = { type: "contextmenu", button: 2 }; await BrowserTestUtils.synthesizeMouseAtCenter( passwordInput, eventDetails, browsingContext ); await contextMenuShownPromise; if (assertCallback) { let shouldContinue = await assertCallback(); if (!shouldContinue) { return; } } // Synthesize a mouse click over the fill login menu header. let popupShownPromise = BrowserTestUtils.waitForCondition( () => POPUP_HEADER.open && BrowserTestUtils.is_visible(LOGIN_POPUP) ); EventUtils.synthesizeMouseAtCenter(POPUP_HEADER, {}, browser.ownerGlobal); await popupShownPromise; } /** * Listen for the login manager test notification specified by * expectedMessage. Possible messages: * FormProcessed - a form was processed after page load. * FormSubmit - a form was just submitted. * PasswordFilledOrEdited - a password was filled in or modified. * * The count is the number of that messages to wait for. This should * typically be used when waiting for the FormProcessed message for a page * that has subframes to ensure all have been handled. * * Returns a promise that will passed additional data specific to the message. */ function listenForTestNotification(expectedMessage, count = 1) { return new Promise(resolve => { LoginManagerParent.setListenerForTests((msg, data) => { if (msg == expectedMessage && --count == 0) { LoginManagerParent.setListenerForTests(null); resolve(data); } }); }); } /** * Use the contextmenu to fill a field with a generated password */ async function doFillGeneratedPasswordContextMenuItem(browser, passwordInput) { await SimpleTest.promiseFocus(browser); await openPasswordContextMenu(browser, passwordInput); let loginPopup = document.getElementById("fill-login-popup"); let generatedPasswordItem = document.getElementById( "fill-login-generated-password" ); let generatedPasswordSeparator = document.getElementById( "generated-password-separator" ); // Check the content of the password manager popup ok(BrowserTestUtils.is_visible(loginPopup), "Popup is visible"); ok( BrowserTestUtils.is_visible(generatedPasswordItem), "generated password item is visible" ); ok( BrowserTestUtils.is_visible(generatedPasswordSeparator), "separator is visible" ); let passwordChangedPromise = ContentTask.spawn( browser, [passwordInput], async function(passwordInput) { let input = content.document.querySelector(passwordInput); await ContentTaskUtils.waitForEvent(input, "input"); } ); let passwordGeneratedPromise = listenForTestNotification( "PasswordFilledOrEdited" ); await new Promise(resolve => { SimpleTest.executeSoon(resolve); }); EventUtils.synthesizeMouseAtCenter(generatedPasswordItem, {}); info( "doFillGeneratedPasswordContextMenuItem: Waiting for content input event" ); await passwordChangedPromise; await passwordGeneratedPromise; }