fune/toolkit/components/passwordmgr/test/browser/head.js
2019-10-21 18:18:02 +00:00

636 lines
19 KiB
JavaScript

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;
}